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}