Skip to main content

scope_cli/cli/tui/
journal.rs

1//! # TUI Journal
2//!
3//! Two files under the scope data directory:
4//!
5//! - `history.txt`: the input history, one line per entry. The rustyline
6//!   REPL wrote the same file, so old history carries over.
7//! - `tui-events.jsonl`: one JSON object per submitted line, with the
8//!   exact input, the argument vector clap parsed, where the command ran,
9//!   how long it took and how it ended. One more object records a signal
10//!   that ends the TUI. Use it to find out after the fact what a TUI
11//!   session did.
12
13use serde::Serialize;
14use std::borrow::Cow;
15use std::fs::{self, OpenOptions};
16use std::io::{self, Write};
17use std::path::PathBuf;
18
19/// The most history entries kept on disk.
20pub const HISTORY_LIMIT: usize = 1000;
21
22/// The header line that rustyline writes at the top of its history file.
23const RUSTYLINE_HEADER: &str = "#V2";
24
25/// Where the journal files live. `None` paths turn the journal off.
26#[derive(Debug, Clone, Default)]
27pub struct Journal {
28    history: Option<PathBuf>,
29    events: Option<PathBuf>,
30}
31
32/// One record in `tui-events.jsonl`.
33#[derive(Debug, Serialize)]
34pub struct Event<'a> {
35    /// When the line was submitted (RFC 3339, UTC).
36    pub started: String,
37    /// Wall time until the command ended, in milliseconds.
38    pub duration_ms: u128,
39    /// The line exactly as the user typed it.
40    pub input: &'a str,
41    /// The argument vector clap parsed, after context injection. Empty for
42    /// session commands and parse errors.
43    pub argv: &'a [String],
44    /// `pane`, `suspend` or `session`.
45    pub route: &'static str,
46    /// `ok`, `error`, `cancelled`, `session`, or `signal:<NAME>` (for
47    /// example `signal:TERM`) when a signal ended the TUI.
48    pub outcome: Cow<'static, str>,
49    /// The error text, for `error`.
50    pub error: Option<String>,
51}
52
53impl Journal {
54    /// The journal in the scope state directory.
55    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    /// A journal at explicit paths.
64    pub fn at(history: PathBuf, events: PathBuf) -> Self {
65        Self {
66            history: Some(history),
67            events: Some(events),
68        }
69    }
70
71    /// Loads the history. A missing file is an empty history.
72    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    /// Saves the newest [`HISTORY_LIMIT`] entries.
87    ///
88    /// # Errors
89    ///
90    /// Returns the file system error.
91    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    /// The last `n` lines of the event log, oldest first. A missing or
105    /// unreadable file gives no lines.
106    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    /// Appends one event record.
119    ///
120    /// # Errors
121    ///
122    /// Returns the file system or serialization error.
123    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// ============================================================================
141// Unit Tests
142// ============================================================================
143
144#[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        // Users upgrading from the REPL keep their history.
172        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        // Docs and RCA instructions name these files; keep them stable.
223        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}