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
+23
View File
@@ -248,6 +248,27 @@ impl App {
}
}
/// Replace the in-memory navigable history with `entries`,
/// truncating to the in-memory cap.
///
/// Used by the runtime to hydrate from the project's
/// `history.log` on open (I2-persist, ADR-0015 §12).
/// Entries should arrive in chronological order (oldest
/// first); the most recent stays at the back, which is
/// where Up/Down navigation expects it.
///
/// Cancels any in-flight history navigation so a hydrate
/// during a session (e.g. after `load`) doesn't leave a
/// dangling cursor pointing at a now-removed entry.
pub fn seed_history(&mut self, entries: Vec<String>) {
self.history = entries;
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
self.history_cursor = None;
self.history_draft = None;
}
/// Effective mode for the *next* submission, given the
/// persistent mode and the current input buffer. See
/// [`EffectiveMode`].
@@ -353,12 +374,14 @@ impl App {
AppEvent::ProjectSwitched {
display_name,
is_temp,
history_entries,
} => {
self.note_system(format!("[ok] now editing: {display_name}"));
self.project_name = Some(display_name);
self.project_is_temp = is_temp;
self.tables.clear();
self.current_table = None;
self.seed_history(history_entries);
Vec::new()
}
AppEvent::ProjectSwitchFailed { error } => {
+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:
+5 -2
View File
@@ -78,11 +78,14 @@ pub enum AppEvent {
entries: Vec<crate::app::LoadPickerEntry>,
},
/// A project switch (load / new / save-as / import)
/// succeeded. Carries the new display name + temp flag
/// so App can update the status bar.
/// succeeded. Carries the new display name, the temp
/// flag (drives the `[TEMP]` status-bar prefix), and the
/// seed entries for input-history hydration off the new
/// project's `history.log` (I2-persist, ADR-0015 §12).
ProjectSwitched {
display_name: String,
is_temp: bool,
history_entries: Vec<String>,
},
/// A project switch failed in a non-fatal way (target
/// already exists, path unreadable, …). Surfaced as an
+145
View File
@@ -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();
+428
View File
@@ -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(&current_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(),
&registry_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(), &registry, 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(),
&registry_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(), &registry, 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:?}"),
}
}
}
+10
View File
@@ -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 +
+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());
}
}
+117 -3
View File
@@ -10,6 +10,7 @@
//! additional producers.
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
@@ -34,7 +35,7 @@ use crate::dsl::Command;
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
resolve_data_root, safely_delete_temp_project,
read_last_project, resolve_data_root, safely_delete_temp_project, write_last_project,
};
use crate::theme::Theme;
use crate::ui;
@@ -51,8 +52,71 @@ pub async fn run(args: Args) -> Result<()> {
// Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref())
.context("resolve data root")?;
let project = open_or_create(args.project_path.as_deref(), Some(data_root.as_path()))
// Resolve the initial project path: --resume reads it from
// <data-root>/last_project; otherwise an explicit positional
// arg, falling back to a fresh auto-named temp.
//
// ADR-0015 §7: --resume errors out cleanly when the path is
// missing or the recorded project no longer exists. We
// surface those failures to stderr before booting the
// terminal so the message lands directly in the user's
// shell.
let initial_path: Option<PathBuf> = if args.resume {
match read_last_project(&data_root)
.context("read last_project")?
{
Some(p) if p.exists() => Some(p),
Some(p) => {
eprintln!(
"rdbms-playground: --resume: recorded project `{}` no longer exists",
p.display(),
);
return Ok(());
}
None => {
eprintln!(
"rdbms-playground: --resume: no previous project recorded under `{}`",
data_root.display(),
);
return Ok(());
}
}
} else {
args.project_path.clone()
};
let project = open_or_create(initial_path.as_deref(), Some(data_root.as_path()))
.context("open or create project")?;
// Run any pending project.yaml migrations before the
// database opens (so the rebuild path only ever sees the
// latest schema). The registry is empty in v1; future
// versions register their migrators here. A migration
// that runs is recorded in tracing and leaves a
// `project.yaml.v<N>.bak` breadcrumb on disk; that's
// sufficient v1 UX and lets us defer dedicated event
// plumbing until a real migrator demands it.
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
project.path(),
&migrate_registry,
)
.context("migrate project.yaml")?;
if let Some(from) = migration_outcome.migrated_from {
info!(
from_version = from,
to_version = migrate_registry.latest_version(),
"migrated project.yaml",
);
}
// Record the just-opened project as the new resume target.
// Write failures here are non-fatal: --resume on the next
// launch will report the missing/stale state, which is the
// safer default than refusing to launch.
if let Err(e) = write_last_project(&data_root, project.path()) {
warn!(error = %e, "could not update last_project");
}
let db_path = project.db_path();
let display_name = project.display_name().to_string();
let project_path = project.path().to_path_buf();
@@ -170,6 +234,11 @@ async fn run_loop(
let mut app = App::new();
app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp;
// Seed the in-memory navigable history from the
// initial project's history.log (I2-persist, ADR-0015
// §12). Subsequent project switches re-seed via the
// `ProjectSwitched` event payload.
app.seed_history(read_history_seed(session.project().path()));
// Send any startup events (e.g., the system-message form
// of "rebuilt from text on missing .db") so they're
@@ -369,10 +438,12 @@ async fn handle_project_switch(
) {
match perform_switch(session, req, source).await {
Ok((display_name, is_temp)) => {
let history_entries = read_history_seed(session.project().path());
let _ = event_tx
.send(AppEvent::ProjectSwitched {
display_name,
is_temp,
history_entries,
})
.await;
if let Ok(tables) = session.database().list_tables().await {
@@ -387,6 +458,28 @@ async fn handle_project_switch(
}
}
/// Read the most-recent `HISTORY_HYDRATION_CAP` source lines
/// out of the project's `history.log` for input-history
/// seeding. Failures are logged and swallowed — an empty
/// hydration is the right fallback when the file is unreadable.
fn read_history_seed(project_path: &std::path::Path) -> Vec<String> {
let p = crate::persistence::Persistence::new(project_path.to_path_buf());
match p.read_recent_history(HISTORY_HYDRATION_CAP) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(error = %e, "history hydration failed; starting empty");
Vec::new()
}
}
}
/// Maximum number of `history.log` entries to seed the
/// in-memory navigable history with on project open. Matches
/// the in-memory cap (`app::HISTORY_CAPACITY`) per ADR-0015
/// §12: "latest N entries, where N is the same in-memory
/// cap as today."
const HISTORY_HYDRATION_CAP: usize = 1000;
async fn perform_switch(
session: &mut Session,
req: SwitchRequest,
@@ -510,6 +603,19 @@ async fn perform_switch(
};
let new_path = new_project.path().to_path_buf();
// Run any pending project.yaml migrations before the
// database opens. Same registry as `run()`. A failed
// migration aborts the switch (the old project has
// already been dropped — user lands in a "no project"
// state momentarily, but the next user action will
// surface the error and they can retry).
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
crate::persistence::migrations::ensure_project_yaml_migrated(
new_project.path(),
&migrate_registry,
)
.map_err(|e| e.to_string())?;
// Open the new database (rebuild from text if .db is
// missing — applies to NewTemp's just-created project,
// and to Load when the user opened a project whose .db
@@ -535,7 +641,15 @@ async fn perform_switch(
// history.log. The worker's persistence is wired but not
// directly addressable from here, so we use a fresh
// Persistence handle for this single line.
let _ = Persistence::new(new_path).append_history(&source);
let _ = Persistence::new(new_path.clone()).append_history(&source);
// Update the resume pointer so the next `--resume`
// launch reopens the project we just switched to. Write
// failures are non-fatal — see the same rationale at
// `run()` startup.
if let Err(e) = write_last_project(&session.data_root, &new_path) {
tracing::warn!(error = %e, "could not update last_project after switch");
}
Ok((display_name, is_temp))
}