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
+44 -1
View File
@@ -18,8 +18,14 @@ pub struct Args {
pub data_dir: Option<PathBuf>,
/// Positional path argument: open an existing project at
/// this path (L1, ADR-0015 §1). Mutually exclusive with
/// `--resume` once that lands.
/// `--resume`.
pub project_path: Option<PathBuf>,
/// `--resume`: open the most-recently-used project at
/// startup (L1a, ADR-0015 §7). Reads the path from
/// `<data-root>/last_project`. Mutually exclusive with
/// `<project-path>` — supplying both is an error rather
/// than silently picking one.
pub resume: bool,
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
@@ -44,6 +50,11 @@ Options:
--data-dir <PATH> Use PATH as the data root instead of
the OS-standard location for this run.
--log-file <PATH> Write tracing output to PATH.
--resume Open the most-recently-used project
(path tracked under <data-root>/last_project).
Errors out if no previous project is
recorded. Mutually exclusive with
<project-path>.
App-level commands (typed inside the app, available in both modes):
quit / q Exit cleanly.
@@ -80,6 +91,11 @@ pub enum ArgsError {
Unknown(String),
#[error("only one project path may be supplied; got both `{first}` and `{second}`")]
MultiplePaths { first: String, second: String },
#[error(
"--resume and a positional <project-path> are mutually exclusive; \
pass one or the other"
)]
ResumeWithPath,
}
impl Args {
@@ -98,6 +114,7 @@ impl Args {
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
let mut data_dir: Option<PathBuf> = None;
let mut project_path: Option<PathBuf> = None;
let mut resume = false;
let mut help = false;
let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() {
@@ -105,6 +122,9 @@ impl Args {
"--help" | "-h" => {
help = true;
}
"--resume" => {
resume = true;
}
"--theme" => {
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
theme = match value.as_str() {
@@ -141,11 +161,15 @@ impl Args {
}
}
}
if resume && project_path.is_some() {
return Err(ArgsError::ResumeWithPath);
}
Ok(Self {
theme,
log_path,
data_dir,
project_path,
resume,
help,
})
}
@@ -264,6 +288,25 @@ mod tests {
assert!(args.help);
}
#[test]
fn resume_flag_parses() {
let args = Args::parse(["--resume"]).unwrap();
assert!(args.resume);
assert!(args.project_path.is_none());
}
#[test]
fn resume_with_positional_path_errors() {
let err = Args::parse(["--resume", "/some/path"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn positional_path_with_resume_errors_in_either_order() {
let err = Args::parse(["/some/path", "--resume"]).unwrap_err();
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
#[test]
fn unknown_double_dash_flag_errors_even_with_positional() {
// Make sure the path-vs-flag distinction is robust: