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