Iteration 4b: save / save as / new / load with project switching

Adds the rest of the track-2 lifecycle commands (ADR-0015 §11)
and the project-switching machinery they need at runtime.

Temp vs named distinction: replaced the fragile naming heuristic
with an explicit `[temp]` marker in the directory pattern
(`<YYYYMMDD>-[temp]-<word>-<word>-<word>`). validate_user_name
already rejects brackets, so user-typed names can never collide
with a temp marker. The status bar shows `[TEMP] <Display Name>`
for temp projects; the prettifier strips both the date and the
marker so display names are clean.

save / save as: temp project's `save` opens a path-entry modal
(acts as save as); named project's `save` reports "already
auto-saved; use `save as`". `save as` always prompts. Relative
names resolve under <data-root>/projects/; absolute paths used
as-is. Copy excludes the per-process lock file; everything else
(.db, yaml, csvs, history.log) is copied.

new: closes current project, creates a fresh auto-named temp,
switches.

load: opens a picker. List sub-mode shows projects in the active
data root, sorted newest-first by project.yaml mtime; arrow keys
navigate, Enter loads, `b` switches to a path-entry sub-mode for
projects elsewhere, Esc cancels. Empty data root jumps straight
to path entry.

Runtime: `Session` holds Option<Project> + Option<Database> so
project switches can drop old (releasing lock + stopping worker)
before opening new -- required for the "load my own current
project" case. `perform_switch` handles Load / SaveAs / NewTemp
uniformly.

