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
|
||||
|
||||
Reference in New Issue
Block a user