use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use serde_json::Value as JsonValue;
use crate::builder::canonical::canonicalize;
use crate::builder::ulid_gen::new_ulid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoryOp {
ProjectInit,
AddModel,
AddField,
Commit,
FileCreated,
Undo,
}
impl HistoryOp {
pub(crate) fn as_str(self) -> &'static str {
match self {
HistoryOp::ProjectInit => "project_init",
HistoryOp::AddModel => "add_model",
HistoryOp::AddField => "add_field",
HistoryOp::Commit => "commit",
HistoryOp::FileCreated => "file_created",
HistoryOp::Undo => "undo",
}
}
}
#[derive(Debug, Serialize)]
struct EventLine<'a> {
id: &'a str,
ts: &'a str,
op: &'a str,
actor: &'a str,
args: &'a JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
prev_hash: Option<&'a str>,
schema_v: u32,
}
const HISTORY_SCHEMA_V: u32 = 1;
pub(crate) fn append(
history_path: &Path,
op: HistoryOp,
actor: &str,
args: JsonValue,
) -> std::io::Result<String> {
let id = new_ulid();
let ts = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
let line = EventLine {
id: &id,
ts: &ts,
op: op.as_str(),
actor,
args: &args,
prev_hash: None, schema_v: HISTORY_SCHEMA_V,
};
let mut json = serde_json::to_string(&line).map_err(std::io::Error::other)?;
json.push('\n');
let canonical = canonicalize(&json);
const MAX_ATOMIC_LINE_BYTES: usize = 4096;
if canonical.len() > MAX_ATOMIC_LINE_BYTES {
return Err(std::io::Error::other(format!(
"history.jsonl event line is {} bytes -- exceeds the {}-byte atomic-write \
bound from `DESIGN_BUILDER.md` §4.2.1. Refusing to write a payload that \
could interleave under concurrent CLI invocations.",
canonical.len(),
MAX_ATOMIC_LINE_BYTES
)));
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(history_path)?;
file.write_all(canonical.as_bytes())?;
file.flush()?;
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn op_enum_matches_serialized() {
let pairs = [
(HistoryOp::ProjectInit, "project_init"),
(HistoryOp::AddModel, "add_model"),
(HistoryOp::AddField, "add_field"),
(HistoryOp::Commit, "commit"),
(HistoryOp::FileCreated, "file_created"),
(HistoryOp::Undo, "undo"),
];
for (variant, expected) in pairs {
assert_eq!(
variant.as_str(),
expected,
"HistoryOp::{variant:?} must serialize as {expected:?}"
);
}
for (variant, expected) in pairs {
for c in expected.chars() {
assert!(
c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit(),
"HistoryOp::{variant:?} serialized as {expected:?} \
contains non-snake_case char {c:?}"
);
}
}
}
#[test]
fn append_writes_one_line_per_call() {
let dir = tempdir();
let path = dir.join("history.jsonl");
append(
&path,
HistoryOp::ProjectInit,
"alice@example.com",
json!({}),
)
.unwrap();
append(
&path,
HistoryOp::AddModel,
"alice@example.com",
json!({"name": "Patient"}),
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2, "expected 2 lines, got: {content:?}");
}
#[test]
fn append_emits_required_fields_in_order() {
let dir = tempdir();
let path = dir.join("history.jsonl");
let id = append(
&path,
HistoryOp::AddModel,
"alice@example.com",
json!({"name": "Patient"}),
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let line = content.lines().next().unwrap();
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["id"], id);
assert_eq!(parsed["op"], "add_model");
assert_eq!(parsed["actor"], "alice@example.com");
assert_eq!(parsed["args"]["name"], "Patient");
assert_eq!(parsed["schema_v"], 1);
assert!(parsed["ts"].is_string());
let raw = line; let expected_order = [
"\"id\":",
"\"ts\":",
"\"op\":",
"\"actor\":",
"\"args\":",
"\"schema_v\":",
];
let mut prev_pos = 0usize;
for marker in expected_order {
let pos = raw[prev_pos..]
.find(marker)
.unwrap_or_else(|| panic!("missing or out-of-order {marker} in {raw}"));
prev_pos += pos + marker.len();
}
}
#[test]
fn append_is_append_only() {
let dir = tempdir();
let path = dir.join("history.jsonl");
append(&path, HistoryOp::ProjectInit, "a", json!({})).unwrap();
let after_first = std::fs::read_to_string(&path).unwrap();
append(&path, HistoryOp::AddModel, "a", json!({"name": "X"})).unwrap();
let after_second = std::fs::read_to_string(&path).unwrap();
assert!(
after_second.starts_with(&after_first),
"second append truncated prior content:\n\
after_first = {after_first:?}\n\
after_second = {after_second:?}"
);
assert!(after_second.len() > after_first.len());
}
#[test]
fn append_uses_second_precision_timestamps() {
let dir = tempdir();
let path = dir.join("history.jsonl");
append(&path, HistoryOp::ProjectInit, "a", json!({})).unwrap();
let line = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
let ts = parsed["ts"].as_str().unwrap();
assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
assert!(
!ts.contains('.'),
"timestamp must not have sub-seconds: {ts}"
);
}
#[test]
fn no_other_writer_to_history_jsonl_in_cli_crate() {
let cli_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
scan_for_history_writes(&cli_src, &mut offenders);
assert!(
offenders.is_empty(),
"Doctrine B3 violation: files other than \
builder/history.rs construct write handles to \
history.jsonl: {offenders:?}",
);
}
fn scan_for_history_writes(dir: &std::path::Path, offenders: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_for_history_writes(&path, offenders);
continue;
}
if path.ends_with("builder/history.rs") {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
for line in src.lines() {
let code = match line.find("//") {
Some(idx) => &line[..idx],
None => line,
};
let opens_write = (code.contains("OpenOptions")
&& (code.contains("append(true)") || code.contains(".write(true)")))
|| code.contains("File::create");
if opens_write && code.contains("history.jsonl") {
offenders.push(path.clone());
break;
}
}
}
}
#[test]
fn append_refuses_oversized_lines() {
let dir = tempdir();
let path = dir.join("history.jsonl");
let big = "x".repeat(5000);
let err = append(
&path,
HistoryOp::AddModel,
"a",
json!({ "name": "X", "table": big }),
)
.expect_err("must refuse oversized line");
assert!(err.to_string().contains("atomic-write"), "{err}");
assert!(
!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty(),
"oversized refusal must not leave a partial line on disk"
);
}
fn tempdir() -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!(
"rustio-history-test-{}-{}",
std::process::id(),
new_ulid()
));
std::fs::create_dir_all(&base).unwrap();
base
}
}