use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::error::Result;
pub const GATE_LOG_SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_GATE_LOG_PATH: &str = ".ctx/gate-log.jsonl";
#[derive(Debug, Serialize)]
pub struct GateRecord {
pub schema_version: u32,
pub ts: String,
pub ctx_version: String,
pub source: String,
pub against: String,
pub fail_on: Option<String>,
pub metrics: serde_json::Value,
pub failed_conditions: Vec<String>,
pub outcome: String,
pub blocking: bool,
pub session_id: Option<String>,
}
pub fn gate_log_target(root: &Path) -> Option<PathBuf> {
resolve(std::env::var("CTX_GATE_LOG").ok().as_deref(), root)
}
fn resolve(value: Option<&str>, root: &Path) -> Option<PathBuf> {
match value? {
"" | "0" => None,
"1" | "true" => Some(root.join(DEFAULT_GATE_LOG_PATH)),
path => {
let path = Path::new(path);
if path.is_absolute() {
Some(path.to_path_buf())
} else {
Some(root.join(path))
}
}
}
}
pub fn blocking_enabled() -> bool {
std::env::var("CTX_GATE_BLOCKING").as_deref() == Ok("1")
}
pub fn session_id() -> Option<String> {
std::env::var("CLAUDE_SESSION_ID").ok()
}
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_default()
}
pub fn append(path: &Path, record: &GateRecord) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(record)?;
line.push('\n');
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_record(outcome: &str) -> GateRecord {
GateRecord {
schema_version: GATE_LOG_SCHEMA_VERSION,
ts: now_rfc3339(),
ctx_version: env!("CARGO_PKG_VERSION").to_string(),
source: "score".to_string(),
against: "main".to_string(),
fail_on: Some("new_duplication>0".to_string()),
metrics: serde_json::json!({"new_duplication": 1}),
failed_conditions: vec!["new_duplication > 0".to_string()],
outcome: outcome.to_string(),
blocking: false,
session_id: None,
}
}
#[test]
fn test_resolve_disabled_values() {
let root = Path::new("/repo");
assert_eq!(resolve(None, root), None);
assert_eq!(resolve(Some(""), root), None);
assert_eq!(resolve(Some("0"), root), None);
}
#[test]
fn test_resolve_default_path_values() {
let root = Path::new("/repo");
let default = root.join(DEFAULT_GATE_LOG_PATH);
assert_eq!(resolve(Some("1"), root), Some(default.clone()));
assert_eq!(resolve(Some("true"), root), Some(default));
}
#[test]
fn test_resolve_custom_paths() {
let root = Path::new("/repo");
assert_eq!(
resolve(Some("logs/gates.jsonl"), root),
Some(root.join("logs/gates.jsonl"))
);
assert_eq!(
resolve(Some("/var/log/gates.jsonl"), root),
Some(PathBuf::from("/var/log/gates.jsonl"))
);
}
#[test]
fn test_append_creates_dirs_and_accumulates_lines() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("nested/dir/gate-log.jsonl");
append(&path, &sample_record("fail")).unwrap();
append(&path, &sample_record("pass")).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.ends_with('\n'), "content: {content:?}");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "content: {content:?}");
for (line, outcome) in lines.iter().zip(["fail", "pass"]) {
let doc: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(doc["schema_version"], GATE_LOG_SCHEMA_VERSION);
assert_eq!(doc["source"], "score");
assert_eq!(doc["outcome"], outcome);
let ts = doc["ts"].as_str().unwrap();
assert!(OffsetDateTime::parse(ts, &Rfc3339).is_ok(), "ts: {ts}");
}
}
}