Skip to main content

codeswarm_core/
history.rs

1//! Durable prompt history compatible with the retired Python client.
2//!
3//! Python CodeSwarm stored one JSON object per line (`input` and
4//! `timestamp`).  Early Rust snapshots stored bare JSON strings instead.  The
5//! reader accepts both shapes so upgrading does not silently discard a
6//! user's prompt history; damaged or partially-written lines are ignored just
7//! like the Python implementation.
8
9use std::fs::{self, OpenOptions};
10use std::io::{self, BufRead, BufReader, Write};
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Number of entries retained in memory by the prompt editor.
15pub const MAX_HISTORY_ENTRIES: usize = 50;
16
17/// Decode one history line.  `None` means that the line is malformed or does
18/// not contain a string prompt.
19pub fn parse_entry(line: &str) -> Option<String> {
20    let value = serde_json::from_str::<serde_json::Value>(line).ok()?;
21    match value {
22        // Current Python-compatible representation.
23        serde_json::Value::Object(object) => object
24            .get("input")
25            .and_then(serde_json::Value::as_str)
26            .map(ToOwned::to_owned),
27        // Compatibility with the first Rust history writer.
28        serde_json::Value::String(prompt) => Some(prompt),
29        _ => None,
30    }
31}
32
33/// Read valid prompts from a JSONL history file, retaining the newest bounded
34/// window in original chronological order.  Missing files are empty history.
35pub 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
56/// Append one prompt in the Python-compatible object format.
57///
58/// Empty prompts are deliberately no-ops.  The write is intentionally not
59/// fsynced on the input/render path; session event persistence owns explicit
60/// durability checkpoints and prompt history is recoverable convenience state.
61pub 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}