//! Append-only `history.log` writer (ADR-0015 §5). //! //! Format: one record per line, three pipe-separated fields: //! //! ```text //! 2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial) //! ``` //! //! Status is always `ok` in v1; failed commands are not //! recorded. The status field is kept in the line shape so //! future use cases can carry additional values without a //! format break. //! //! Newlines inside the command (which do not yet appear, but //! will when multi-line input I1 lands) are escaped to a //! literal `\n`. use std::fs::OpenOptions; use std::io::Write as _; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use super::PersistenceError; /// Journal-record status tokens (ADR-0034 §1). Kept as named /// constants so the writer and the readers (hydration + replay) /// cannot drift on the spelling. pub(super) const STATUS_OK: &str = "ok"; pub(super) const STATUS_ERR: &str = "err"; /// The optional status suffix marking an advanced-mode submission /// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that /// hydration can reconstruct the `:`-prefixed runnable form of an /// advanced command, making advanced history reusable in simple mode. pub(super) const ADV_SUFFIX: &str = "adv"; /// Build the status token for a `base` (`ok`/`err`) and submission mode. pub(super) fn status_token(base: &str, advanced: bool) -> String { if advanced { format!("{base}:{ADV_SUFFIX}") } else { base.to_string() } } /// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base /// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional /// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so /// replay skips it rather than mis-running it. pub(super) fn parse_status(status: &str) -> (bool, bool) { let (base, suffix) = status.split_once(':').unwrap_or((status, "")); (base == STATUS_OK, suffix == ADV_SUFFIX) } /// Format a successful-command record. Pure; no I/O. (Simple-mode /// convenience used by tests; production threads the mode through /// [`format_record_with_status`] + [`status_token`], so this is /// test-only since ADR-0052.) #[cfg(test)] pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String { format_record_with_status(command_text, timestamp_iso, STATUS_OK) } /// Format a record with an explicit status token (ADR-0034 §1). /// Pure; no I/O. pub(super) fn format_record_with_status( command_text: &str, timestamp_iso: String, status: &str, ) -> String { let escaped = escape_command(command_text); format!("{timestamp_iso}|{status}|{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 `||` 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, 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 = 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 `||` line and return the /// unescaped source. Returns `None` for malformed lines. fn parse_record_source(line: &str) -> Option { // 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()?; let (_is_ok, advanced) = parse_status(status); let command = unescape_command(source); // ADR-0052: an advanced record is hydrated in its `:`-prefixed // simple-mode runnable form, so cross-session recall matches the // in-session ring (and recall strips the `:` again in advanced // mode). A simple record hydrates bare. Old `ok`/`err` logs have no // `:adv` suffix → read as simple, unchanged. Some(if advanced { format!(": {command}") } else { command }) } /// A parsed journal record (ADR-0034 §3). `source` is already /// unescaped. pub(super) struct JournalRecord { pub status_is_ok: bool, pub source: String, } /// Classify `line` as a journal record or a bare command /// (ADR-0034 §3). Returns `Some(JournalRecord)` only when the /// line begins with a valid `||` /// prefix — so a bare command containing `|` (e.g. /// `select 'a|b' from t`) is `None` (treated as bare by the /// caller) because it does not start with a timestamp. A valid /// timestamp prefix with a non-`ok` (or unrecognised) status is /// still a journal record, reported with `status_is_ok = false` /// so replay skips it rather than mis-running it as a command. pub(super) fn parse_journal_record(line: &str) -> Option { let mut parts = line.splitn(3, '|'); let ts = parts.next()?; let status = parts.next()?; let source = parts.next()?; if !looks_like_iso8601(ts) { return None; } // ADR-0052: the status may carry a `:adv` mode suffix; replayability // keys off the base token only (`ok` / `ok:adv` are both ok). let (status_is_ok, _advanced) = parse_status(status); Some(JournalRecord { status_is_ok, source: unescape_command(source), }) } /// True when `s` is exactly an `YYYY-MM-DDTHH:MM:SSZ` timestamp /// — the shape `utc_iso8601_now` emits. Used to distinguish a /// journal record's leading field from a bare command that /// merely contains `|`. fn looks_like_iso8601(s: &str) -> bool { let b = s.as_bytes(); if b.len() != 20 { return false; } let digit = |i: usize| b[i].is_ascii_digit(); digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-' && digit(5) && digit(6) && b[7] == b'-' && digit(8) && digit(9) && b[10] == b'T' && digit(11) && digit(12) && b[13] == b':' && digit(14) && digit(15) && b[16] == b':' && digit(17) && digit(18) && b[19] == b'Z' } 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. pub(super) fn append(path: &Path, line: &str) -> Result<(), PersistenceError> { let mut f = OpenOptions::new() .create(true) .append(true) .open(path) .map_err(|source| PersistenceError::Io { operation: "open", path: path.to_path_buf(), source, })?; f.write_all(line.as_bytes()) .map_err(|source| PersistenceError::Io { operation: "write", path: path.to_path_buf(), source, })?; f.sync_all().map_err(|source| PersistenceError::Io { operation: "fsync", path: path.to_path_buf(), source, })?; Ok(()) } fn escape_command(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\\' => out.push_str("\\\\"), _ => out.push(c), } } out } /// Current UTC time as ISO-8601 with second precision and a /// `Z` suffix. pub(super) fn utc_iso8601_now() -> String { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); iso8601_from_unix_secs(secs) } fn iso8601_from_unix_secs(secs: i64) -> String { let day_secs = secs.rem_euclid(86_400); let h = day_secs / 3600; let m = (day_secs % 3600) / 60; let s = day_secs % 60; let (y, mo, d) = ymd_from_unix_secs(secs); format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") } const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) { let days = secs.div_euclid(86_400); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; (y as u32, m as u32, d as u32) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn record_format() { let line = format_record( "create table Customers with pk id(serial)", "2026-05-07T14:30:12Z".to_string(), ); assert_eq!( line, "2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial)\n", ); } #[test] fn newlines_in_command_are_escaped() { let line = format_record("foo\nbar", "T".to_string()); assert_eq!(line, "T|ok|foo\\nbar\n"); } // ---- ADR-0034 §3 — journal-record detection for replay ---- #[test] fn parse_journal_record_ok_extracts_unescaped_source() { let rec = parse_journal_record( "2026-05-24T10:00:00Z|ok|create table T with pk id(int)", ) .expect("valid ok journal record"); assert!(rec.status_is_ok); assert_eq!(rec.source, "create table T with pk id(int)"); } #[test] fn parse_journal_record_err_is_record_but_not_ok() { // A valid timestamp + `err` status is a journal record (so // replay treats it as skippable), reported `status_is_ok = // false`. let rec = parse_journal_record("2026-05-24T10:00:02Z|err|insert into T values (1)") .expect("valid err journal record"); assert!(!rec.status_is_ok); } #[test] fn parse_journal_record_unknown_status_is_record_but_not_ok() { // A valid timestamp + an unrecognised status is still a // journal record (ADR-0034 §1 "readers ignore values they // do not recognise"); replay skips it rather than running it. let rec = parse_journal_record("2026-05-24T10:00:02Z|frobnicate|whatever") .expect("valid-ts record with unknown status"); assert!(!rec.status_is_ok); } #[test] fn parse_journal_record_rejects_bare_command_with_pipe() { // A bare command that merely contains `|` must NOT be read // as a journal record — its first field is not a timestamp. assert!(parse_journal_record("select 'a|b' from t").is_none()); assert!(parse_journal_record("show data Orders").is_none()); } #[test] fn parse_journal_record_rejects_timestamp_ish_but_invalid_prefix() { // Boundary: looks vaguely date-y but isn't the exact // `YYYY-MM-DDTHH:MM:SSZ` shape → bare command. assert!(parse_journal_record("2026-5-24|ok|x").is_none()); assert!(parse_journal_record("2026-05-24 10:00:00|ok|x").is_none()); assert!(parse_journal_record("notatimestamp|ok|x").is_none()); } #[test] fn parse_journal_record_preserves_pipe_in_source() { // `|` is not escaped by the writer (it's a valid SQL char); // `splitn(3, '|')` keeps everything after the second `|`. let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t") .expect("ok record"); assert_eq!(rec.source, "select 'a|b' from t"); } #[test] fn parse_journal_record_round_trips_a_written_record() { // What `format_record` writes, `parse_journal_record` reads // back to the original command (escape→unescape lossless for // the awkward cases). let cmd = "update T set v = 'x\\y' where id = 1"; let line = format_record(cmd, "2026-05-24T10:00:00Z".to_string()); let rec = parse_journal_record(line.trim_end()).expect("round-trip record"); assert!(rec.status_is_ok); assert_eq!(rec.source, cmd); } #[test] fn backslash_is_escaped() { let line = format_record("a\\b", "T".to_string()); assert_eq!(line, "T|ok|a\\\\b\n"); } #[test] fn iso8601_format_is_well_formed() { let s = utc_iso8601_now(); // YYYY-MM-DDTHH:MM:SSZ has length 20. assert_eq!(s.len(), 20); assert!(s.ends_with('Z')); assert_eq!(&s[4..5], "-"); assert_eq!(&s[10..11], "T"); } #[test] fn iso8601_known_seconds() { assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z"); 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(); let path = dir.path().join("history.log"); append(&path, "first|ok|a\n").unwrap(); append(&path, "second|ok|b\n").unwrap(); let body = fs::read_to_string(&path).unwrap(); assert_eq!(body, "first|ok|a\nsecond|ok|b\n"); } // ---- ADR-0052 (issue #30): mode tag in the status field ---- #[test] fn status_token_builds_and_parses_the_adv_suffix() { assert_eq!(status_token(STATUS_OK, false), "ok"); assert_eq!(status_token(STATUS_OK, true), "ok:adv"); assert_eq!(status_token(STATUS_ERR, true), "err:adv"); assert_eq!(parse_status("ok"), (true, false)); assert_eq!(parse_status("ok:adv"), (true, true)); assert_eq!(parse_status("err"), (false, false)); assert_eq!(parse_status("err:adv"), (false, true)); // Unknown base → not ok (replay skips it), simple. assert_eq!(parse_status("frobnicate"), (false, false)); } #[test] fn read_recent_sources_reconstructs_colon_prefix_for_advanced() { // An advanced record (`ok:adv`) hydrates in its `:`-prefixed // simple-mode runnable form; a simple record stays bare. This is // the cross-session half of the issue #30 fix. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("history.log"); let adv = format_record_with_status( "select * from T", "2026-06-13T10:00:00Z".to_string(), &status_token(STATUS_OK, true), ); let simple = format_record_with_status( "create table T with pk", "2026-06-13T10:00:01Z".to_string(), &status_token(STATUS_OK, false), ); std::fs::write(&path, format!("{adv}{simple}")).unwrap(); let got = read_recent_sources(&path, 10).unwrap(); assert_eq!( got, vec![ ": select * from T".to_string(), "create table T with pk".to_string(), ], ); } #[test] fn parse_journal_record_treats_ok_adv_as_ok() { // Replay keys off the base token, so `ok:adv` replays like `ok` // (source stays canonical). let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T") .expect("ok:adv journal record"); assert!(rec.status_is_ok); assert_eq!(rec.source, "select * from T"); let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad") .expect("err:adv journal record"); assert!(!err.status_is_ok); } #[test] fn old_three_field_log_reads_as_simple() { // Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("history.log"); std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap(); let got = read_recent_sources(&path, 10).unwrap(); assert_eq!(got, vec!["select 1".to_string()]); } }