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:
claude@clouddev1
2026-05-08 08:27:50 +00:00
parent c6cf3df6dc
commit 67d68db5f8
12 changed files with 1544 additions and 34 deletions
+87 -3
View File
@@ -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());
}
}