Files
rdbms-playground/src/persistence/history.rs
T
claude@clouddev1 d9a98bbd49 Grammar: with-pk column specs use name(type), matching add column
`create table … with pk` parsed column types as `name:type`,
while `add column` uses `name(type)`. Unify on the parens
form so column-type syntax is consistent across the DSL:

    create table T with pk id(serial), name(text)

Only `COL_SPEC` changes (`:` → `( … )`); `build_create_table`
reads columns by role, so it is unaffected. The `:` that
separates table from column in `add column` / `drop column`
is unchanged. Sweeps the test suite, the typing-surface
matrix (two `after_colon` cells renamed to `after_paren`,
4 snapshots regenerated), the friendly catalog's usage
templates, ADR-0009's example, and requirements.md.

1039 passing / 0 failing / 1 ignored; clippy clean.
2026-05-18 21:51:52 +00:00

306 lines
10 KiB
Rust

//! 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;
/// Format a single log record. Pure; no I/O.
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
let escaped = escape_command(command_text);
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.
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");
}
#[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");
}
}