scope_cli/cli/tui/
journal.rs1use serde::Serialize;
14use std::borrow::Cow;
15use std::fs::{self, OpenOptions};
16use std::io::{self, Write};
17use std::path::PathBuf;
18
19pub const HISTORY_LIMIT: usize = 1000;
21
22const RUSTYLINE_HEADER: &str = "#V2";
24
25#[derive(Debug, Clone, Default)]
27pub struct Journal {
28 history: Option<PathBuf>,
29 events: Option<PathBuf>,
30}
31
32#[derive(Debug, Serialize)]
34pub struct Event<'a> {
35 pub started: String,
37 pub duration_ms: u128,
39 pub input: &'a str,
41 pub argv: &'a [String],
44 pub route: &'static str,
46 pub outcome: Cow<'static, str>,
49 pub error: Option<String>,
51}
52
53impl Journal {
54 pub fn in_data_dir() -> Self {
56 let dir = scope::paths::state_dir();
57 Self {
58 history: Some(dir.join("history.txt")),
59 events: Some(dir.join("tui-events.jsonl")),
60 }
61 }
62
63 pub fn at(history: PathBuf, events: PathBuf) -> Self {
65 Self {
66 history: Some(history),
67 events: Some(events),
68 }
69 }
70
71 pub fn load_history(&self) -> Vec<String> {
73 let Some(path) = &self.history else {
74 return Vec::new();
75 };
76 fs::read_to_string(path)
77 .map(|text| {
78 text.lines()
79 .filter(|l| !l.is_empty() && *l != RUSTYLINE_HEADER)
80 .map(str::to_string)
81 .collect()
82 })
83 .unwrap_or_default()
84 }
85
86 pub fn save_history(&self, history: &[String]) -> io::Result<()> {
92 let Some(path) = &self.history else {
93 return Ok(());
94 };
95 if let Some(parent) = path.parent() {
96 fs::create_dir_all(parent)?;
97 }
98 let start = history.len().saturating_sub(HISTORY_LIMIT);
99 let mut text = history[start..].join("\n");
100 text.push('\n');
101 fs::write(path, text)
102 }
103
104 pub fn tail(&self, n: usize) -> Vec<String> {
107 let Some(path) = &self.events else {
108 return Vec::new();
109 };
110 let text = fs::read_to_string(path).unwrap_or_default();
111 let lines: Vec<&str> = text.lines().collect();
112 lines[lines.len().saturating_sub(n)..]
113 .iter()
114 .map(|l| (*l).to_string())
115 .collect()
116 }
117
118 pub fn record(&self, event: &Event<'_>) -> io::Result<()> {
124 let Some(path) = &self.events else {
125 return Ok(());
126 };
127 if let Some(parent) = path.parent() {
128 fs::create_dir_all(parent)?;
129 }
130 let mut line = serde_json::to_string(event)?;
131 line.push('\n');
132 OpenOptions::new()
133 .create(true)
134 .append(true)
135 .open(path)?
136 .write_all(line.as_bytes())
137 }
138}
139
140#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn journal(dir: &tempfile::TempDir) -> Journal {
149 Journal::at(dir.path().join("h.txt"), dir.path().join("e.jsonl"))
150 }
151
152 #[test]
153 fn test_history_round_trip_keeps_newest_entries() {
154 let dir = tempfile::tempdir().unwrap();
155 let j = journal(&dir);
156 let many: Vec<String> = (0..HISTORY_LIMIT + 5)
157 .map(|i| format!("cmd {}", i))
158 .collect();
159 j.save_history(&many).unwrap();
160 let loaded = j.load_history();
161 assert_eq!(loaded.len(), HISTORY_LIMIT);
162 assert_eq!(loaded.first().unwrap(), "cmd 5");
163 assert_eq!(
164 loaded.last().unwrap(),
165 &format!("cmd {}", HISTORY_LIMIT + 4)
166 );
167 }
168
169 #[test]
170 fn test_rustyline_history_carries_over() {
171 let dir = tempfile::tempdir().unwrap();
173 let j = journal(&dir);
174 fs::write(dir.path().join("h.txt"), "#V2\naddress 0x1\n\ntx 0x2\n").unwrap();
175 assert_eq!(j.load_history(), vec!["address 0x1", "tx 0x2"]);
176 }
177
178 #[test]
179 fn test_events_append_one_json_object_per_line() {
180 let dir = tempfile::tempdir().unwrap();
181 let j = journal(&dir);
182 let argv = vec![
183 "scope".to_string(),
184 "venues".to_string(),
185 "list".to_string(),
186 ];
187 for outcome in ["ok", "error"] {
188 j.record(&Event {
189 started: "2026-10-10T12:00:00Z".into(),
190 duration_ms: 12,
191 input: "venues list",
192 argv: &argv,
193 route: "pane",
194 outcome: outcome.into(),
195 error: (outcome == "error").then(|| "boom".to_string()),
196 })
197 .unwrap();
198 }
199 let text = fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
200 let rows: Vec<serde_json::Value> = text
201 .lines()
202 .map(|l| serde_json::from_str(l).unwrap())
203 .collect();
204 assert_eq!(rows.len(), 2);
205 assert_eq!(rows[0]["argv"][2], "list");
206 assert_eq!(rows[1]["outcome"], "error");
207 assert_eq!(rows[1]["error"], "boom");
208 }
209
210 #[test]
211 fn test_tail_returns_the_newest_lines_in_order() {
212 let dir = tempfile::tempdir().unwrap();
213 let j = journal(&dir);
214 assert!(j.tail(2).is_empty());
215 std::fs::write(dir.path().join("e.jsonl"), "a\nb\nc\n").unwrap();
216 assert_eq!(j.tail(2), vec!["b", "c"]);
217 assert_eq!(j.tail(10), vec!["a", "b", "c"]);
218 }
219
220 #[test]
221 fn test_data_dir_journal_uses_the_documented_file_names() {
222 let j = Journal::in_data_dir();
224 if let (Some(h), Some(e)) = (&j.history, &j.events) {
225 assert!(h.ends_with("state/history.txt"));
226 assert!(e.ends_with("state/tui-events.jsonl"));
227 }
228 }
229
230 #[test]
231 fn test_disabled_journal_is_a_no_op() {
232 let j = Journal::default();
233 assert!(j.load_history().is_empty());
234 j.save_history(&["x".into()]).unwrap();
235 }
236}