use serde::Serialize;
use std::borrow::Cow;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::PathBuf;
pub const HISTORY_LIMIT: usize = 1000;
const RUSTYLINE_HEADER: &str = "#V2";
#[derive(Debug, Clone, Default)]
pub struct Journal {
history: Option<PathBuf>,
events: Option<PathBuf>,
}
#[derive(Debug, Serialize)]
pub struct Event<'a> {
pub started: String,
pub duration_ms: u128,
pub input: &'a str,
pub argv: &'a [String],
pub route: &'static str,
pub outcome: Cow<'static, str>,
pub error: Option<String>,
}
impl Journal {
pub fn in_data_dir() -> Self {
let dir = scope::paths::state_dir();
Self {
history: Some(dir.join("history.txt")),
events: Some(dir.join("tui-events.jsonl")),
}
}
pub fn at(history: PathBuf, events: PathBuf) -> Self {
Self {
history: Some(history),
events: Some(events),
}
}
pub fn load_history(&self) -> Vec<String> {
let Some(path) = &self.history else {
return Vec::new();
};
fs::read_to_string(path)
.map(|text| {
text.lines()
.filter(|l| !l.is_empty() && *l != RUSTYLINE_HEADER)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub fn save_history(&self, history: &[String]) -> io::Result<()> {
let Some(path) = &self.history else {
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let start = history.len().saturating_sub(HISTORY_LIMIT);
let mut text = history[start..].join("\n");
text.push('\n');
fs::write(path, text)
}
pub fn tail(&self, n: usize) -> Vec<String> {
let Some(path) = &self.events else {
return Vec::new();
};
let text = fs::read_to_string(path).unwrap_or_default();
let lines: Vec<&str> = text.lines().collect();
lines[lines.len().saturating_sub(n)..]
.iter()
.map(|l| (*l).to_string())
.collect()
}
pub fn record(&self, event: &Event<'_>) -> io::Result<()> {
let Some(path) = &self.events else {
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(event)?;
line.push('\n');
OpenOptions::new()
.create(true)
.append(true)
.open(path)?
.write_all(line.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn journal(dir: &tempfile::TempDir) -> Journal {
Journal::at(dir.path().join("h.txt"), dir.path().join("e.jsonl"))
}
#[test]
fn test_history_round_trip_keeps_newest_entries() {
let dir = tempfile::tempdir().unwrap();
let j = journal(&dir);
let many: Vec<String> = (0..HISTORY_LIMIT + 5)
.map(|i| format!("cmd {}", i))
.collect();
j.save_history(&many).unwrap();
let loaded = j.load_history();
assert_eq!(loaded.len(), HISTORY_LIMIT);
assert_eq!(loaded.first().unwrap(), "cmd 5");
assert_eq!(
loaded.last().unwrap(),
&format!("cmd {}", HISTORY_LIMIT + 4)
);
}
#[test]
fn test_rustyline_history_carries_over() {
let dir = tempfile::tempdir().unwrap();
let j = journal(&dir);
fs::write(dir.path().join("h.txt"), "#V2\naddress 0x1\n\ntx 0x2\n").unwrap();
assert_eq!(j.load_history(), vec!["address 0x1", "tx 0x2"]);
}
#[test]
fn test_events_append_one_json_object_per_line() {
let dir = tempfile::tempdir().unwrap();
let j = journal(&dir);
let argv = vec![
"scope".to_string(),
"venues".to_string(),
"list".to_string(),
];
for outcome in ["ok", "error"] {
j.record(&Event {
started: "2026-10-10T12:00:00Z".into(),
duration_ms: 12,
input: "venues list",
argv: &argv,
route: "pane",
outcome: outcome.into(),
error: (outcome == "error").then(|| "boom".to_string()),
})
.unwrap();
}
let text = fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
let rows: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["argv"][2], "list");
assert_eq!(rows[1]["outcome"], "error");
assert_eq!(rows[1]["error"], "boom");
}
#[test]
fn test_tail_returns_the_newest_lines_in_order() {
let dir = tempfile::tempdir().unwrap();
let j = journal(&dir);
assert!(j.tail(2).is_empty());
std::fs::write(dir.path().join("e.jsonl"), "a\nb\nc\n").unwrap();
assert_eq!(j.tail(2), vec!["b", "c"]);
assert_eq!(j.tail(10), vec!["a", "b", "c"]);
}
#[test]
fn test_data_dir_journal_uses_the_documented_file_names() {
let j = Journal::in_data_dir();
if let (Some(h), Some(e)) = (&j.history, &j.events) {
assert!(h.ends_with("state/history.txt"));
assert!(e.ends_with("state/tui-events.jsonl"));
}
}
#[test]
fn test_disabled_journal_is_a_no_op() {
let j = Journal::default();
assert!(j.load_history().is_empty());
j.save_history(&["x".into()]).unwrap();
}
}