agent-decision-log 0.1.0

WHY-layer decision log for AI agents: record options considered, the option chosen, the rationale, and the outcome, then persist as JSONL.
Documentation
use agent_decision_log::{Decision, DecisionLog};
use serde_json::json;
use std::thread;

fn sample(log: &mut DecisionLog) -> String {
    log.add(
        vec!["a".to_string(), "b".to_string()],
        "a",
        "a is cheaper",
        json!({"turn": 0}),
    )
}

#[test]
fn new_log_is_empty() {
    let log = DecisionLog::new();
    assert!(log.is_empty());
    assert_eq!(log.len(), 0);
    assert!(log.last().is_none());
}

#[test]
fn default_log_is_empty() {
    let log: DecisionLog = Default::default();
    assert_eq!(log.len(), 0);
}

#[test]
fn add_returns_unique_id() {
    let mut log = DecisionLog::new();
    let id1 = sample(&mut log);
    let id2 = sample(&mut log);
    assert_ne!(id1, id2);
    assert_eq!(log.len(), 2);
}

#[test]
fn add_preserves_insertion_order() {
    let mut log = DecisionLog::new();
    let id1 = log.add(vec!["x".to_string()], "x", "first", json!({}));
    let id2 = log.add(vec!["y".to_string()], "y", "second", json!({}));
    assert_eq!(log.decisions[0].id, id1);
    assert_eq!(log.decisions[1].id, id2);
}

#[test]
fn find_by_id_hits_and_misses() {
    let mut log = DecisionLog::new();
    let id = sample(&mut log);
    assert!(log.find_by_id(&id).is_some());
    assert!(log.find_by_id("not-a-real-id").is_none());
}

#[test]
fn last_returns_most_recent() {
    let mut log = DecisionLog::new();
    sample(&mut log);
    let id2 = sample(&mut log);
    assert_eq!(log.last().unwrap().id, id2);
}

#[test]
fn set_outcome_updates_existing() {
    let mut log = DecisionLog::new();
    let id = sample(&mut log);
    assert!(log.set_outcome(&id, "ran fine"));
    assert_eq!(
        log.find_by_id(&id).unwrap().outcome.as_deref(),
        Some("ran fine")
    );
}

#[test]
fn set_outcome_overwrites_previous() {
    let mut log = DecisionLog::new();
    let id = sample(&mut log);
    log.set_outcome(&id, "first");
    log.set_outcome(&id, "second");
    assert_eq!(
        log.find_by_id(&id).unwrap().outcome.as_deref(),
        Some("second")
    );
}

#[test]
fn set_outcome_returns_false_for_missing() {
    let mut log = DecisionLog::new();
    assert!(!log.set_outcome("nope", "x"));
}

#[test]
fn outcome_starts_as_none() {
    let mut log = DecisionLog::new();
    let id = sample(&mut log);
    assert!(log.find_by_id(&id).unwrap().outcome.is_none());
}

#[test]
fn chose_listed_option_true_when_in_options() {
    let mut log = DecisionLog::new();
    log.add(
        vec!["a".to_string(), "b".to_string()],
        "b",
        "picked b",
        json!({}),
    );
    assert!(log.last().unwrap().chose_listed_option());
}

#[test]
fn chose_listed_option_false_when_hallucinated() {
    let mut log = DecisionLog::new();
    log.add(
        vec!["a".to_string(), "b".to_string()],
        "c",
        "made up c",
        json!({}),
    );
    assert!(!log.last().unwrap().chose_listed_option());
}

#[test]
fn add_accepts_string_and_str() {
    let mut log = DecisionLog::new();
    log.add(vec!["a"], "a", "rationale", json!({}));
    log.add(vec!["b".to_string()], "b".to_string(), "r2".to_string(), json!({}));
    assert_eq!(log.len(), 2);
}

#[test]
fn meta_round_trips_complex_json() {
    let mut log = DecisionLog::new();
    let meta = json!({
        "turn": 7,
        "tool_args": {"q": "hello", "n": 5},
        "tags": ["retry", "fallback"],
    });
    let id = log.add(vec!["x"], "x", "r", meta.clone());
    assert_eq!(log.find_by_id(&id).unwrap().meta, meta);
}

#[test]
fn id_format_is_dec_prefix() {
    let mut log = DecisionLog::new();
    let id = sample(&mut log);
    assert!(id.starts_with("dec_"));
}

