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