use std::fmt::Write as FmtWrite;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Result;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GateRunRecord {
pub ts: String,
pub head_sha: String,
pub gate: String,
pub threshold: f64,
pub value: f64,
pub verdict: String,
pub mode: String,
}
#[must_use]
pub fn ledger_dir(cache_root: &Path, repo_path: &Path) -> PathBuf {
crate::cache::repo_cache_dir(cache_root, repo_path)
}
#[must_use]
pub fn ledger_path(cache_root: &Path, repo_path: &Path) -> PathBuf {
ledger_dir(cache_root, repo_path).join("gate_runs.jsonl")
}
pub fn append_gate_runs(cache_root: &Path, repo_path: &Path, records: &[GateRunRecord]) {
if records.is_empty() {
return;
}
let path = ledger_path(cache_root, repo_path);
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
tracing::warn!("ledger: could not create dir {}: {e}", parent.display());
return;
}
let file = OpenOptions::new().create(true).append(true).open(&path);
let mut file = match file {
Ok(f) => f,
Err(e) => {
tracing::warn!("ledger: could not open {}: {e}", path.display());
return;
}
};
for rec in records {
match serde_json::to_string(rec) {
Ok(mut line) => {
line.push('\n');
if let Err(e) = file.write_all(line.as_bytes()) {
tracing::warn!("ledger: write failed: {e}");
}
}
Err(e) => {
tracing::warn!("ledger: serialize failed for gate {}: {e}", rec.gate);
}
}
}
}
pub fn read_gate_runs(cache_root: &Path, repo_path: &Path) -> Result<Vec<GateRunRecord>> {
let path = ledger_path(cache_root, repo_path);
if !path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(&path).map_err(|e| {
crate::CodeLoreError::Analysis(format!("open ledger {}: {e}", path.display()))
})?;
let mut records = Vec::new();
for (lineno, line) in BufReader::new(file).lines().enumerate() {
let line = match line {
Ok(l) => l,
Err(e) => {
tracing::warn!("ledger: IO error at line {lineno}: {e}");
continue;
}
};
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<GateRunRecord>(&line) {
Ok(rec) => records.push(rec),
Err(e) => {
tracing::warn!("ledger: malformed line {lineno} (skipped): {e}");
}
}
}
Ok(records)
}
#[must_use]
pub fn now_utc_ts() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let sec = secs % 60;
let min = (secs / 60) % 60;
let hour = (secs / 3600) % 24;
let days = secs / 86400; let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
}
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
let mut y = 1970u64;
loop {
let leap = is_leap(y);
let days_in_year = if leap { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
y += 1;
}
let leap = is_leap(y);
let month_days: [u64; 12] = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut mo = 0u64;
for &md in &month_days {
if days < md {
break;
}
days -= md;
mo += 1;
}
(y, mo + 1, days + 1)
}
fn is_leap(y: u64) -> bool {
(y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
}
#[must_use]
pub fn format_history(all_records: &[GateRunRecord], n: usize) -> String {
if all_records.is_empty() {
return "No gate runs recorded yet.\n".to_string();
}
let tail: Vec<&GateRunRecord> = all_records.iter().rev().take(n).collect();
let tail: Vec<&GateRunRecord> = tail.into_iter().rev().collect();
let mut out = String::new();
let mut current_sha = "";
for rec in &tail {
if rec.head_sha != current_sha {
current_sha = &rec.head_sha;
let _ = write!(
out,
"\ncommit {}\n",
&rec.head_sha[..rec.head_sha.len().min(12)]
);
out.push_str(" gate threshold value verdict\n");
out.push_str(" ─────────────────────────────────────────────────────────\n");
}
let _ = writeln!(
out,
" {gate:<26} {threshold:<11.2} {value:<10.2} {verdict}",
gate = rec.gate,
threshold = rec.threshold,
value = rec.value,
verdict = rec.verdict,
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_record(gate: &str, verdict: &str) -> GateRunRecord {
GateRunRecord {
ts: "2026-07-08T10:00:00Z".into(),
head_sha: "abc123def456".into(),
gate: gate.into(),
threshold: 60.0,
value: 54.2,
verdict: verdict.into(),
mode: "check".into(),
}
}
#[test]
fn append_and_read_round_trips() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let records = vec![
sample_record("code_health_min", "failed"),
sample_record("cognitive_max", "passed"),
];
append_gate_runs(dir.path(), &repo, &records);
let read = read_gate_runs(dir.path(), &repo).expect("read");
assert_eq!(read.len(), 2);
assert_eq!(read[0].gate, "code_health_min");
assert_eq!(read[1].verdict, "passed");
}
#[test]
fn append_writes_exactly_one_physical_line_per_record() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let records = vec![
sample_record("code_health_min", "failed"),
sample_record("cognitive_max", "passed"),
sample_record("hotspot_score_max", "degraded"),
];
append_gate_runs(dir.path(), &repo, &records);
let raw = fs::read_to_string(ledger_path(dir.path(), &repo)).expect("read raw");
assert!(raw.ends_with('\n'), "each record is newline-terminated");
let lines: Vec<&str> = raw.lines().collect();
assert_eq!(lines.len(), records.len(), "one physical line per record");
for line in lines {
serde_json::from_str::<GateRunRecord>(line).expect("each line parses standalone");
}
}
#[test]
fn missing_ledger_returns_empty() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("nonexistent_repo");
let read = read_gate_runs(dir.path(), &repo).expect("read");
assert!(read.is_empty());
}
#[test]
fn malformed_line_is_skipped() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let path = ledger_path(dir.path(), &repo);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
b"{\"ts\":\"2026-07-08T10:00:00Z\",\"head_sha\":\"abc\",\"gate\":\"g1\",\"threshold\":1.0,\"value\":0.5,\"verdict\":\"passed\",\"mode\":\"check\"}\nNOT_JSON\n{\"ts\":\"2026-07-08T10:00:00Z\",\"head_sha\":\"abc\",\"gate\":\"g2\",\"threshold\":1.0,\"value\":0.5,\"verdict\":\"failed\",\"mode\":\"check\"}\n",
)
.unwrap();
let read = read_gate_runs(dir.path(), &repo).expect("read");
assert_eq!(read.len(), 2, "corrupt line skipped, 2 valid remain");
assert_eq!(read[0].gate, "g1");
assert_eq!(read[1].gate, "g2");
}
#[test]
fn append_twice_produces_two_times_n_lines() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let records = vec![sample_record("code_health_min", "failed")];
append_gate_runs(dir.path(), &repo, &records);
append_gate_runs(dir.path(), &repo, &records);
let read = read_gate_runs(dir.path(), &repo).expect("read");
assert_eq!(read.len(), 2);
}
#[test]
fn degraded_verdict_round_trips() {
let dir = tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let records = vec![sample_record("code_health_min", "degraded")];
append_gate_runs(dir.path(), &repo, &records);
let read = read_gate_runs(dir.path(), &repo).expect("read");
assert_eq!(read[0].verdict, "degraded");
}
#[test]
fn format_history_groups_by_sha() {
let records = vec![
GateRunRecord {
ts: "2026-07-08T10:00:00Z".into(),
head_sha: "sha1abc".into(),
gate: "code_health_min".into(),
threshold: 60.0,
value: 54.2,
verdict: "failed".into(),
mode: "check".into(),
},
GateRunRecord {
ts: "2026-07-08T11:00:00Z".into(),
head_sha: "sha2def".into(),
gate: "cognitive_max".into(),
threshold: 30.0,
value: 25.0,
verdict: "passed".into(),
mode: "check".into(),
},
];
let out = format_history(&records, 20);
assert!(out.contains("sha1abc"), "first sha present");
assert!(out.contains("sha2def"), "second sha present");
assert!(out.contains("code_health_min"));
assert!(out.contains("failed"));
}
#[test]
fn now_utc_ts_format() {
let ts = now_utc_ts();
assert_eq!(ts.len(), 20, "format: YYYY-MM-DDTHH:MM:SSZ");
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
}
}