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
+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());