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();
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Migration framework scaffold (Iteration 6, ADR-0015 §9 /
|
||||
//! requirement F3).
|
||||
//!
|
||||
//! The shape lands in v1 even though no migrator is
|
||||
//! registered: the cost is small, the wiring is non-trivial,
|
||||
//! and shipping the framework now lets the *first* real
|
||||
//! migrator (v1 → v2, when that lands) be a tightly scoped
|
||||
//! one-file change rather than "design migrations + write a
|
||||
//! migrator + integrate."
|
||||
//!
|
||||
//! Public surface:
|
||||
//!
|
||||
//! - [`MigratorRegistry`] — ordered list of `MigrateFn`s, one
|
||||
//! per source version. Tests inject their own registries; the
|
||||
//! production-default registry is empty.
|
||||
//! - [`migrate_to_latest`] — given a YAML body and a registry,
|
||||
//! detect the source version, run each migrator in
|
||||
//! sequence, and return the upgraded body. Writes the
|
||||
//! pre-migration body to `project.yaml.v<N>.bak` inside the
|
||||
//! project (a recovery aid; the .gitignore template excludes
|
||||
//! `project.yaml.v*.bak` so backups don't leak into git).
|
||||
//!
|
||||
//! What this does NOT do:
|
||||
//!
|
||||
//! - It does not write `project.yaml` itself. The runtime is
|
||||
//! responsible for atomically writing the upgraded body back.
|
||||
//! Keeping the migration step separate from the write step
|
||||
//! makes the order of operations explicit at the call site
|
||||
//! and trivially testable: pass in a body, get back a body.
|
||||
//! - It does not parse anything beyond the leading `version:`
|
||||
//! line. Full schema parsing is `yaml::parse_schema`'s job
|
||||
//! and runs *after* migration so the parser only ever sees
|
||||
//! the latest format.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A pure migrator: takes a YAML body at version `N` and
|
||||
/// returns the same project at version `N + 1`.
|
||||
///
|
||||
/// Migrators must not perform I/O. The framework is
|
||||
/// responsible for the .bak copy and the write-back; the
|
||||
/// migrator's job is purely the format transformation.
|
||||
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
|
||||
|
||||
/// Ordered list of migrators. `migrators[i]` runs from
|
||||
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
|
||||
/// index 1 is v2→v3, etc.).
|
||||
///
|
||||
/// `latest_version()` is `1 + migrators.len()`. In v1 the
|
||||
/// list is empty and `latest_version()` is `1`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MigratorRegistry {
|
||||
pub migrators: Vec<MigrateFn>,
|
||||
}
|
||||
|
||||
impl MigratorRegistry {
|
||||
/// Production-default registry: empty. As new versions
|
||||
/// land, register the migrators here in source-version
|
||||
/// order.
|
||||
#[must_use]
|
||||
pub const fn production() -> Self {
|
||||
Self {
|
||||
migrators: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest schema version this build understands.
|
||||
#[must_use]
|
||||
pub const fn latest_version(&self) -> u32 {
|
||||
1u32.saturating_add(self.migrators.len() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MigratorRegistry {
|
||||
fn default() -> Self {
|
||||
Self::production()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MigrateError {
|
||||
#[error("could not read version field from project.yaml: {0}")]
|
||||
VersionParse(String),
|
||||
#[error(
|
||||
"project.yaml is at version {file} but this build only understands \
|
||||
up to version {latest}; upgrade the application or restore an \
|
||||
older project.yaml"
|
||||
)]
|
||||
NewerThanSupported { file: u32, latest: u32 },
|
||||
#[error(
|
||||
"no migrator registered for version {0} (programmer error: \
|
||||
registry latest_version disagrees with migrators length)"
|
||||
)]
|
||||
NoMigratorForVersion(u32),
|
||||
#[error("migrator from v{from} to v{to} failed: {source}")]
|
||||
StepFailed {
|
||||
from: u32,
|
||||
to: u32,
|
||||
source: Box<Self>,
|
||||
},
|
||||
#[error("migrator produced an unparseable result: {0}")]
|
||||
BadOutput(String),
|
||||
#[error("io error during migration on `{}`: {source}", path.display())]
|
||||
Io {
|
||||
path: std::path::PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of running [`migrate_to_latest`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MigrationOutcome {
|
||||
/// The upgraded body. When no migration was needed this
|
||||
/// is identical to the input body.
|
||||
pub body: String,
|
||||
/// Source version found in the input. `None` if the
|
||||
/// input parsed but its version equals `latest_version`
|
||||
/// (no migration ran); `Some(N)` if a migration ran from
|
||||
/// `N` to `latest_version`.
|
||||
pub migrated_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Detect the version of `body` and migrate it to the
|
||||
/// registry's `latest_version()`.
|
||||
///
|
||||
/// If the body is already at the latest version, returns the
|
||||
/// body unchanged with `migrated_from = None`. Otherwise:
|
||||
///
|
||||
/// 1. Writes `<project_path>/project.yaml.v<N>.bak` with the
|
||||
/// original body (so the recovery aid is in place before
|
||||
/// we start mutating).
|
||||
/// 2. Runs each registered migrator in sequence from
|
||||
/// `file_version` to `latest_version`.
|
||||
/// 3. Returns the upgraded body for the caller to write back.
|
||||
///
|
||||
/// A future-version body (file_version > latest_version)
|
||||
/// errors out — older builds shouldn't try to interpret
|
||||
/// newer formats they don't understand.
|
||||
pub fn migrate_to_latest(
|
||||
body: &str,
|
||||
registry: &MigratorRegistry,
|
||||
project_path: &Path,
|
||||
) -> Result<MigrationOutcome, MigrateError> {
|
||||
let file_version = read_version(body)?;
|
||||
let latest = registry.latest_version();
|
||||
if file_version == latest {
|
||||
return Ok(MigrationOutcome {
|
||||
body: body.to_string(),
|
||||
migrated_from: None,
|
||||
});
|
||||
}
|
||||
if file_version > latest {
|
||||
return Err(MigrateError::NewerThanSupported {
|
||||
file: file_version,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
|
||||
// Write the .bak before any transformation runs so a
|
||||
// mid-migration crash leaves the original recoverable.
|
||||
let bak_path =
|
||||
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
|
||||
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
|
||||
path: bak_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut current_body = body.to_string();
|
||||
for v in file_version..latest {
|
||||
let idx = (v - 1) as usize;
|
||||
let migrator = registry
|
||||
.migrators
|
||||
.get(idx)
|
||||
.ok_or(MigrateError::NoMigratorForVersion(v))?;
|
||||
let next_body = migrator(¤t_body).map_err(|e| MigrateError::StepFailed {
|
||||
from: v,
|
||||
to: v + 1,
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
// Sanity: the new body must declare the next version.
|
||||
// If a migrator forgets to bump, we'd loop endlessly
|
||||
// through the chain — catch it here.
|
||||
let advertised = read_version(&next_body)
|
||||
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
|
||||
if advertised != v + 1 {
|
||||
return Err(MigrateError::BadOutput(format!(
|
||||
"v{v}→v{} migrator left version field at {advertised}",
|
||||
v + 1,
|
||||
)));
|
||||
}
|
||||
current_body = next_body;
|
||||
}
|
||||
|
||||
Ok(MigrationOutcome {
|
||||
body: current_body,
|
||||
migrated_from: Some(file_version),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure the `project.yaml` at `project_path` is migrated
|
||||
/// to the registry's latest version, writing the upgraded
|
||||
/// body back to disk if a migration ran.
|
||||
///
|
||||
/// Convenience wrapper that pairs [`migrate_to_latest`] with
|
||||
/// the read/write IO. Used by the runtime on every project
|
||||
/// open (before the rebuild path or DB-existence check
|
||||
/// touches anything).
|
||||
///
|
||||
/// A missing `project.yaml` is `Ok(MigrationOutcome { body:
|
||||
/// "", migrated_from: None })` — a brand-new project that
|
||||
/// the skeleton hasn't filled in yet falls into this branch
|
||||
/// and is left alone.
|
||||
pub fn ensure_project_yaml_migrated(
|
||||
project_path: &Path,
|
||||
registry: &MigratorRegistry,
|
||||
) -> Result<MigrationOutcome, MigrateError> {
|
||||
let yaml_path = project_path.join(crate::project::PROJECT_YAML);
|
||||
let body = match std::fs::read_to_string(&yaml_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(MigrationOutcome {
|
||||
body: String::new(),
|
||||
migrated_from: None,
|
||||
});
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(MigrateError::Io {
|
||||
path: yaml_path,
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
let outcome = migrate_to_latest(&body, registry, project_path)?;
|
||||
if outcome.migrated_from.is_some() {
|
||||
std::fs::write(&yaml_path, &outcome.body).map_err(|source| MigrateError::Io {
|
||||
path: yaml_path,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Extract just the `version:` field from a YAML body,
|
||||
/// without parsing the rest of the document.
|
||||
fn read_version(body: &str) -> Result<u32, MigrateError> {
|
||||
#[derive(Deserialize)]
|
||||
struct VersionOnly {
|
||||
version: u32,
|
||||
}
|
||||
let v: VersionOnly = serde_yml::from_str(body).map_err(|e| {
|
||||
MigrateError::VersionParse(e.to_string())
|
||||
})?;
|
||||
Ok(v.version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("create tempdir")
|
||||
}
|
||||
|
||||
fn v1_body() -> String {
|
||||
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_registry_latest_version_is_1() {
|
||||
let r = MigratorRegistry::production();
|
||||
assert_eq!(r.latest_version(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_migration_runs_when_body_already_latest() {
|
||||
let tmp = tempdir();
|
||||
let outcome = migrate_to_latest(
|
||||
&v1_body(),
|
||||
&MigratorRegistry::production(),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.body, v1_body());
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// No .bak written when nothing migrated.
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
assert!(!bak.exists(), "no .bak when no migration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_than_supported_errors() {
|
||||
let body = "version: 99\nproject:\n created_at: x\n";
|
||||
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
|
||||
.expect_err("must reject");
|
||||
assert!(
|
||||
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
|
||||
"got: {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_version_errors() {
|
||||
let body = "tables: []\n";
|
||||
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
|
||||
.expect_err("must reject");
|
||||
assert!(matches!(err, MigrateError::VersionParse(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
// --- Exercise the framework with a fake v1→v2 migrator
|
||||
// so we know the chain runs even without a real one. ---
|
||||
|
||||
fn fake_v1_to_v2(body: &str) -> Result<String, MigrateError> {
|
||||
// Trivial transformation: bump the version number in
|
||||
// place. Exercises the registry plumbing without
|
||||
// committing to any real schema change.
|
||||
Ok(body.replace("version: 1", "version: 2"))
|
||||
}
|
||||
|
||||
fn registry_with_v1_to_v2() -> MigratorRegistry {
|
||||
MigratorRegistry {
|
||||
migrators: vec![fake_v1_to_v2 as MigrateFn],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_with_one_migrator_advertises_latest_version_2() {
|
||||
let r = registry_with_v1_to_v2();
|
||||
assert_eq!(r.latest_version(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_runs_chain_and_writes_bak() {
|
||||
let tmp = tempdir();
|
||||
let outcome = migrate_to_latest(
|
||||
&v1_body(),
|
||||
®istry_with_v1_to_v2(),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
assert!(outcome.body.contains("version: 2"));
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
assert!(bak.exists(), "expected .v1.bak to be written");
|
||||
let bak_body = std::fs::read_to_string(&bak).unwrap();
|
||||
assert!(bak_body.contains("version: 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrator_that_forgets_to_bump_version_is_caught() {
|
||||
let bad: MigrateFn = |body| Ok(body.to_string()); // no change
|
||||
let registry = MigratorRegistry {
|
||||
migrators: vec![bad],
|
||||
};
|
||||
let tmp = tempdir();
|
||||
let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail");
|
||||
assert!(matches!(err, MigrateError::BadOutput(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() {
|
||||
let tmp = tempdir();
|
||||
let yaml_path = tmp.path().join("project.yaml");
|
||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
&MigratorRegistry::production(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// File unchanged.
|
||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||
assert_eq!(on_disk, v1_body());
|
||||
assert!(!tmp.path().join("project.yaml.v1.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_yaml_migrated_writes_upgraded_body_and_bak() {
|
||||
let tmp = tempdir();
|
||||
let yaml_path = tmp.path().join("project.yaml");
|
||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
®istry_with_v1_to_v2(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
assert!(bak.exists());
|
||||
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_yaml_migrated_handles_missing_yaml() {
|
||||
let tmp = tempdir();
|
||||
// No project.yaml exists.
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
&MigratorRegistry::production(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
assert!(outcome.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrator_that_returns_internal_error_propagates() {
|
||||
let bad: MigrateFn =
|
||||
|_| Err(MigrateError::VersionParse("simulated".to_string()));
|
||||
let registry = MigratorRegistry {
|
||||
migrators: vec![bad],
|
||||
};
|
||||
let tmp = tempdir();
|
||||
let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail");
|
||||
match err {
|
||||
MigrateError::StepFailed { from, to, .. } => {
|
||||
assert_eq!(from, 1);
|
||||
assert_eq!(to, 2);
|
||||
}
|
||||
other => panic!("expected StepFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
|
||||
// during rebuild (ADR-0015 §7) are re-exported below.
|
||||
mod csv_io;
|
||||
mod history;
|
||||
pub mod migrations;
|
||||
mod yaml;
|
||||
|
||||
pub(crate) use csv_io::{decode_cell, parse_csv};
|
||||
@@ -212,6 +213,15 @@ impl Persistence {
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
/// Read the most-recent `max_n` sources out of
|
||||
/// `history.log` for input-history hydration on project
|
||||
/// open (ADR-0015 §12). Returned in chronological order
|
||||
/// (oldest first). A missing file is `Ok(Vec::new())`.
|
||||
pub fn read_recent_history(&self, max_n: usize) -> Result<Vec<String>, PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
history::read_recent_sources(&path, max_n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `body` to `path` atomically via temp file + fsync +
|
||||
|
||||
Reference in New Issue
Block a user