From f2198275f0e184d1faf9de69d12f2f765a6c525a Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 8 May 2026 06:23:46 +0000 Subject: [PATCH] Iteration 4b: save / save as / new / load with project switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`-[temp]---`). validate_user_name already rejects brackets, so user-typed names can never collide with a temp marker. The status bar shows `[TEMP] ` 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 /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 + Option 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. --- src/action.rs | 23 ++ src/app.rs | 362 +++++++++++++++++++++++- src/event.rs | 18 ++ src/project/mod.rs | 140 +++++++++ src/project/naming.rs | 71 ++++- src/project/prettifier.rs | 17 ++ src/runtime.rs | 273 +++++++++++++++++- src/ui.rs | 179 +++++++++++- tests/iteration4b_lifecycle_commands.rs | 337 ++++++++++++++++++++++ 9 files changed, 1376 insertions(+), 44 deletions(-) create mode 100644 tests/iteration4b_lifecycle_commands.rs diff --git a/src/action.rs b/src/action.rs index 6e002d9..b282555 100644 --- a/src/action.rs +++ b/src/action.rs @@ -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 `/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, + }, } diff --git a/src/app.rs b/src/app.rs index fc86964..3501068 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + /// 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 `/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, + 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 { + 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 { - 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 { + 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 { + 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 { + 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(), }, } } diff --git a/src/event.rs b/src/event.rs index f0d7f65..3c9a0d4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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, + }, + /// 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, + }, } diff --git a/src/project/mod.rs b/src/project/mod.rs index f5a2409..5515d6d 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -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 `/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 { + 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 /// `/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 diff --git a/src/project/naming.rs b/src/project/naming.rs index 37630f6..3d59a9f 100644 --- a/src/project/naming.rs +++ b/src/project/naming.rs @@ -1,13 +1,19 @@ //! Generate temp project directory names (P-NAME-1, ADR-0015 §2). //! -//! Output pattern: `---` 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: `-[temp]---` +//! 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( 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( 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()); diff --git a/src/project/prettifier.rs b/src/project/prettifier.rs index 198972c..31854b5 100644 --- a/src/project/prettifier.rs +++ b/src/project/prettifier.rs @@ -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"); diff --git a/src/runtime.rs b/src/runtime.rs index 67b38a6..ee0cdc4 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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, + database: Option, + 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>, theme: Theme, - database: Database, + mut session: Session, project_display_name: String, - project_path: std::path::PathBuf, + project_is_temp: bool, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(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 = + 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 `/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, +) { + 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 = 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 +/// `/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) { match database.list_tables().await { Ok(tables) => { diff --git a/src/ui.rs b/src/ui.rs index f0466d5..7439854 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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> = 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> = 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> = 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); } diff --git a/tests/iteration4b_lifecycle_commands.rs b/tests/iteration4b_lifecycle_commands.rs new file mode 100644 index 0000000..63ce345 --- /dev/null +++ b/tests/iteration4b_lifecycle_commands.rs @@ -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 { + 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); + } +}