pitboard-core 0.14.0

The engine behind Pitboard: switches Claude Code, Codex and Claude Desktop between accounts by parking and restoring their logins. Its API serves Pitboard's own front ends.
Documentation
//! One line per change Pitboard makes: when, which front end asked, what was asked, of
//! what, and how it ended. Labels, codes and times only, no email addresses or account
//! identifiers, so it is safe to paste into a bug report.

use crate::context::Context;
use crate::service::Permit;
use crate::{home, time};
use std::io::Write;
use std::path::PathBuf;

/// Rotated once past this size, keeping one previous file.
const LIMIT_BYTES: u64 = 256 * 1024;

fn path(ctx: &Context) -> PathBuf {
    home::dir(ctx).join("audit.log")
}

/// A failure to audit never fails the operation it describes. A change refused at the gate
/// every change passes is not recorded: recording it would be a change.
pub(crate) fn record(ctx: &Context, permit: Permit, verb: &str, subject: &str, outcome: &str) {
    let _ = append(
        ctx,
        permit,
        &line(ctx.now(), &ctx.caller, verb, subject, outcome),
    );
}

fn line(at: i64, caller: &str, verb: &str, subject: &str, outcome: &str) -> String {
    let clean = |s: &str| s.replace(['\n', '\r', '\t'], " ");
    format!(
        "{}\t{}\t{}\t{}\t{}\n",
        time::local(at, "%Y-%m-%dT%H:%M:%S%:z"),
        clean(caller),
        clean(verb),
        clean(subject),
        clean(outcome)
    )
}

fn append(ctx: &Context, permit: Permit, line: &str) -> std::io::Result<()> {
    // Never makes the home itself. Every change settles first, which makes it; and after an
    // uninstall there is no home to write into and nothing left to describe.
    if !std::fs::metadata(home::dir(ctx)).is_ok_and(|m| m.is_dir()) {
        return Ok(());
    }
    let path = path(ctx);
    if std::fs::metadata(&path).is_ok_and(|m| m.len() > LIMIT_BYTES) {
        crate::host::fs::rename(permit, &path, &path.with_extension("log.1"))?;
    }
    crate::host::fs::open_private_append(permit, &path)?.write_all(line.as_bytes())
}

/// One recorded change, as `read` hands it back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    /// Local time, as it was written.
    pub at: String,
    /// Which front end asked. Lines written before this was recorded say `unknown`.
    pub caller: String,
    pub verb: String,
    pub subject: String,
    /// `ok`, or the stable code of whatever stopped it.
    pub outcome: String,
}

/// The newest `limit` changes, oldest first. The rotated file is read too, so asking for
/// more than the current file holds still answers.
pub fn read(ctx: &Context, limit: usize) -> Vec<Entry> {
    let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
    let mut text = read(path(ctx).with_extension("log.1"));
    text.push_str(&read(path(ctx)));
    let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
    lines[lines.len().saturating_sub(limit)..]
        .iter()
        .map(|line| {
            let mut fields = line.split('\t');
            let mut next = || fields.next().unwrap_or_default().to_string();
            let (at, second, third, fourth) = (next(), next(), next(), next());
            match fields.next() {
                // Written before the caller had a column of its own.
                None => Entry {
                    at,
                    caller: "unknown".into(),
                    verb: second,
                    subject: third,
                    outcome: fourth,
                },
                Some(outcome) => Entry {
                    at,
                    caller: second,
                    verb: third,
                    subject: fourth,
                    outcome: outcome.to_string(),
                },
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Lines written before the caller had a column of its own still read, because a log is
    /// only useful if the version that wrote it does not matter.
    #[test]
    fn a_line_from_before_the_caller_column_still_reads() {
        let four = "2026-09-22T01:00:00+07:00\tuse\twork\tok";
        let five = "2026-09-22T01:01:00+07:00\tapp\tuse\tpersonal\tok";
        let dir = tempfile::tempdir().unwrap();
        let home = dir.path().to_path_buf();
        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
        std::fs::write(home.join("audit.log"), format!("{four}\n{five}\n")).unwrap();

        let entries = read(&ctx, 10);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].caller, "unknown");
        assert_eq!(entries[0].verb, "use");
        assert_eq!(entries[0].subject, "work");
        assert_eq!(entries[0].outcome, "ok");
        assert_eq!(entries[1].caller, "app");
        assert_eq!(entries[1].verb, "use");
        assert_eq!(entries[1].outcome, "ok");
    }

    /// The seam the rest of this crate's time judgements hang on: what gets written is the
    /// context's idea of now, not the machine's.
    #[test]
    #[cfg_attr(windows, ignore = "W15: files made private to the person on Windows")]
    fn a_change_is_stamped_with_the_contexts_clock() {
        use crate::time::FixedClock;
        use std::sync::Arc;

        let dir = tempfile::tempdir().unwrap();
        let home = dir.path().to_path_buf();
        let clock = Arc::new(FixedClock::at(1_760_000_000));
        let ctx = Context::new(home.clone())
            .with_pitboard_home(home)
            .with_caller("cli".into())
            .with_clock(clock.clone());

        record(&ctx, Permit::for_a_test(), "use", "work", "ok");
        clock.advance(3600);
        record(&ctx, Permit::for_a_test(), "use", "personal", "ok");

        let entries = read(&ctx, 10);
        assert_eq!(entries.len(), 2);
        let stamp = |epoch| time::local(epoch, "%Y-%m-%dT%H:%M:%S%:z");
        assert_eq!(entries[0].at, stamp(1_760_000_000));
        assert_eq!(
            entries[1].at,
            stamp(1_760_003_600),
            "an hour later, because we said so"
        );
    }

    #[test]
    fn a_line_is_one_line_whatever_the_input() {
        let l = line(1_789_935_600, "cli", "use", "work\ninjected", "ok");
        assert_eq!(
            l.matches('\n').count(),
            1,
            "a label must not be able to forge entries"
        );
        assert_eq!(l.split('\t').count(), 5);
    }
}