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}