openlatch-client 0.6.0

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The rail's transcript: every stage, note, detail and error, written to
//! `<openlatch dir>/logs/<command>-YYYY-MM-DD.log` whatever the terminal shows.
//!
//! The screen shows the customer six lines and a card; the log keeps what
//! `--verbose` would have shown, so a failed install can be diagnosed from the
//! file the card points at without a re-run. Runs on the same day append to
//! one file, each opening with a `begin` line. Colour codes are stripped.
//!
//! Best effort by design: a directory that cannot be created or a file that
//! cannot be written costs the log, never the command.

use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

/// An open transcript file.
#[derive(Debug)]
pub struct Transcript {
    file: File,
    path: PathBuf,
    /// What opening the transcript brought into existence — directories, then the
    /// file if it was new — so [`Transcript::take_back`] can leave the host as found.
    created: Vec<PathBuf>,
}

impl Transcript {
    /// Open today's transcript for `command` under the OpenLatch state
    /// directory (`OPENLATCH_DIR` respected, via [`crate::core::config::openlatch_dir`]).
    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)
    }

    /// Open (append) a transcript at an explicit path.
    pub fn open_at(path: &Path, command: &str) -> Option<Self> {
        let mut created = Vec::new();
        if let Some(dir) = path.parent() {
            // Outermost first, recorded only when this call is what made it.
            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)
    }

    /// Where the transcript is written.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Take back what opening the transcript created under the state directory,
    /// keeping the transcript itself: a file this run created moves to `dest_dir`
    /// and is written there from now on, and the directories it created are
    /// removed (non-recursively, deepest first — one that gained other content
    /// keeps it).
    ///
    /// For a command that must leave the host as it found it on failure — `init`'s
    /// fresh egress gate — while still handing support the log of what happened.
    /// A transcript appended to an existing file stays where it is. Best effort.
    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();
        // Copy then delete, not rename: the two may sit on different volumes.
        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;
    }

    /// Append one entry per line of `text`, tagged with `kind`.
    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() {
            // A failed write is dropped on purpose: see the module doc.
            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");
        }
        // A second run on the same day appends.
        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"));

        // An existing transcript is appended to, and stays where it is.
        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");
        // Serialised by OPENLATCH_DIR_ENV_LOCK, restored below.
        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}"
        );
    }
}