codeswarm_core/
history.rs1use std::fs::{self, OpenOptions};
10use std::io::{self, BufRead, BufReader, Write};
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub const MAX_HISTORY_ENTRIES: usize = 50;
16
17pub fn parse_entry(line: &str) -> Option<String> {
20 let value = serde_json::from_str::<serde_json::Value>(line).ok()?;
21 match value {
22 serde_json::Value::Object(object) => object
24 .get("input")
25 .and_then(serde_json::Value::as_str)
26 .map(ToOwned::to_owned),
27 serde_json::Value::String(prompt) => Some(prompt),
29 _ => None,
30 }
31}
32
33pub fn read(path: impl AsRef<Path>) -> io::Result<Vec<String>> {
36 let file = match std::fs::File::open(path) {
37 Ok(file) => file,
38 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
39 Err(error) => return Err(error),
40 };
41 let mut entries = Vec::new();
42 for line in BufReader::new(file).lines() {
43 let line = line?;
44 if let Some(prompt) = parse_entry(&line)
45 && !prompt.trim().is_empty()
46 {
47 entries.push(prompt);
48 if entries.len() > MAX_HISTORY_ENTRIES {
49 entries.remove(0);
50 }
51 }
52 }
53 Ok(entries)
54}
55
56pub fn append(path: impl AsRef<Path>, prompt: &str) -> io::Result<()> {
62 if prompt.trim().is_empty() {
63 return Ok(());
64 }
65 let path = path.as_ref();
66 if let Some(parent) = path.parent() {
67 fs::create_dir_all(parent)?;
68 }
69 let timestamp = SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .map(|duration| duration.as_secs_f64())
72 .unwrap_or_default();
73 let record = serde_json::json!({ "input": prompt, "timestamp": timestamp });
74 let encoded = serde_json::to_string(&record).map_err(io::Error::other)?;
75 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
76 file.write_all(encoded.as_bytes())?;
77 file.write_all(b"\n")
78}
79
80#[cfg(test)]
81mod tests {
82 use std::time::{SystemTime, UNIX_EPOCH};
83
84 use super::{MAX_HISTORY_ENTRIES, append, parse_entry, read};
85
86 fn temp_path(prefix: &str) -> std::path::PathBuf {
87 let unique = SystemTime::now()
88 .duration_since(UNIX_EPOCH)
89 .expect("clock")
90 .as_nanos();
91 std::env::temp_dir().join(format!("codeswarm-{prefix}-{unique}.jsonl"))
92 }
93
94 #[test]
95 fn parses_python_and_early_rust_records_but_skips_damage() {
96 assert_eq!(
97 parse_entry(r#"{"input":"git status","timestamp":1}"#),
98 Some("git status".into())
99 );
100 assert_eq!(parse_entry(r#""git log""#), Some("git log".into()));
101 assert_eq!(parse_entry("not json"), None);
102 assert_eq!(parse_entry("123"), None);
103 assert_eq!(parse_entry(r#"{"input":7}"#), None);
104 }
105
106 #[test]
107 fn reads_valid_entries_around_torn_lines_and_bounds_to_newest() {
108 let path = temp_path("history-read");
109 let mut content = String::new();
110 for index in 0..(MAX_HISTORY_ENTRIES + 3) {
111 content.push_str(&format!(
112 r#"{{"input":"prompt-{index}","timestamp":{index}}}"#
113 ));
114 content.push('\n');
115 }
116 content.push_str("{\"input\":\"torn\n");
117 std::fs::write(&path, content).expect("write");
118 let entries = read(&path).expect("read");
119 assert_eq!(entries.len(), MAX_HISTORY_ENTRIES);
120 assert_eq!(entries.first().map(String::as_str), Some("prompt-3"));
121 assert_eq!(entries.last().map(String::as_str), Some("prompt-52"));
122 std::fs::remove_file(path).expect("cleanup");
123 }
124
125 #[test]
126 fn append_writes_python_compatible_record_and_empty_is_noop() {
127 let path = temp_path("history-write");
128 append(&path, "").expect("empty append");
129 assert!(!path.exists());
130 append(&path, "make verify").expect("append");
131 let line = std::fs::read_to_string(&path).expect("read");
132 let value: serde_json::Value = serde_json::from_str(line.trim()).expect("json");
133 assert_eq!(value["input"], "make verify");
134 assert!(value["timestamp"].is_number());
135 assert_eq!(read(&path).expect("history"), ["make verify"]);
136 std::fs::remove_file(path).expect("cleanup");
137 }
138}