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}