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:
+58
-13
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user