Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog. * `--resume` CLI flag (L1a, ADR-0015 §7) opens the most- recently-used project, tracked in <data-root>/last_project. Mutually exclusive with a positional <project-path>; errors cleanly to stderr (above the shell prompt) on missing file or stale recorded path. last_project is rewritten on every successful project open (startup, load, new, save as, import). * Persistent input history (I2-persist, ADR-0015 §12). On project open, the in-memory navigable history is hydrated from the tail of history.log (capped at the in-memory cap). ProjectSwitched gains a `history_entries` payload field; App::seed_history is the entry point. Pipes inside source text round-trip via splitn(3); unknown escape sequences are passed through literally. * Migration framework scaffold (F3, ADR-0015 §9). New persistence::migrations module with MigratorRegistry + migrate_to_latest + ensure_project_yaml_migrated. Empty in v1 (production registry has no migrators); the loader runs through it on every project open and is exercised by tests with a fake v1→v2 migrator. Writes project.yaml.v<N>.bak before any migrator runs; verifies each step bumps the version field. Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a / test baseline) and adds docs/handoff/20260508-handoff-3.md covering both Iter 5 and Iter 6. Total tests: 408 passing, 0 failing, 0 skipped (up from 345 at handoff-2). Clippy clean.
This commit is contained in:
+87
-3
@@ -40,11 +40,61 @@ pub const PROJECTS_SUBDIR: &str = "projects";
|
||||
/// State file under the data root used by `--resume`.
|
||||
///
|
||||
/// Records the absolute path of the most-recently-opened
|
||||
/// project (Iteration 6, ADR-0015 §7). Iteration 1 doesn't
|
||||
/// read or write it yet; defining the constant now keeps
|
||||
/// related code colocated.
|
||||
/// project (Iteration 6, ADR-0015 §7). The runtime writes
|
||||
/// it on every successful project open and reads it when
|
||||
/// `--resume` is passed; a clean exit deliberately leaves
|
||||
/// it intact (the whole point is to reopen "what I had").
|
||||
pub const LAST_PROJECT_FILE: &str = "last_project";
|
||||
|
||||
/// Read the recorded last-project path under `data_root`,
|
||||
/// stripping trailing whitespace/newlines.
|
||||
///
|
||||
/// Returns `Ok(None)` when the file is absent (a fresh data
|
||||
/// root), `Err(_)` for IO errors that aren't `NotFound`. The
|
||||
/// runtime treats `None` as "no resume target" and surfaces
|
||||
/// the absent path explicitly when `--resume` was requested.
|
||||
pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
|
||||
let path = data_root.join(LAST_PROJECT_FILE);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(body) => {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(PathBuf::from(trimmed)))
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write `project_path` as the recorded
|
||||
/// last-project for `data_root` (uses temp-write + rename so
|
||||
/// a crash mid-write never leaves a half-line behind).
|
||||
///
|
||||
/// The path is written verbatim, with a single trailing
|
||||
/// newline. We don't canonicalize: a stale entry pointing at
|
||||
/// a moved/deleted directory is the kind of error `--resume`
|
||||
/// is supposed to surface clearly, not paper over by
|
||||
/// resolving symlinks at write time.
|
||||
pub fn write_last_project(
|
||||
data_root: &Path,
|
||||
project_path: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
fs::create_dir_all(data_root)?;
|
||||
let final_path = data_root.join(LAST_PROJECT_FILE);
|
||||
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
|
||||
{
|
||||
use std::io::Write as _;
|
||||
let mut f = fs::File::create(&tmp_path)?;
|
||||
writeln!(f, "{}", project_path.display())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp_path, &final_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the data root for this run.
|
||||
///
|
||||
/// - If `override_dir` is `Some`, that path is used verbatim
|
||||
@@ -812,4 +862,38 @@ mod tests {
|
||||
let project = Project::create_temp(tmp.path()).expect("create");
|
||||
assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_last_project_returns_none_when_missing() {
|
||||
let tmp = tempdir();
|
||||
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_last_project_round_trips() {
|
||||
let tmp = tempdir();
|
||||
let target = std::path::PathBuf::from("/tmp/some/project");
|
||||
write_last_project(tmp.path(), &target).unwrap();
|
||||
let read_back = read_last_project(tmp.path()).unwrap();
|
||||
assert_eq!(read_back, Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_project_strips_trailing_whitespace() {
|
||||
let tmp = tempdir();
|
||||
fs::write(
|
||||
tmp.path().join(LAST_PROJECT_FILE),
|
||||
"/tmp/some/project\n\n ",
|
||||
)
|
||||
.unwrap();
|
||||
let read_back = read_last_project(tmp.path()).unwrap();
|
||||
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_last_project_file_is_treated_as_none() {
|
||||
let tmp = tempdir();
|
||||
fs::write(tmp.path().join(LAST_PROJECT_FILE), " \n").unwrap();
|
||||
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user