openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `events tail` — stream the runtime audit log (JSONL) to the terminal.
//!
//! The audit log writer in `runtime/audit_log.rs` records one line per
//! HMAC-verified event under `~/.openlatch/provider/logs/runtime-YYYY-MM-DD.jsonl`.
//! `events tail` reads that file directly — no daemon RPC needed.
//!
//! `--follow` polls the file every 250 ms for new lines and transparently
//! reopens the next file when the UTC date changes, so long-running follows
//! don't strand on yesterday's log.

use std::path::Path;
use std::time::Duration;

use chrono::{NaiveDate, Utc};

use crate::cli::commands::shared;
use crate::cli::{EventsAction, GlobalArgs};
use crate::config;
use crate::error::OlError;
use crate::observability::verdict_display_str;
use crate::runtime::audit_log::{log_path_for, Record};
use crate::ui::output::OutputConfig;

pub async fn run(g: &GlobalArgs, action: EventsAction) -> Result<(), OlError> {
    match action {
        EventsAction::Tail { follow, tail } => tail_cmd(g, follow, tail).await,
    }
}

pub async fn tail_cmd(g: &GlobalArgs, follow: bool, tail_n: usize) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let log_dir = config::provider_dir().join("logs");
    let today = Utc::now().date_naive();
    let today_path = log_path_for(&log_dir, today);

    if !today_path.exists() && !follow {
        out.print_info(&format!(
            "No audit log at {} — has the daemon run today?",
            today_path.display()
        ));
        return Ok(());
    }

    if today_path.exists() {
        if out.is_machine() {
            shared::print_tail(&today_path, tail_n).await?;
        } else {
            for raw in shared::read_last_lines(&today_path, tail_n).await? {
                println!("{}", render_line(&raw, out.color));
            }
        }
    }

    if !follow {
        return Ok(());
    }

    follow_with_rotation(&log_dir, today, &out).await
}

/// Follow the daily audit log, reopening across UTC midnight crossings. Runs
/// forever — the caller relies on Ctrl-C to terminate.
async fn follow_with_rotation(
    log_dir: &Path,
    starting_date: NaiveDate,
    out: &OutputConfig,
) -> Result<(), OlError> {
    use tokio::io::AsyncBufReadExt;

    let mut current_date = starting_date;
    let mut current_path = log_path_for(log_dir, current_date);
    let mut reader = open_at_end_or_wait(&current_path).await;
    let mut line = String::new();

    loop {
        let now_date = Utc::now().date_naive();
        if now_date != current_date {
            current_date = now_date;
            current_path = log_path_for(log_dir, current_date);
            reader = open_at_end_or_wait(&current_path).await;
        }

        line.clear();
        match reader.read_line(&mut line).await {
            Ok(0) => {
                tokio::time::sleep(Duration::from_millis(250)).await;
            }
            Ok(_) => {
                if line.ends_with('\n') {
                    line.pop();
                    if line.ends_with('\r') {
                        line.pop();
                    }
                }
                emit_followed_line(&line, out);
            }
            Err(_) => {
                tokio::time::sleep(Duration::from_millis(250)).await;
            }
        }
    }
}

async fn open_at_end_or_wait(path: &Path) -> tokio::io::BufReader<tokio::fs::File> {
    use tokio::io::AsyncSeekExt;
    loop {
        if let Ok(mut f) = tokio::fs::File::open(path).await {
            let _ = f.seek(std::io::SeekFrom::End(0)).await;
            return tokio::io::BufReader::new(f);
        }
        tokio::time::sleep(Duration::from_millis(250)).await;
    }
}

fn emit_followed_line(raw: &str, out: &OutputConfig) {
    if out.is_machine() {
        println!("{raw}");
    } else {
        println!("{}", render_line(raw, out.color));
    }
}

fn render_line(raw: &str, ansi: bool) -> String {
    match serde_json::from_str::<Record>(raw) {
        Ok(rec) => render_record(&rec, ansi),
        // Malformed line — show raw so the user still sees it. Won't happen
        // for lines the daemon wrote; only matters for hand-edited files.
        Err(_) => raw.to_string(),
    }
}

fn render_record(rec: &Record, ansi: bool) -> String {
    let verdict = verdict_display_str(rec.verdict_hint.as_deref(), ansi);
    format!(
        "{ts}  {binding}  {event}  outcome={outcome}  verdict={verdict}  processing_ms={pms} tool_ms={tms}",
        ts = rec.timestamp.to_rfc3339(),
        binding = rec.binding_id,
        event = rec.event_id,
        outcome = rec.outcome,
        verdict = verdict,
        pms = rec.processing_ms,
        tms = rec.tool_ms,
    )
}

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

    #[test]
    fn rotation_picks_tomorrow_file_after_midnight() {
        let dir = std::env::temp_dir().join("openlatch-test-logs");
        let today = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
        let tomorrow = today + Duration::days(1);

        let today_path = log_path_for(&dir, today);
        let tomorrow_path = log_path_for(&dir, tomorrow);

        assert!(today_path
            .to_string_lossy()
            .ends_with("runtime-2026-05-12.jsonl"));
        assert!(tomorrow_path
            .to_string_lossy()
            .ends_with("runtime-2026-05-13.jsonl"));
        assert_ne!(today_path, tomorrow_path);
    }

    #[test]
    fn render_record_includes_verdict_token() {
        let rec = Record::now("evt_abc", "bnd_xyz", "delivered")
            .with_verdict(Some("allow".into()), Some(10))
            .with_timings(8, 2);
        let line = render_record(&rec, false);
        assert!(line.contains("evt_abc"));
        assert!(line.contains("bnd_xyz"));
        assert!(line.contains("outcome=delivered"));
        assert!(line.contains("verdict=allow"));
        assert!(line.contains("processing_ms=8"));
        assert!(line.contains("tool_ms=2"));
    }

    #[test]
    fn render_record_deny_is_red_when_ansi() {
        let rec = Record::now("evt_abc", "bnd_xyz", "delivered")
            .with_verdict(Some("deny".into()), Some(95));
        let line = render_record(&rec, true);
        assert!(line.contains("\x1b[31m"));
    }
}