use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct Transcript {
file: File,
path: PathBuf,
created: Vec<PathBuf>,
}
impl Transcript {
pub fn open(command: &str) -> Option<Self> {
let date = chrono::Utc::now().format("%Y-%m-%d");
let path = crate::core::config::openlatch_dir()
.join("logs")
.join(format!("{command}-{date}.log"));
Self::open_at(&path, command)
}
pub fn open_at(path: &Path, command: &str) -> Option<Self> {
let mut created = Vec::new();
if let Some(dir) = path.parent() {
let missing: Vec<&Path> = dir.ancestors().take_while(|d| !d.exists()).collect();
std::fs::create_dir_all(dir).ok()?;
created.extend(missing.into_iter().rev().map(Path::to_path_buf));
}
let existed = path.exists();
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()?;
if !existed {
created.push(path.to_path_buf());
}
let mut transcript = Self {
file,
path: path.to_path_buf(),
created,
};
transcript.write(
"begin",
&format!("openlatch {command} v{}", env!("OPENLATCH_VERSION")),
);
Some(transcript)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn take_back(&mut self, dest_dir: &Path) {
if !self.created.contains(&self.path) {
return;
}
let Some(name) = self.path.file_name() else {
return;
};
let dest = dest_dir.join(format!("openlatch-{}", name.to_string_lossy()));
let _ = self.file.flush();
let moved = std::fs::copy(&self.path, &dest).is_ok()
&& OpenOptions::new().append(true).open(&dest).is_ok_and(|f| {
self.file = f;
true
});
if !moved {
return;
}
let _ = std::fs::remove_file(&self.path);
for dir in self.created.iter().rev().filter(|p| **p != self.path) {
let _ = std::fs::remove_dir(dir);
}
self.created.clear();
self.path = dest;
}
pub fn write(&mut self, kind: &str, text: &str) {
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
let plain = console::strip_ansi_codes(text);
for line in plain.lines() {
let _ = writeln!(self.file, "{ts} {kind:<7}{line}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transcript_appends_tagged_uncoloured_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("logs").join("init-2026-09-22.log");
{
let mut t = Transcript::open_at(&path, "init").expect("opens");
t.write("stage", "\x1b[32m◇\x1b[0m Checked this machine");
t.write("detail", "one\ntwo");
}
Transcript::open_at(&path, "init").expect("reopens");
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 5, "{body}");
assert!(lines[0].contains("begin openlatch init v"));
assert!(lines[1].ends_with("stage ◇ Checked this machine"));
assert!(!body.contains('\x1b'), "colour codes must be stripped");
assert!(lines[2].ends_with("detail one"));
assert!(lines[3].ends_with("detail two"));
assert!(lines[4].contains("begin"));
}
#[test]
fn test_take_back_moves_a_new_transcript_and_removes_what_it_created() {
let home = tempfile::tempdir().unwrap();
let away = tempfile::tempdir().unwrap();
let state = home.path().join("ol");
let path = state.join("logs").join("init-2026-09-22.log");
let mut t = Transcript::open_at(&path, "init").expect("opens");
t.write("stage", "Checking this machine");
t.take_back(away.path());
t.write("error", "after the move");
assert!(!state.exists(), "the state directory it created is gone");
assert!(t.path().starts_with(away.path()));
let body = std::fs::read_to_string(t.path()).unwrap();
assert!(body.contains("Checking this machine") && body.contains("after the move"));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "earlier run\n").unwrap();
let mut t = Transcript::open_at(&path, "init").expect("reopens");
t.take_back(away.path());
assert_eq!(t.path(), path);
assert!(path.exists());
}
#[test]
fn test_open_resolves_under_openlatch_dir() {
let _guard = crate::core::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
let prev = std::env::var_os("OPENLATCH_DIR");
std::env::set_var("OPENLATCH_DIR", dir.path());
let t = Transcript::open("init");
match prev {
Some(v) => std::env::set_var("OPENLATCH_DIR", v),
None => std::env::remove_var("OPENLATCH_DIR"),
}
let t = t.expect("opens");
assert!(t.path().starts_with(dir.path().join("logs")));
let name = t.path().file_name().unwrap().to_string_lossy().into_owned();
assert!(
name.starts_with("init-") && name.ends_with(".log"),
"{name}"
);
}
}