#[test]
fn ids_unique_across_threads() {
    use std::sync::{Arc, Mutex};
    let log = Arc::new(Mutex::new(DecisionLog::new()));
    let mut handles = Vec::new();
    for _ in 0..8 {
        let log_cl = Arc::clone(&log);
        handles.push(thread::spawn(move || {
            for _ in 0..100 {
                let mut guard = log_cl.lock().unwrap();
                guard.add(vec!["a"], "a", "r", json!({}));
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    let guard = log.lock().unwrap();
    assert_eq!(guard.len(), 800);
    let mut ids: Vec<&str> = guard.decisions.iter().map(|d| d.id.as_str()).collect();
    ids.sort();
    ids.dedup();
    assert_eq!(ids.len(), 800);
}

#[test]
fn jsonl_round_trip_empty() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("empty.jsonl");
    let log = DecisionLog::new();
    log.to_jsonl(&path).unwrap();
    let loaded = DecisionLog::from_jsonl(&path).unwrap();
    assert!(loaded.is_empty());
}

#[test]
fn jsonl_round_trip_single_decision() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("one.jsonl");
    let mut log = DecisionLog::new();
    let id = log.add(
        vec!["a", "b"],
        "a",
        "because reasons",
        json!({"turn": 4}),
    );
    log.set_outcome(&id, "ok");
    log.to_jsonl(&path).unwrap();

    let loaded = DecisionLog::from_jsonl(&path).unwrap();
    assert_eq!(loaded.len(), 1);
    let d = &loaded.decisions[0];
    assert_eq!(d.id, id);
    assert_eq!(d.chosen, "a");
    assert_eq!(d.outcome.as_deref(), Some("ok"));
    assert_eq!(d.meta, json!({"turn": 4}));
}

#[test]
fn jsonl_round_trip_many_decisions() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("many.jsonl");
    let mut log = DecisionLog::new();
    for i in 0..50 {
        let id = log.add(
            vec![format!("opt-{i}")],
            format!("opt-{i}"),
            format!("rationale {i}"),
            json!({"i": i}),
        );
        if i % 2 == 0 {
            log.set_outcome(&id, format!("done {i}"));
        }
    }
    log.to_jsonl(&path).unwrap();

    let loaded = DecisionLog::from_jsonl(&path).unwrap();
    assert_eq!(loaded.len(), 50);
    assert_eq!(loaded.decisions[0].chosen, "opt-0");
    assert_eq!(loaded.decisions[49].chosen, "opt-49");
    assert_eq!(loaded.decisions[10].outcome.as_deref(), Some("done 10"));
    assert!(loaded.decisions[11].outcome.is_none());
}

#[test]
fn jsonl_one_object_per_line() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("lines.jsonl");
    let mut log = DecisionLog::new();
    log.add(vec!["a"], "a", "r1", json!({}));
    log.add(vec!["b"], "b", "r2", json!({}));
    log.to_jsonl(&path).unwrap();
    let raw = std::fs::read_to_string(&path).unwrap();
    let lines: Vec<&str> = raw.lines().collect();
    assert_eq!(lines.len(), 2);
    for line in lines {
        let _: serde_json::Value = serde_json::from_str(line).unwrap();
    }
}

#[test]
fn jsonl_skips_blank_lines() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("blanks.jsonl");
    let mut log = DecisionLog::new();
    log.add(vec!["a"], "a", "r", json!({}));
    log.to_jsonl(&path).unwrap();
    let raw = std::fs::read_to_string(&path).unwrap();
    std::fs::write(&path, format!("\n\n{}\n\n", raw.trim())).unwrap();

    let loaded = DecisionLog::from_jsonl(&path).unwrap();
    assert_eq!(loaded.len(), 1);
}

#[test]
fn jsonl_rejects_malformed_line() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("bad.jsonl");
    std::fs::write(&path, "this is not json\n").unwrap();
    let err = DecisionLog::from_jsonl(&path).unwrap_err();
    assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}

#[test]
fn from_jsonl_missing_file_is_error() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("does-not-exist.jsonl");
    assert!(DecisionLog::from_jsonl(&path).is_err());
}

#[test]
fn decision_clone_round_trip() {
    let mut log = DecisionLog::new();
    sample(&mut log);
    let d1 = log.decisions[0].clone();
    let d2: Decision = d1.clone();
    assert_eq!(d1, d2);
}

#[test]
fn log_clone_round_trip() {
    let mut log = DecisionLog::new();
    sample(&mut log);
    sample(&mut log);
    let log2 = log.clone();
    assert_eq!(log, log2);
}

#[test]
fn timestamp_is_set_on_add() {
    use std::time::SystemTime;
    let before = SystemTime::now();
    let mut log = DecisionLog::new();
    sample(&mut log);
    let after = SystemTime::now();
    let t = log.decisions[0].timestamp;
    assert!(t >= before);
    assert!(t <= after);
}

#[test]
fn json_payload_compact_keys() {
    let mut log = DecisionLog::new();
    let id = log.add(vec!["a"], "a", "r", json!({"k": "v"}));
    let line = serde_json::to_string(&log.decisions[0]).unwrap();
    assert!(line.contains("\"id\":\""));
    assert!(line.contains(&id));
    assert!(line.contains("\"chosen\":\"a\""));
    assert!(line.contains("\"options\":[\"a\"]"));
}

#[test]
fn outcome_omitted_when_none() {
    let mut log = DecisionLog::new();
    log.add(vec!["a"], "a", "r", json!({}));
    let line = serde_json::to_string(&log.decisions[0]).unwrap();
    assert!(!line.contains("\"outcome\""));
}

#[test]
fn outcome_present_when_set() {
    let mut log = DecisionLog::new();
    let id = log.add(vec!["a"], "a", "r", json!({}));
    log.set_outcome(&id, "done");
    let line = serde_json::to_string(&log.decisions[0]).unwrap();
    assert!(line.contains("\"outcome\":\"done\""));
}

#[test]
fn empty_options_are_allowed() {
    let mut log = DecisionLog::new();
    log.add(Vec::<String>::new(), "fallback", "no options", json!({}));
    assert_eq!(log.last().unwrap().options.len(), 0);
    assert!(!log.last().unwrap().chose_listed_option());
}