Skip to main content

otto_cli/
history.rs

1use crate::model::RunRecord;
2use std::fs::{self, File, OpenOptions};
3use std::io::{BufRead, BufReader, Write};
4use std::path::{Path, PathBuf};
5
6pub const DEFAULT_PATH: &str = ".otto/history.jsonl";
7
8#[derive(Debug, Clone, Default)]
9pub struct Filter {
10    pub limit: Option<usize>,
11    pub status: Option<String>,
12    pub source: Option<String>,
13}
14
15#[derive(Debug, Clone)]
16pub struct Store {
17    path: PathBuf,
18}
19
20impl Store {
21    pub fn new(path: impl Into<PathBuf>) -> Self {
22        Self { path: path.into() }
23    }
24
25    pub fn path(&self) -> &Path {
26        &self.path
27    }
28
29    pub fn append(&self, record: &RunRecord) -> Result<(), String> {
30        let parent = self
31            .path
32            .parent()
33            .ok_or_else(|| "invalid history path".to_string())?;
34
35        fs::create_dir_all(parent).map_err(|e| format!("create history directory: {e}"))?;
36
37        let mut file = OpenOptions::new()
38            .create(true)
39            .append(true)
40            .open(&self.path)
41            .map_err(|e| format!("open history file: {e}"))?;
42
43        let line =
44            serde_json::to_vec(record).map_err(|e| format!("serialize history record: {e}"))?;
45        file.write_all(&line)
46            .and_then(|_| file.write_all(b"\n"))
47            .map_err(|e| format!("write history record: {e}"))
48    }
49
50    pub fn list(&self, filter: &Filter) -> Result<Vec<RunRecord>, String> {
51        let file = match File::open(&self.path) {
52            Ok(file) => file,
53            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
54            Err(err) => return Err(format!("open history file: {err}")),
55        };
56
57        let mut records = Vec::new();
58        let reader = BufReader::new(file);
59
60        for line in reader.lines() {
61            let Ok(line) = line else {
62                continue;
63            };
64            let trimmed = line.trim();
65            if trimmed.is_empty() {
66                continue;
67            }
68
69            let Ok(rec) = serde_json::from_str::<RunRecord>(trimmed) else {
70                continue;
71            };
72
73            if !matches_filter(&rec, filter) {
74                continue;
75            }
76
77            records.push(rec);
78        }
79
80        records.reverse();
81
82        if let Some(limit) = filter.limit
83            && records.len() > limit
84        {
85            records.truncate(limit);
86        }
87
88        Ok(records)
89    }
90}
91
92fn matches_filter(record: &RunRecord, filter: &Filter) -> bool {
93    if let Some(status) = &filter.status {
94        let current = match record.status {
95            crate::model::RunStatus::Success => "success",
96            crate::model::RunStatus::Failed => "failed",
97        };
98        if current != status {
99            return false;
100        }
101    }
102
103    if let Some(source) = &filter.source {
104        let current = match record.source {
105            crate::model::RunSource::Task => "task",
106            crate::model::RunSource::Inline => "inline",
107        };
108        if current != source {
109            return false;
110        }
111    }
112
113    true
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::model::{RunSource, RunStatus};
120    use tempfile::tempdir;
121    use time::OffsetDateTime;
122
123    fn record(id: &str, source: RunSource, status: RunStatus) -> RunRecord {
124        RunRecord {
125            id: id.to_string(),
126            name: id.to_string(),
127            source,
128            command_preview: "echo ok".to_string(),
129            started_at: OffsetDateTime::now_utc(),
130            duration_ms: 10,
131            exit_code: if status == RunStatus::Success { 0 } else { 1 },
132            status,
133            stderr_tail: None,
134        }
135    }
136
137    #[test]
138    fn append_and_list() {
139        let dir = tempdir().expect("tempdir");
140        let path = dir.path().join("history.jsonl");
141        let store = Store::new(&path);
142
143        store
144            .append(&record("1", RunSource::Task, RunStatus::Success))
145            .expect("append first");
146        store
147            .append(&record("2", RunSource::Inline, RunStatus::Failed))
148            .expect("append second");
149
150        let rows = store
151            .list(&Filter {
152                limit: Some(10),
153                ..Filter::default()
154            })
155            .expect("list");
156        assert_eq!(rows.len(), 2);
157        assert_eq!(rows[0].id, "2");
158
159        let filtered = store
160            .list(&Filter {
161                status: Some("failed".to_string()),
162                ..Filter::default()
163            })
164            .expect("filter");
165        assert_eq!(filtered.len(), 1);
166        assert_eq!(filtered[0].id, "2");
167    }
168
169    #[test]
170    fn list_ignores_malformed_lines() {
171        let dir = tempdir().expect("tempdir");
172        let path = dir.path().join("history.jsonl");
173        let store = Store::new(&path);
174
175        store
176            .append(&record("good", RunSource::Task, RunStatus::Success))
177            .expect("append");
178
179        let mut file = OpenOptions::new()
180            .append(true)
181            .open(&path)
182            .expect("open for append");
183        writeln!(file, "{{bad").expect("write malformed");
184
185        let rows = store.list(&Filter::default()).expect("list");
186        assert_eq!(rows.len(), 1);
187        assert_eq!(rows[0].id, "good");
188    }
189
190    #[test]
191    fn list_missing_file() {
192        let dir = tempdir().expect("tempdir");
193        let path = dir.path().join("missing.jsonl");
194        let store = Store::new(path);
195        let rows = store.list(&Filter::default()).expect("list");
196        assert!(rows.is_empty());
197    }
198}