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:
@@ -28,6 +28,92 @@ pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String
|
||||
format!("{timestamp_iso}|ok|{escaped}\n")
|
||||
}
|
||||
|
||||
/// Read the most-recent `max_n` user-issued command sources
|
||||
/// from `history_log_path`, in chronological order
|
||||
/// (oldest-first within the returned slice).
|
||||
///
|
||||
/// This is the I2-persist hydration helper (ADR-0015 §12):
|
||||
/// on project open, the runtime seeds the in-memory navigable
|
||||
/// history from this list so Up/Down recall picks up where
|
||||
/// the user left off in the previous session.
|
||||
///
|
||||
/// Lines that do not match the `<ts>|<status>|<source>` shape
|
||||
/// are silently skipped — they are likely corruption or a
|
||||
/// future format extension; either way, refusing to seed at
|
||||
/// all because of a single bad line would be a worse UX than
|
||||
/// quietly rejoining the user's history.
|
||||
///
|
||||
/// A missing file returns an empty `Vec`; other IO errors
|
||||
/// are surfaced via `PersistenceError` so the caller can
|
||||
/// decide how to handle them. In practice the runtime treats
|
||||
/// hydration failures as non-fatal — the user just gets an
|
||||
/// empty history and a tracing warning.
|
||||
pub(super) fn read_recent_sources(
|
||||
history_log_path: &std::path::Path,
|
||||
max_n: usize,
|
||||
) -> Result<Vec<String>, super::PersistenceError> {
|
||||
use std::io::ErrorKind;
|
||||
let body = match std::fs::read_to_string(history_log_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(source) => {
|
||||
return Err(super::PersistenceError::Io {
|
||||
operation: "read",
|
||||
path: history_log_path.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut sources: Vec<String> = body
|
||||
.lines()
|
||||
.filter_map(parse_record_source)
|
||||
.collect();
|
||||
if sources.len() > max_n {
|
||||
let skip = sources.len() - max_n;
|
||||
sources.drain(0..skip);
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Parse one `<ts>|<status>|<source>` line and return the
|
||||
/// unescaped source. Returns `None` for malformed lines.
|
||||
fn parse_record_source(line: &str) -> Option<String> {
|
||||
// Format: timestamp|status|source-with-pipes-allowed
|
||||
// We split into at most 3 parts so a `|` inside source
|
||||
// (which append() does NOT escape — pipes are valid SQL
|
||||
// characters) is preserved.
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let _ts = parts.next()?;
|
||||
let _status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
Some(unescape_command(source))
|
||||
}
|
||||
|
||||
fn unescape_command(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('\\') => out.push('\\'),
|
||||
// Preserve unknown escapes literally so a future
|
||||
// extension to `escape_command` doesn't corrupt
|
||||
// entries written before that extension.
|
||||
Some(other) => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append `line` (which already ends in `\n`) to the file at
|
||||
/// `path`. Creates the file if it doesn't exist. fsyncs after
|
||||
/// the write so a power-cut doesn't lose the latest entry.
|
||||
@@ -147,6 +233,65 @@ mod tests {
|
||||
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_returns_empty_when_file_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nope.log");
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_unescapes_newlines_and_backslashes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let line1 = format_record("a\nb", "T1".to_string());
|
||||
let line2 = format_record("c\\d", "T2".to_string());
|
||||
std::fs::write(&path, format!("{line1}{line2}")).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["a\nb".to_string(), "c\\d".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_caps_at_max_n_keeping_most_recent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let body: String = (0..10)
|
||||
.map(|i| format_record(&format!("cmd{i}"), format!("T{i}")))
|
||||
.collect();
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let got = read_recent_sources(&path, 3).unwrap();
|
||||
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_skips_malformed_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
// Two valid lines and one garbage line in the middle.
|
||||
let body = format!(
|
||||
"{}{}{}",
|
||||
format_record("good1", "T1".to_string()),
|
||||
"this is not a record\n",
|
||||
format_record("good2", "T2".to_string()),
|
||||
);
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["good1".to_string(), "good2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_preserves_pipes_inside_source() {
|
||||
// The append-side does NOT escape `|`, so pipes inside
|
||||
// the source must round-trip through the parser. This
|
||||
// is what splitn(3) on `|` is supposed to handle.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
std::fs::write(&path, "T1|ok|select 'a|b' from t\n").unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["select 'a|b' from t".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_creates_and_grows_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user