use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::adapter::Fs;
use crate::error::SessionError;
use crate::layout::StorePaths;
use crate::manifest::{append_jsonl_record, read_jsonl_records};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunDiagnostic {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStep {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_hash: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub affected_nodes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<RunDiagnostic>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunRecord {
pub id: String,
pub seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brief: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraints: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<RunStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u128>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_hash: Option<String>,
}
pub fn append_run(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
record: &RunRecord,
) -> Result<(), SessionError> {
append_jsonl_record(fs, &paths.runs_file(doc_id), record)
}
pub fn read_runs(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Vec<RunRecord>, SessionError> {
read_jsonl_records(fs, &paths.runs_file(doc_id))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::adapter::MemFs;
use crate::layout::StorePaths;
fn paths() -> StorePaths {
StorePaths::new("/data")
}
fn make_fs() -> MemFs {
MemFs::new()
}
fn full_step(id: &str) -> RunStep {
let mut params = BTreeMap::new();
params.insert("x".to_string(), "10".to_string());
params.insert("y".to_string(), "20".to_string());
RunStep {
id: id.to_string(),
parent: None,
action: "move_node".to_string(),
action_version: Some("rev-2".to_string()),
action_hash: Some("acthash42".to_string()),
params,
affected_nodes: vec!["node-a".to_string(), "node-b".to_string()],
diagnostics: vec![RunDiagnostic {
severity: "warning".to_string(),
code: "font.glyph_missing".to_string(),
message: "glyph U+FFFD not found".to_string(),
}],
source_hash: Some("src123".to_string()),
}
}
fn minimal_step(id: &str) -> RunStep {
RunStep {
id: id.to_string(),
parent: None,
action: "noop".to_string(),
action_version: None,
action_hash: None,
params: BTreeMap::new(),
affected_nodes: Vec::new(),
diagnostics: Vec::new(),
source_hash: None,
}
}
#[test]
fn append_then_read_runs_roundtrip() {
let fs = make_fs();
let paths = paths();
let r0 = RunRecord {
id: "run-0".to_string(),
seq: 0,
brief: Some("move two nodes".to_string()),
constraints: None,
plan: Some("step A then step B".to_string()),
steps: vec![full_step("s0"), minimal_step("s1")],
timestamp_ms: Some(1_700_000_000_100),
snapshot_hash: Some("snap0".to_string()),
};
let r1 = RunRecord {
id: "run-1".to_string(),
seq: 1,
brief: None,
constraints: Some("read-only".to_string()),
plan: None,
steps: Vec::new(),
timestamp_ms: Some(1_700_000_001_000),
snapshot_hash: None,
};
append_run(&fs, &paths, "doc1", &r0).unwrap();
append_run(&fs, &paths, "doc1", &r1).unwrap();
let records = read_runs(&fs, &paths, "doc1").unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0], r0);
assert_eq!(records[1], r1);
}
#[test]
fn lean_run_omits_optionals() {
let fs = make_fs();
let paths = paths();
let rec = RunRecord {
id: "run-lean".to_string(),
seq: 0,
brief: None,
constraints: None,
plan: None,
steps: Vec::new(),
timestamp_ms: None,
snapshot_hash: None,
};
append_run(&fs, &paths, "doc1", &rec).unwrap();
let raw = fs.read(&paths.runs_file("doc1")).unwrap();
let line = std::str::from_utf8(&raw).unwrap();
assert!(!line.contains("brief"), "brief must be absent in lean form");
assert!(
!line.contains("constraints"),
"constraints must be absent in lean form"
);
assert!(!line.contains("plan"), "plan must be absent in lean form");
assert!(!line.contains("steps"), "steps must be absent in lean form");
assert!(
!line.contains("timestamp_ms"),
"timestamp_ms must be absent in lean form"
);
assert!(
!line.contains("snapshot_hash"),
"snapshot_hash must be absent in lean form"
);
assert!(line.contains("\"id\""), "id must be present");
assert!(line.contains("\"seq\""), "seq must be present");
}
#[test]
fn old_run_line_without_new_fields_deserializes() {
let fs = make_fs();
let paths = paths();
let old_line = b"{\"id\":\"run-old\",\"seq\":3}\n";
let run_path = paths.runs_file("doc1");
fs.create_dir_all(run_path.parent().unwrap()).unwrap();
fs.write(&run_path, old_line).unwrap();
let records = read_runs(&fs, &paths, "doc1").unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "run-old");
assert_eq!(records[0].seq, 3);
assert_eq!(records[0].brief, None);
assert_eq!(records[0].constraints, None);
assert_eq!(records[0].plan, None);
assert!(records[0].steps.is_empty());
assert_eq!(records[0].timestamp_ms, None);
assert_eq!(records[0].snapshot_hash, None);
}
#[test]
fn read_runs_absent_is_empty() {
let fs = make_fs();
let paths = paths();
let records = read_runs(&fs, &paths, "no-such-doc").unwrap();
assert!(records.is_empty());
}
}