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