1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct AgentHistoryEntry {
9 pub unit_id: String,
10 pub title: String,
11 pub attempt: u32,
12 pub success: bool,
13 pub duration_secs: u64,
14 pub tokens: u64,
15 pub cost: f64,
16 pub tool_count: usize,
17 pub error: Option<String>,
18 pub model: String,
19 pub timestamp: String,
20}
21
22pub fn append_history(mana_dir: &Path, entry: &AgentHistoryEntry) {
26 let _ = try_append(mana_dir, entry);
27}
28
29fn try_append(
30 mana_dir: &Path,
31 entry: &AgentHistoryEntry,
32) -> Result<(), Box<dyn std::error::Error>> {
33 let path = mana_dir.join("agent_history.jsonl");
34 let line = serde_json::to_string(entry)?;
35 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
36 writeln!(file, "{}", line)?;
37 Ok(())
38}
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43 use std::fs;
44 use tempfile::TempDir;
45
46 fn make_entry(success: bool) -> AgentHistoryEntry {
47 AgentHistoryEntry {
48 unit_id: "42".to_string(),
49 title: "Test unit".to_string(),
50 attempt: 1,
51 success,
52 duration_secs: 30,
53 tokens: 5000,
54 cost: 0.03,
55 tool_count: 12,
56 error: None,
57 model: "default".to_string(),
58 timestamp: "2026-03-03T00:00:00Z".to_string(),
59 }
60 }
61
62 #[test]
63 fn append_creates_file_and_writes_valid_jsonl() {
64 let dir = TempDir::new().unwrap();
65 let mana_dir = dir.path().join(".mana");
66 fs::create_dir(&mana_dir).unwrap();
67
68 let entry = make_entry(true);
69 append_history(&mana_dir, &entry);
70
71 let content = fs::read_to_string(mana_dir.join("agent_history.jsonl")).unwrap();
72 let lines: Vec<&str> = content.lines().collect();
73 assert_eq!(lines.len(), 1);
74
75 let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
76 assert_eq!(parsed["unit_id"], "42");
77 assert_eq!(parsed["success"], true);
78 assert_eq!(parsed["tokens"], 5000);
79 assert_eq!(parsed["cost"], 0.03);
80 }
81
82 #[test]
83 fn append_appends_multiple_lines() {
84 let dir = TempDir::new().unwrap();
85 let mana_dir = dir.path().join(".mana");
86 fs::create_dir(&mana_dir).unwrap();
87
88 append_history(&mana_dir, &make_entry(true));
89 append_history(&mana_dir, &make_entry(false));
90
91 let content = fs::read_to_string(mana_dir.join("agent_history.jsonl")).unwrap();
92 let lines: Vec<&str> = content.lines().collect();
93 assert_eq!(lines.len(), 2);
94
95 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
96 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
97 assert_eq!(first["success"], true);
98 assert_eq!(second["success"], false);
99 }
100
101 #[test]
102 fn append_error_field_serialized_when_present() {
103 let dir = TempDir::new().unwrap();
104 let mana_dir = dir.path().join(".mana");
105 fs::create_dir(&mana_dir).unwrap();
106
107 let mut entry = make_entry(false);
108 entry.error = Some("Exit code 1".to_string());
109 append_history(&mana_dir, &entry);
110
111 let content = fs::read_to_string(mana_dir.join("agent_history.jsonl")).unwrap();
112 let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
113 assert_eq!(parsed["error"], "Exit code 1");
114 }
115
116 #[test]
117 fn append_error_field_null_when_none() {
118 let dir = TempDir::new().unwrap();
119 let mana_dir = dir.path().join(".mana");
120 fs::create_dir(&mana_dir).unwrap();
121
122 append_history(&mana_dir, &make_entry(true));
123
124 let content = fs::read_to_string(mana_dir.join("agent_history.jsonl")).unwrap();
125 let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
126 assert!(parsed["error"].is_null());
127 }
128
129 #[test]
130 fn append_swallows_errors_on_missing_dir() {
131 let dir = TempDir::new().unwrap();
132 let bogus = dir.path().join("nonexistent");
133
134 append_history(&bogus, &make_entry(true));
136 }
137}