Tests: 332 passing (270 lib + 9 + 5 + 6 + 16 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-08 06:23:46 +00:00
parent ba93d3c7d8
commit f2198275f0
9 changed files with 1376 additions and 44 deletions
+23
View File
@@ -36,4 +36,27 @@ pub enum Action {
Rebuild {
source: String,
},
/// Open the load-picker modal. Runtime lists projects in
/// the active data root and posts back as
/// `AppEvent::LoadPickerReady`.
OpenLoadPicker,
/// Switch to the project at `path` (absolute or relative
/// to the active data root). Runtime drops the current
/// project, opens the new one, refreshes app state.
LoadProject {
path: std::path::PathBuf,
source: String,
},
/// Save the current project to `target` and switch to it.
/// `target` is a name or absolute path; relative names
/// resolve against `<data-root>/projects/` per ADR-0015 §1.
SaveAs {
target: String,
source: String,
},
/// Close the current project (auto-save guarantees state
/// is on disk) and create a fresh auto-named temp.
NewProject {
source: String,
},
}
+349 -13
View File
@@ -106,6 +106,10 @@ pub struct App {
/// during very-early startup before the runtime has opened a
/// project; otherwise always populated.
pub project_name: Option<String>,
/// Whether the open project is auto-named temporary or
/// user-named permanent. Drives the `[TEMP]` prefix in the
/// status bar and the `save` command's behaviour.
pub project_is_temp: bool,
/// Set when a fatal persistence failure has occurred
/// (ADR-0015 §8). The runtime reads this after the event
/// loop exits and prints it to stderr post-teardown so the
@@ -130,6 +134,13 @@ pub enum Modal {
/// `rebuild` confirmation. Shows a summary of what would
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
RebuildConfirm(RebuildConfirmModal),
/// One-line text prompt used by `save` / `save as` for
/// the target name/path.
PathEntry(PathEntryModal),
/// Load picker. Shows a list of projects in the active
/// data root; `b` switches to a path-entry sub-mode for
/// projects outside the data root (ADR-0015 §7).
LoadPicker(LoadPickerModal),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -139,6 +150,53 @@ pub struct RebuildConfirmModal {
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathEntryModal {
pub title: String,
pub prompt: String,
pub input: String,
/// Byte offset of the insertion point inside `input`.
pub cursor: usize,
pub purpose: PathEntryPurpose,
}
/// What the runtime should do with the path the user typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathEntryPurpose {
/// Save the current project to the typed name/path.
/// Relative names resolve against `<data-root>/projects/`.
SaveAs,
/// Load the project at the typed path. Used by the load
/// picker's `b` (browse) sub-mode.
LoadByPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadPickerModal {
pub entries: Vec<LoadPickerEntry>,
pub selected: usize,
/// Sub-mode: list-of-recents (default) or path-entry
/// (after `b`).
pub sub_mode: LoadPickerSubMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadPickerEntry {
pub display_name: String,
pub modified: String,
pub path: std::path::PathBuf,
pub is_temp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadPickerSubMode {
List,
/// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`.
PathEntry { input: String, cursor: usize },
}
const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000;
@@ -167,6 +225,7 @@ impl App {
last_output_visible: 0,
last_output_total_wrapped: 0,
project_name: None,
project_is_temp: false,
fatal_message: None,
modal: None,
}
@@ -269,6 +328,43 @@ impl App {
self.note_error(format!("rebuild failed: {error}"));
Vec::new()
}
AppEvent::LoadPickerReady { entries } => {
if entries.is_empty() {
// Empty data root: jump straight to path-entry
// mode so the user can still browse to a
// project elsewhere.
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries,
selected: 0,
sub_mode: LoadPickerSubMode::PathEntry {
input: String::new(),
cursor: 0,
},
}));
} else {
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries,
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
}
Vec::new()
}
AppEvent::ProjectSwitched {
display_name,
is_temp,
} => {
self.note_system(format!("[ok] now editing: {display_name}"));
self.project_name = Some(display_name);
self.project_is_temp = is_temp;
self.tables.clear();
self.current_table = None;
Vec::new()
}
AppEvent::ProjectSwitchFailed { error } => {
self.note_error(format!("project switch failed: {error}"));
Vec::new()
}
}
}
@@ -497,12 +593,26 @@ impl App {
}
// Canonical app-level commands recognised in both modes.
// Track-2's full lifecycle command set (`save`, `load`,
// `new`, `export`, `import`) lands across Iterations 4
// and 5; this iteration adds `rebuild`.
// Track-2's full lifecycle command set lands across
// Iterations 4 (rebuild, save, save as, new, load) and
// 5 (export, import).
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
"rebuild" => return vec![Action::PrepareRebuild],
"save" => {
return self.handle_save_command(false);
}
"save as" => {
return self.handle_save_command(true);
}
"new" => {
return vec![Action::NewProject {
source: "new".to_string(),
}];
}
"load" => {
return vec![Action::OpenLoadPicker];
}
other if other.starts_with("mode") => {
self.handle_mode_command(other);
return Vec::new();
@@ -660,6 +770,30 @@ impl App {
));
}
/// Dispatch for the `save` and `save as` commands.
///
/// `save` on a temp project is identical to `save as`
/// (prompts for a target). `save` on a named project is a
/// no-op with a friendly hint, since auto-save guarantees
/// the named project is already persistent (ADR-0015 §11).
fn handle_save_command(&mut self, force_save_as: bool) -> Vec<Action> {
if !force_save_as && !self.project_is_temp {
self.note_system(
"already auto-saved; use `save as` to copy to a different location",
);
return Vec::new();
}
let title = if force_save_as { "Save as" } else { "Save" };
self.modal = Some(Modal::PathEntry(PathEntryModal {
title: title.to_string(),
prompt: "Name (under data dir/projects) or absolute path:".to_string(),
input: String::new(),
cursor: 0,
purpose: PathEntryPurpose::SaveAs,
}));
Vec::new()
}
/// Route a keypress through whichever modal is active.
///
/// Each modal owns its own tiny state machine. On
@@ -667,23 +801,225 @@ impl App {
/// for the runtime to enact. On dismissal it simply
/// closes itself.
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
let Some(modal) = self.modal.as_ref() else {
let Some(modal) = self.modal.clone() else {
return Vec::new();
};
match modal {
Modal::RebuildConfirm(_) => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.modal = None;
vec![Action::Rebuild {
source: "rebuild".to_string(),
}]
Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key),
Modal::PathEntry(state) => self.handle_path_entry_key(key, state),
Modal::LoadPicker(state) => self.handle_load_picker_key(key, state),
}
}
fn handle_rebuild_confirm_key(&mut self, key: KeyEvent) -> Vec<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.modal = None;
vec![Action::Rebuild {
source: "rebuild".to_string(),
}]
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.modal = None;
self.note_system("rebuild cancelled");
Vec::new()
}
_ => Vec::new(),
}
}
fn handle_path_entry_key(
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
match key.code {
KeyCode::Esc => {
self.modal = None;
self.note_system(format!("{} cancelled", state.title.to_lowercase()));
Vec::new()
}
KeyCode::Enter => {
let target = state.input.trim().to_string();
if target.is_empty() {
self.note_error("path entry: empty name");
self.modal = Some(Modal::PathEntry(state));
return Vec::new();
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.modal = None;
match state.purpose {
PathEntryPurpose::SaveAs => vec![Action::SaveAs {
target,
source: "save as".to_string(),
}],
PathEntryPurpose::LoadByPath => vec![Action::LoadProject {
path: std::path::PathBuf::from(target),
source: "load".to_string(),
}],
}
}
KeyCode::Char(c) => {
state.input.insert(state.cursor, c);
state.cursor += c.len_utf8();
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Backspace => {
if state.cursor > 0 {
let before = state.input[..state.cursor].chars().next_back();
if let Some(c) = before {
let new_cursor = state.cursor - c.len_utf8();
state.input.drain(new_cursor..state.cursor);
state.cursor = new_cursor;
}
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Left => {
if state.cursor > 0
&& let Some(c) = state.input[..state.cursor].chars().next_back()
{
state.cursor -= c.len_utf8();
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Right => {
if state.cursor < state.input.len()
&& let Some(c) = state.input[state.cursor..].chars().next()
{
state.cursor += c.len_utf8();
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Home => {
state.cursor = 0;
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::End => {
state.cursor = state.input.len();
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
}
}
fn handle_load_picker_key(
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code {
KeyCode::Esc => {
self.modal = None;
self.note_system("rebuild cancelled");
self.note_system("load cancelled");
Vec::new()
}
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Down => {
if state.selected + 1 < state.entries.len() {
state.selected += 1;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Enter => {
if let Some(entry) = state.entries.get(state.selected).cloned() {
self.modal = None;
return vec![Action::LoadProject {
path: entry.path,
source: "load".to_string(),
}];
}
self.note_error("nothing to load");
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Char('b') | KeyCode::Char('B') => {
state.sub_mode = LoadPickerSubMode::PathEntry {
input: String::new(),
cursor: 0,
};
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
},
LoadPickerSubMode::PathEntry { input, cursor } => match key.code {
KeyCode::Esc => {
state.sub_mode = LoadPickerSubMode::List;
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Enter => {
let target = input.trim().to_string();
if target.is_empty() {
self.note_error("path entry: empty path");
self.modal = Some(Modal::LoadPicker(state));
return Vec::new();
}
self.modal = None;
vec![Action::LoadProject {
path: std::path::PathBuf::from(target),
source: "load".to_string(),
}]
}
KeyCode::Char(c) => {
input.insert(*cursor, c);
*cursor += c.len_utf8();
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Backspace => {
if *cursor > 0
&& let Some(c) = input[..*cursor].chars().next_back()
{
let new_cursor = *cursor - c.len_utf8();
input.drain(new_cursor..*cursor);
*cursor = new_cursor;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Left => {
if *cursor > 0
&& let Some(c) = input[..*cursor].chars().next_back()
{
*cursor -= c.len_utf8();
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Right => {
if *cursor < input.len()
&& let Some(c) = input[*cursor..].chars().next()
{
*cursor += c.len_utf8();
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
_ => Vec::new(),
},
}
}
+18
View File
@@ -72,4 +72,22 @@ pub enum AppEvent {
RebuildFailed {
error: String,
},
/// Runtime has gathered the list of available projects
/// for the load picker. App opens the picker modal.
LoadPickerReady {
entries: Vec<crate::app::LoadPickerEntry>,
},
/// A project switch (load / new / save-as) succeeded.
/// Carries the new display name + temp flag so App can
/// update the status bar.
ProjectSwitched {
display_name: String,
is_temp: bool,
},
/// A project switch failed in a non-fatal way (target
/// already exists, path unreadable, …). Surfaced as an
/// error in the output panel.
ProjectSwitchFailed {
error: String,
},
}
+140
View File
@@ -70,6 +70,78 @@ pub fn projects_dir(data_root: &Path) -> PathBuf {
data_root.join(PROJECTS_SUBDIR)
}
/// One entry the load picker shows. Contains the full path
/// (so the runtime can switch to it) plus the cached display
/// metadata (so the picker doesn't have to re-prettify on
/// every redraw).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectListing {
pub path: PathBuf,
pub display_name: String,
/// The `mtime` of `project.yaml`, formatted as
/// `YYYY-MM-DD HH:MM` for display in the picker. Falls
/// back to "" if the metadata can't be read.
pub modified: String,
pub kind: ProjectKind,
}
/// List the projects available to the load picker.
///
/// Walks `<data_root>/projects/` and returns one
/// `ProjectListing` per child directory that looks like a
/// project (has a `project.yaml`). Sorted newest-first by
/// `project.yaml` mtime.
pub fn list_projects(data_root: &Path) -> Vec<ProjectListing> {
let dir = projects_dir(data_root);
let Ok(entries) = fs::read_dir(&dir) else {
return Vec::new();
};
let mut listings: Vec<(std::time::SystemTime, ProjectListing)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let yaml = path.join(PROJECT_YAML);
if !yaml.exists() {
continue;
}
let mtime = yaml
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
let dirname = directory_name(&path);
let display_name = prettifier::prettify(&dirname);
let modified = format_modified(mtime);
let kind = if naming::is_temp_dirname(&dirname) {
ProjectKind::Temp
} else {
ProjectKind::Named
};
listings.push((
mtime,
ProjectListing {
path,
display_name,
modified,
kind,
},
));
}
// Sort newest mtime first.
listings.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
listings.into_iter().map(|(_, l)| l).collect()
}
fn format_modified(t: std::time::SystemTime) -> String {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let day_secs = secs.rem_euclid(86_400);
let h = day_secs / 3600;
let m = (day_secs % 3600) / 60;
let (y, mo, d) = naming_ymd(secs);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
}
/// Iteration-1 startup logic (ADR-0015 §1):
///
/// - If `project_path` is `Some`, open that project (refused if
@@ -98,10 +170,24 @@ pub fn open_or_create(
pub struct Project {
path: PathBuf,
display_name: String,
kind: ProjectKind,
/// Held for the project's lifetime; released on drop.
_lock: Lock,
}
/// Whether this project is auto-named temporary or
/// user-named permanent (ADR-0015 §1, §11).
///
/// The distinction drives the `save` command's behaviour:
/// for temp projects it elevates to a named project (== `save
/// as`); for named projects it reports "already auto-saved"
/// since no work has been done that wasn't already persisted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectKind {
Temp,
Named,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
@@ -150,6 +236,7 @@ impl Project {
Ok(Self {
path,
display_name,
kind: ProjectKind::Temp,
_lock: lock,
})
}
@@ -174,6 +261,7 @@ impl Project {
Ok(Self {
path: path.to_path_buf(),
display_name,
kind: ProjectKind::Named,
_lock: lock,
})
}
@@ -193,11 +281,17 @@ impl Project {
}
let dirname = directory_name(path);
let display_name = prettifier::prettify(&dirname);
let kind = if naming::is_temp_dirname(&dirname) {
ProjectKind::Temp
} else {
ProjectKind::Named
};
let lock = Lock::acquire(path)?;
info!(path = %path.display(), name = %display_name, "opened project");
Ok(Self {
path: path.to_path_buf(),
display_name,
kind,
_lock: lock,
})
}
@@ -247,6 +341,11 @@ impl Project {
&self.display_name
}
#[must_use]
pub const fn kind(&self) -> ProjectKind {
self.kind
}
/// Path to the SQLite database for this project. Always
/// `<project>/playground.db`.
#[must_use]
@@ -255,6 +354,47 @@ impl Project {
}
}
/// Copy a project directory to a new location.
///
/// Used by `save` / `save as` (ADR-0015 §11). Excludes the
/// per-process lock file (a fresh one is acquired when the
/// destination project is opened); copies everything else
/// including `playground.db`. The target path must not
/// already exist (per the §2 collision rule); the caller is
/// expected to validate that before invoking this helper.
pub fn copy_project(src: &Path, dst: &Path) -> Result<(), ProjectError> {
if dst.exists() {
return Err(ProjectError::AlreadyExists(dst.to_path_buf()));
}
copy_dir_recursive(src, dst).map_err(|source| ProjectError::Io {
path: dst.to_path_buf(),
source,
})?;
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
if name == ".rdbms-playground.lock" {
// Per-process artifact; the destination project
// will write a fresh one when opened.
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&name);
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
/// Heuristic for "does this directory look like an
/// rdbms-playground project?" — used by `Project::open` to
/// reject obviously-wrong CLI arguments before we try to
+58 -13
View File
@@ -1,13 +1,19 @@
//! Generate temp project directory names (P-NAME-1, ADR-0015 §2).
//!
//! Output pattern: `<YYYYMMDD>-<word>-<word>-<word>` where the
//! three words are distinct picks from a small built-in
//! wordlist compiled into the binary. Collisions against
//! existing entries in the data root are detected and the
//! slug is regenerated; we cap retries at a generous number to
//! turn the theoretical never-give-up loop into a clean
//! failure if something is profoundly wrong (e.g. wordlist
//! inadvertently truncated to a handful of items).
//! Output pattern: `<YYYYMMDD>-[temp]-<word>-<word>-<word>`
//! where the three words are distinct picks from a small
//! built-in wordlist compiled into the binary. The literal
//! `[temp]` segment is what marks a directory as an
//! auto-named temporary project — `validate_user_name` rejects
//! `[` and `]` for user-supplied names, so the marker can
//! never collide with a saved project's directory.
//!
//! Collisions against existing entries in the data root are
//! detected and the slug is regenerated; we cap retries at a
//! generous number to turn the theoretical never-give-up loop
//! into a clean failure if something is profoundly wrong
//! (e.g. wordlist inadvertently truncated to a handful of
//! items).
use std::path::Path;
@@ -58,7 +64,7 @@ pub fn generate_temp_name<R: Rng + ?Sized>(
for _ in 0..MAX_COLLISION_RETRIES {
let date = today();
let slug = three_distinct_words(rng, &pool);
let candidate = format!("{date}-{slug}");
let candidate = format!("{date}-{TEMP_MARKER}-{slug}");
if !parent_dir.join(&candidate).exists() {
return Ok(candidate);
}
@@ -66,6 +72,14 @@ pub fn generate_temp_name<R: Rng + ?Sized>(
Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES))
}
/// Literal segment marking a directory as a temp project.
///
/// Distinguishes auto-named temp projects from user-named
/// ones in the directory listing. Brackets are reserved
/// (rejected by `validate_user_name`) so this never collides
/// with a saved project's name.
pub const TEMP_MARKER: &str = "[temp]";
/// Pick three distinct words from the pool and join them with
/// `-`. Uses `choose_multiple` so the picks are always distinct
/// without needing manual deduplication.
@@ -114,6 +128,19 @@ const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) {
(y as u32, m as u32, d as u32)
}
/// Is this an auto-named temp project directory?
///
/// We look for the literal `[temp]` segment in the
/// dash-separated dirname. Because brackets are reserved
/// (rejected by `validate_user_name`), this is unambiguous: a
/// user-named project can never produce a directory whose
/// name contains `[temp]`. Dir-name-only check keeps the
/// load picker fast — no YAML parse needed per project.
#[must_use]
pub fn is_temp_dirname(dirname: &str) -> bool {
dirname.split('-').any(|seg| seg == TEMP_MARKER)
}
/// Validate a user-supplied project directory name.
///
/// Returns `Ok(())` if the name is acceptable, or an error
@@ -180,11 +207,15 @@ mod tests {
let tmp = tempdir();
let mut rng = StdRng::seed_from_u64(42);
let name = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
assert!(name.starts_with("20260507-"), "got: {name}");
let parts: Vec<&str> = name.splitn(4, '-').collect();
assert_eq!(parts.len(), 4, "expected date + 3 words, got: {name}");
let words_in_name: std::collections::HashSet<_> = parts[1..].iter().collect();
assert!(name.starts_with("20260507-[temp]-"), "got: {name}");
// After the date and the [temp] marker, three distinct
// word segments separated by `-`.
let segments: Vec<&str> = name.split('-').collect();
assert_eq!(segments[0], "20260507");
assert_eq!(segments[1], "[temp]");
let words_in_name: std::collections::HashSet<_> = segments[2..].iter().collect();
assert_eq!(words_in_name.len(), 3, "words must be distinct: {name}");
assert!(is_temp_dirname(&name));
}
#[test]
@@ -221,6 +252,20 @@ mod tests {
assert!(s.chars().all(|c| c.is_ascii_digit()), "today_local: {s}");
}
#[test]
fn is_temp_dirname_examples() {
// Generated form.
assert!(is_temp_dirname("20260507-[temp]-water-buffalo-skating"));
assert!(is_temp_dirname("19991231-[temp]-amber-cosmic-flying"));
// Named projects: cannot contain `[temp]` because
// validate_user_name rejects brackets.
assert!(!is_temp_dirname("MyOrders"));
assert!(!is_temp_dirname("term_planner"));
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
assert!(!is_temp_dirname("temp-project")); // no brackets
}
#[test]
fn validates_user_name() {
assert!(validate_user_name("MyProject").is_ok());
+17
View File
@@ -21,6 +21,7 @@
#[must_use]
pub fn prettify(dirname: &str) -> String {
let trimmed = strip_date_prefix(dirname);
let trimmed = strip_temp_marker(trimmed);
let words = split_into_words(trimmed);
words
.into_iter()
@@ -29,6 +30,14 @@ pub fn prettify(dirname: &str) -> String {
.join(" ")
}
/// Strip a leading `[temp]-` segment if present. Temp project
/// directory names look like `20260507-[temp]-water-buffalo-skating`
/// after `strip_date_prefix`, this removes the marker so the
/// display name reduces to just the three random words.
fn strip_temp_marker(s: &str) -> &str {
s.strip_prefix("[temp]-").unwrap_or(s)
}
/// Strip a leading `YYYYMMDD-` if present. Eight ASCII digits
/// followed by a single `-` are required; anything else is
/// returned unchanged.
@@ -123,6 +132,14 @@ mod tests {
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
}
#[test]
fn strips_date_and_temp_marker() {
assert_eq!(
prettify("20260507-[temp]-water-buffalo-skating"),
"Water Buffalo Skating",
);
}
#[test]
fn handles_pascal_case() {
assert_eq!(prettify("MyOrders"), "My Orders");
+259 -14
View File
@@ -32,7 +32,10 @@ use crate::db::{
};
use crate::dsl::Command;
use crate::event::AppEvent;
use crate::project::open_or_create;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
resolve_data_root,
};
use crate::theme::Theme;
use crate::ui;
@@ -42,11 +45,18 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(args: Args) -> Result<()> {
let project = open_or_create(args.project_path.as_deref(), args.data_dir.as_deref())
// Resolve data root explicitly so run_loop can refer back
// to it for `new` (creates a temp) and `load` (lists
// projects). We can't easily recover this from the
// Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref())
.context("resolve data root")?;
let project = open_or_create(args.project_path.as_deref(), Some(data_root.as_path()))
.context("open or create project")?;
let db_path = project.db_path();
let display_name = project.display_name().to_string();
let project_path = project.path().to_path_buf();
let project_is_temp = matches!(project.kind(), ProjectKind::Temp);
let persistence = crate::persistence::Persistence::new(project_path.clone());
// Capture whether the .db file existed BEFORE we open it —
// sqlite creates it on connect, so this is the only honest
@@ -78,18 +88,19 @@ pub async fn run(args: Args) -> Result<()> {
let result = run_loop(
&mut terminal,
args.theme,
database,
Session {
project: Some(project),
database: Some(database),
data_root,
},
display_name,
project_path,
project_is_temp,
)
.await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
warn!(error = %e, "terminal teardown failed");
}
// `project` (and the lock it holds) is dropped here, releasing
// the lock file *after* the terminal has been restored.
drop(project);
// ADR-0015 §8: a fatal persistence failure makes its
// banner visible above the shell prompt by writing to
@@ -100,24 +111,57 @@ pub async fn run(args: Args) -> Result<()> {
result.map(|_| ())
}
/// Mutable state owned by `run_loop` that survives project
/// switches: the live `Project` (with its lock), the live
/// `Database` (with its worker), and the active data root
/// for resolving relative paths in `save as` / `new` / load
/// picker listings.
///
/// `project` and `database` are wrapped in `Option` so a
/// project switch can `take()` the old (dropping its lock
/// and worker) before opening the new — required for the
/// "switch to my own current project" case where the new
/// open would otherwise see a self-held lock.
struct Session {
project: Option<Project>,
database: Option<Database>,
data_root: std::path::PathBuf,
}
impl Session {
const fn project(&self) -> &Project {
match self.project.as_ref() {
Some(p) => p,
None => panic!("project always set during run_loop"),
}
}
const fn database(&self) -> &Database {
match self.database.as_ref() {
Some(d) => d,
None => panic!("database always set during run_loop"),
}
}
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
mut session: Session,
project_display_name: String,
project_path: std::path::PathBuf,
project_is_temp: bool,
) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp;
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
seed_initial_tables(session.database(), &event_tx).await;
terminal
.draw(|f| ui::render(&mut app, &theme, f))
@@ -134,19 +178,67 @@ async fn run_loop(
should_quit = true;
}
Action::ExecuteDsl { command, source } => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
spawn_dsl_dispatch(
session.database().clone(),
event_tx.clone(),
command,
source,
);
}
Action::PrepareRebuild => {
spawn_prepare_rebuild(project_path.clone(), event_tx.clone());
spawn_prepare_rebuild(
session.project().path().to_path_buf(),
event_tx.clone(),
);
}
Action::Rebuild { source } => {
spawn_rebuild(
database.clone(),
project_path.clone(),
session.database().clone(),
session.project().path().to_path_buf(),
event_tx.clone(),
source,
);
}
Action::OpenLoadPicker => {
let entries: Vec<crate::app::LoadPickerEntry> =
list_projects(&session.data_root)
.into_iter()
.map(|p| crate::app::LoadPickerEntry {
display_name: p.display_name,
modified: p.modified,
path: p.path,
is_temp: matches!(p.kind, ProjectKind::Temp),
})
.collect();
let _ = event_tx.send(AppEvent::LoadPickerReady { entries }).await;
}
Action::LoadProject { path, source } => {
handle_project_switch(
&mut session,
SwitchRequest::Load { path },
source,
&event_tx,
)
.await;
}
Action::SaveAs { target, source } => {
handle_project_switch(
&mut session,
SwitchRequest::SaveAs { target },
source,
&event_tx,
)
.await;
}
Action::NewProject { source } => {
handle_project_switch(
&mut session,
SwitchRequest::NewTemp,
source,
&event_tx,
)
.await;
}
}
}
terminal
@@ -163,6 +255,159 @@ async fn run_loop(
Ok(app.fatal_message.clone())
}
/// What kind of project switch the user requested.
enum SwitchRequest {
/// `load` (picker or browse-to-path) — open an existing
/// project at `path`.
Load { path: std::path::PathBuf },
/// `save as` — copy the current project to a new
/// location, then open that copy. `target` is a name
/// (resolved under `<data-root>/projects/`) or an
/// absolute path.
SaveAs { target: String },
/// `new` — close current, create a fresh auto-named temp.
NewTemp,
}
/// Common project-switch path. Drops the current project +
/// database (releasing the lock and stopping the worker),
/// opens the new one, runs a rebuild if the .db is missing,
/// appends history.log, and sends a `ProjectSwitched` event
/// so App refreshes its display.
///
/// Errors are surfaced as `ProjectSwitchFailed` (non-fatal):
/// the current project remains active.
async fn handle_project_switch(
session: &mut Session,
req: SwitchRequest,
source: String,
event_tx: &mpsc::Sender<AppEvent>,
) {
match perform_switch(session, req, source).await {
Ok((display_name, is_temp)) => {
let _ = event_tx
.send(AppEvent::ProjectSwitched {
display_name,
is_temp,
})
.await;
if let Ok(tables) = session.database().list_tables().await {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
}
Err(e) => {
let _ = event_tx
.send(AppEvent::ProjectSwitchFailed { error: e })
.await;
}
}
}
async fn perform_switch(
session: &mut Session,
req: SwitchRequest,
source: String,
) -> Result<(String, bool), String> {
use crate::persistence::Persistence;
// For SaveAs we need a resolved target path up front
// (so the existence check happens before we drop the
// current project). For NewTemp we'll let create_temp
// pick the path. For Load it's the user-supplied path.
let resolved_target: Option<std::path::PathBuf> = match &req {
SwitchRequest::Load { path } => {
if !path.exists() {
return Err(format!("path `{}` does not exist", path.display()));
}
Some(path.clone())
}
SwitchRequest::SaveAs { target } => {
let p = resolve_save_target(target, &session.data_root);
if p.exists() {
return Err(format!(
"`{}` already exists; pick a different name or remove it first",
p.display(),
));
}
Some(p)
}
SwitchRequest::NewTemp => None,
};
// For SaveAs: copy current project to the target while
// the source is still on disk (auto-save guarantees its
// state matches the in-memory db).
if let SwitchRequest::SaveAs { .. } = &req {
let src = session.project().path().to_path_buf();
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
copy_project(&src, dst).map_err(|e| e.to_string())?;
}
// Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old
// worker. Required for the "load my own current project"
// case (otherwise the new open would see a self-held
// lock on this PID).
let _ = session.database.take();
let _ = session.project.take();
// Open the destination project.
let new_project = match &req {
SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => {
let path = resolved_target.expect("Load/SaveAs have resolved target");
Project::open(&path).map_err(|e| e.to_string())?
}
SwitchRequest::NewTemp => {
Project::create_temp(&session.data_root).map_err(|e| e.to_string())?
}
};
let new_path = new_project.path().to_path_buf();
// Open the new database (rebuild from text if .db is
// missing — applies to NewTemp's just-created project,
// and to Load when the user opened a project whose .db
// had been deleted).
let db_path = new_project.db_path();
let db_existed = db_path.exists();
let persistence = Persistence::new(new_path.clone());
let new_database =
Database::open_with_persistence(&db_path, persistence).map_err(|e| e.to_string())?;
if !db_existed
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
return Err(e.friendly_message());
}
let display_name = new_project.display_name().to_string();
let is_temp = matches!(new_project.kind(), ProjectKind::Temp);
session.project = Some(new_project);
session.database = Some(new_database);
// Append the user-issued command to the destination's
// history.log. The worker's persistence is wired but not
// directly addressable from here, so we use a fresh
// Persistence handle for this single line.
let _ = Persistence::new(new_path).append_history(&source);
Ok((display_name, is_temp))
}
/// Resolve a `save as` target path against the data root.
///
/// Absolute paths pass through; relative paths join under
/// `<data-root>/projects/` per the user's stated preference
/// in ADR-0015 §1 ("named projects right alongside the temp
/// ones is the easiest workflow").
fn resolve_save_target(target: &str, data_root: &std::path::Path) -> std::path::PathBuf {
let p = std::path::Path::new(target);
if p.is_absolute() {
p.to_path_buf()
} else {
projects_dir(data_root).join(p)
}
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
match database.list_tables().await {
Ok(tables) => {
+175 -4
View File
@@ -61,9 +61,173 @@ fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>,
use crate::app::Modal;
match modal {
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
Modal::PathEntry(m) => render_path_entry(m, theme, frame, area),
Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area),
}
}
fn render_path_entry(
m: &crate::app::PathEntryModal,
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
) {
let dialog_w = area.width.clamp(20, 70);
let inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.fg))
.title(Line::from(vec![Span::styled(
format!(" {} ", m.title),
title_style,
)]))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let mut text_lines: Vec<Line<'_>> = Vec::new();
text_lines.push(Line::from(""));
for line in prompt_lines {
text_lines.push(Line::from(line));
}
text_lines.push(Line::from(""));
let cursor_marker = "";
let display_input = if m.cursor == m.input.len() {
format!("{}{cursor_marker}", m.input)
} else {
format!(
"{}{cursor_marker}{}",
&m.input[..m.cursor],
&m.input[m.cursor..]
)
};
text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" confirm "),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(" cancel", Style::default().fg(theme.muted)),
]));
let paragraph = Paragraph::new(text_lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, dialog_area);
}
fn render_load_picker(
m: &crate::app::LoadPickerModal,
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
) {
use crate::app::LoadPickerSubMode;
let dialog_w = area.width.clamp(20, 70);
let dialog_h = area.height.clamp(10, 20);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.fg))
.title(Line::from(vec![Span::styled(" Load project ", title_style)]))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let mut text_lines: Vec<Line<'_>> = Vec::new();
text_lines.push(Line::from(""));
match &m.sub_mode {
LoadPickerSubMode::List => {
if m.entries.is_empty() {
text_lines.push(Line::from("(no projects in data directory)"));
} else {
for (i, entry) in m.entries.iter().enumerate() {
let marker = if i == m.selected { "" } else { " " };
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
let style = if i == m.selected {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
let line = format!(
" {marker} {temp_tag}{name} {modified}",
name = entry.display_name,
modified = entry.modified,
);
text_lines.push(Line::from(Span::styled(line, style)));
}
}
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" select "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" load "),
Span::styled("b", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" browse path "),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(" cancel", Style::default().fg(theme.muted)),
]));
}
LoadPickerSubMode::PathEntry { input, cursor } => {
text_lines.push(Line::from("Path to project directory:"));
text_lines.push(Line::from(""));
let cursor_marker = "";
let display_input = if *cursor == input.len() {
format!("{input}{cursor_marker}")
} else {
format!(
"{}{cursor_marker}{}",
&input[..*cursor],
&input[*cursor..]
)
};
text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" load "),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(" back to list", Style::default().fg(theme.muted)),
]));
}
}
let paragraph = Paragraph::new(text_lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, dialog_area);
}
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
/// hint at the bottom. Sized at min(60 cols, area.width-4)
/// wide and tall enough to fit the wrapped body plus 4 rows
@@ -169,10 +333,17 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let display = app.project_name.as_deref().unwrap_or("(no project)");
let line = Line::from(vec![
Span::styled("Project: ", label_style),
Span::styled(display.to_string(), value_style),
]);
let mut spans: Vec<Span<'_>> = vec![Span::styled("Project: ", label_style)];
if app.project_is_temp {
spans.push(Span::styled(
"[TEMP] ",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(display.to_string(), value_style));
let line = Line::from(spans);
let paragraph = Paragraph::new(line).style(bar_style);
frame.render_widget(paragraph, area);
}
+337
View File
@@ -0,0 +1,337 @@
//! Iteration-4b integration tests: `save` / `save as` /
//! `new` / `load` (ADR-0015 §11) and the modal infrastructure
//! that hosts their dialogs.
//!
//! Modal flows are tested at the App layer (synthetic events).
//! Filesystem effects (recursive copy, project switching at
//! runtime) are tested through the public `project` and
//! `runtime` helpers without booting a Tokio loop.
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
PathEntryPurpose,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{self, Project, ProjectKind, copy_project};
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn save_on_temp_opens_path_entry_modal() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save");
let actions = submit(&mut app);
assert!(actions.is_empty());
match app.modal.as_ref() {
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
assert_eq!(title, "Save");
}
other => panic!("expected PathEntry modal, got {other:?}"),
}
}
#[test]
fn save_on_named_project_emits_hint_and_no_modal() {
let mut app = App::new();
app.project_is_temp = false;
type_str(&mut app, "save");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(app.modal.is_none());
let last = app.output.iter().last().expect("an output line");
assert!(
last.text.contains("already auto-saved"),
"got: {}",
last.text,
);
}
#[test]
fn save_as_always_opens_path_entry_modal() {
let mut app = App::new();
app.project_is_temp = false;
type_str(&mut app, "save as");
let actions = submit(&mut app);
assert!(actions.is_empty());
match app.modal.as_ref() {
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
assert_eq!(title, "Save as");
}
other => panic!("expected PathEntry modal, got {other:?}"),
}
}
#[test]
fn new_command_emits_action() {
let mut app = App::new();
type_str(&mut app, "new");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::NewProject {
source: "new".to_string()
}]
);
}
#[test]
fn load_command_emits_open_picker_action() {
let mut app = App::new();
type_str(&mut app, "load");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::OpenLoadPicker]);
}
#[test]
fn path_entry_modal_typing_and_enter_emits_save_as() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
// Type a name and press Enter.
type_str(&mut app, "MyOrders");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::SaveAs { target, source } = &actions[0] else {
panic!("expected SaveAs, got {:?}", actions[0]);
};
assert_eq!(target, "MyOrders");
assert_eq!(source, "save as");
assert!(app.modal.is_none());
}
#[test]
fn path_entry_modal_esc_cancels() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
type_str(&mut app, "TheBest");
let actions = app.update(key(KeyCode::Esc));
assert!(actions.is_empty());
assert!(app.modal.is_none());
}
#[test]
fn path_entry_modal_backspace_edits_input() {
let mut app = App::new();
app.project_is_temp = true;
type_str(&mut app, "save as");
submit(&mut app);
type_str(&mut app, "abc");
app.update(key(KeyCode::Backspace));
match app.modal.as_ref() {
Some(Modal::PathEntry(m)) => assert_eq!(m.input, "ab"),
other => panic!("expected PathEntry, got {other:?}"),
}
}
#[test]
fn load_picker_renders_entries_and_navigates() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady {
entries: vec![
LoadPickerEntry {
display_name: "Newer".to_string(),
modified: "2026-05-07 14:30".to_string(),
path: std::path::PathBuf::from("/tmp/newer"),
is_temp: true,
},
LoadPickerEntry {
display_name: "Older".to_string(),
modified: "2026-05-01 09:15".to_string(),
path: std::path::PathBuf::from("/tmp/older"),
is_temp: false,
},
],
});
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
panic!("expected LoadPicker modal");
};
assert_eq!(picker.selected, 0);
assert!(matches!(picker.sub_mode, LoadPickerSubMode::List));
// Down → select index 1.
app.update(key(KeyCode::Down));
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
panic!("expected LoadPicker still active");
};
assert_eq!(picker.selected, 1);
// Enter → emit LoadProject for entries[1].
let actions = app.update(key(KeyCode::Enter));
assert_eq!(actions.len(), 1);
let Action::LoadProject { path, source } = &actions[0] else {
panic!("expected LoadProject, got {:?}", actions[0]);
};
assert_eq!(path, std::path::Path::new("/tmp/older"));
assert_eq!(source, "load");
}
#[test]
fn load_picker_b_enters_path_entry_submode() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady {
entries: vec![LoadPickerEntry {
display_name: "Foo".to_string(),
modified: "2026-05-07 14:30".to_string(),
path: std::path::PathBuf::from("/tmp/foo"),
is_temp: true,
}],
});
app.update(key(KeyCode::Char('b')));
let Some(Modal::LoadPicker(LoadPickerModal {
sub_mode: LoadPickerSubMode::PathEntry { input, .. },
..
})) = app.modal.clone()
else {
panic!("expected LoadPicker in PathEntry sub-mode");
};
assert_eq!(input, "");
type_str(&mut app, "/some/path");
let actions = app.update(key(KeyCode::Enter));
let Action::LoadProject { path, .. } = &actions[0] else {
panic!("expected LoadProject");
};
assert_eq!(path, std::path::Path::new("/some/path"));
}
#[test]
fn empty_data_root_load_picker_opens_in_path_entry_mode() {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady { entries: vec![] });
match app.modal.as_ref() {
Some(Modal::LoadPicker(LoadPickerModal {
sub_mode: LoadPickerSubMode::PathEntry { .. },
..
})) => {}
other => panic!("expected LoadPicker in PathEntry sub-mode, got {other:?}"),
}
}
#[test]
fn project_switched_event_updates_state() {
let mut app = App::new();
app.project_name = Some("Old".to_string());
app.project_is_temp = true;
app.tables = vec!["Stale".to_string()];
app.update(AppEvent::ProjectSwitched {
display_name: "New Name".to_string(),
is_temp: false,
});
assert_eq!(app.project_name.as_deref(), Some("New Name"));
assert!(!app.project_is_temp);
assert!(app.tables.is_empty(), "tables should clear on switch");
}
// === Filesystem-level tests for project::copy_project ===
#[test]
fn copy_project_excludes_lock_file() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let src = project.path().to_path_buf();
// Confirm the lock exists in the source.
assert!(src.join(".rdbms-playground.lock").exists());
let dst = data.path().join("CopyDestination");
copy_project(&src, &dst).unwrap();
// Destination has the project skeleton but not the lock.
assert!(dst.join("project.yaml").exists());
assert!(dst.join("data").is_dir());
assert!(!dst.join(".rdbms-playground.lock").exists());
drop(project);
}
#[test]
fn copy_project_refuses_existing_destination() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let src = project.path().to_path_buf();
let dst = data.path().join("ExistingDir");
fs::create_dir(&dst).unwrap();
let err = copy_project(&src, &dst).expect_err("must refuse");
assert!(format!("{err}").contains("already exists"));
}
#[test]
fn project_kind_recovered_from_dirname_on_open() {
let data = tempdir();
// Create a temp project. Its dirname will contain `[temp]`.
let temp = project::open_or_create(None, Some(data.path())).unwrap();
let temp_path = temp.path().to_path_buf();
drop(temp);
// Reopen — should still report Temp.
let reopened = Project::open(&temp_path).unwrap();
assert_eq!(reopened.kind(), ProjectKind::Temp);
drop(reopened);
// Now copy to a named directory.
let named_dir = data.path().join("MyProject");
copy_project(&temp_path, &named_dir).unwrap();
let opened_named = Project::open(&named_dir).unwrap();
assert_eq!(opened_named.kind(), ProjectKind::Named);
assert_eq!(opened_named.display_name(), "My Project");
}
#[test]
fn list_projects_sorts_by_mtime() {
let data = tempdir();
// Create two projects in succession; the second has a
// newer mtime on its project.yaml.
let _first = project::open_or_create(None, Some(data.path())).unwrap();
let _first_path = _first.path().to_path_buf();
drop(_first);
// Sleep a hair to ensure different mtimes on filesystems
// with second-resolution timestamps.
std::thread::sleep(std::time::Duration::from_millis(1100));
let _second = project::open_or_create(None, Some(data.path())).unwrap();
let _second_path = _second.path().to_path_buf();
drop(_second);
let listings = project::list_projects(data.path());
assert!(listings.len() >= 2, "got {} listings", listings.len());
// Newer first.
assert!(listings[0].path > listings[1].path || listings[0].modified >= listings[1].modified);
for l in &listings {
// Both are temp projects (auto-named with [temp]).
assert_eq!(l.kind, ProjectKind::Temp);
}
}