Skip to main content

ctx/
gatelog.rs

1//! Gate-evaluation logging.
2//!
3//! When the `CTX_GATE_LOG` environment variable is set, `ctx score` appends
4//! one JSON line per gate evaluation to a local log file (default
5//! `.ctx/gate-log.jsonl`). Opt-in, local-only; ctx ships no telemetry.
6//!
7//! `CTX_GATE_LOG` values:
8//!
9//! - unset, empty, or `0` -- logging disabled
10//! - `1` or `true` -- log to [`DEFAULT_GATE_LOG_PATH`] under the repo root
11//! - anything else -- treated as the log path (joined to the repo root when
12//!   relative)
13
14use std::fs;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use serde::Serialize;
19use time::format_description::well_known::Rfc3339;
20use time::OffsetDateTime;
21
22use crate::error::Result;
23
24/// Version of the [`GateRecord`] line format.
25pub const GATE_LOG_SCHEMA_VERSION: u32 = 1;
26
27/// Default log location (relative to the repo root) for `CTX_GATE_LOG=1`.
28pub const DEFAULT_GATE_LOG_PATH: &str = ".ctx/gate-log.jsonl";
29
30/// One gate evaluation, serialized as a single JSONL line.
31#[derive(Debug, Serialize)]
32pub struct GateRecord {
33    /// Line format version ([`GATE_LOG_SCHEMA_VERSION`]).
34    pub schema_version: u32,
35    /// Evaluation time, RFC3339 UTC (same shape as the JSON envelope's
36    /// `generated_at`; see [`crate::json`]).
37    pub ts: String,
38    /// The ctx version that evaluated the gate.
39    pub ctx_version: String,
40    /// The command that evaluated the gate (`"score"`).
41    pub source: String,
42    /// The git reference the score was computed against.
43    pub against: String,
44    /// The raw `--fail-on` expression, if any.
45    pub fail_on: Option<String>,
46    /// The scorecard metrics: the same seven-key object the `--json`
47    /// payload emits under `metrics`.
48    pub metrics: serde_json::Value,
49    /// Rendered `--fail-on` conditions that fired (empty on pass).
50    pub failed_conditions: Vec<String>,
51    /// `"pass"` or `"fail"`.
52    pub outcome: String,
53    /// Whether blocking mode was requested (`CTX_GATE_BLOCKING=1`).
54    pub blocking: bool,
55    /// Claude Code session id (`CLAUDE_SESSION_ID`), if present.
56    pub session_id: Option<String>,
57}
58
59/// The gate log path selected by `CTX_GATE_LOG`, or `None` when logging is
60/// disabled.
61pub fn gate_log_target(root: &Path) -> Option<PathBuf> {
62    resolve(std::env::var("CTX_GATE_LOG").ok().as_deref(), root)
63}
64
65/// Core `CTX_GATE_LOG` value resolution (env-free, so tests can drive it
66/// without mutating process env).
67fn resolve(value: Option<&str>, root: &Path) -> Option<PathBuf> {
68    match value? {
69        "" | "0" => None,
70        "1" | "true" => Some(root.join(DEFAULT_GATE_LOG_PATH)),
71        path => {
72            let path = Path::new(path);
73            if path.is_absolute() {
74                Some(path.to_path_buf())
75            } else {
76                Some(root.join(path))
77            }
78        }
79    }
80}
81
82/// Whether blocking mode is requested (`CTX_GATE_BLOCKING=1`, exactly).
83pub fn blocking_enabled() -> bool {
84    std::env::var("CTX_GATE_BLOCKING").as_deref() == Ok("1")
85}
86
87/// The Claude Code session id (`CLAUDE_SESSION_ID`), if present.
88pub fn session_id() -> Option<String> {
89    std::env::var("CLAUDE_SESSION_ID").ok()
90}
91
92/// Current UTC time as an RFC3339 string.
93pub fn now_rfc3339() -> String {
94    OffsetDateTime::now_utc()
95        .format(&Rfc3339)
96        .unwrap_or_default()
97}
98
99/// Append one record to the log at `path` as a single JSONL line.
100///
101/// Creates parent directories as needed. The record plus trailing newline
102/// go out in one write call, so concurrent appenders on the same local log
103/// do not interleave within a line.
104pub fn append(path: &Path, record: &GateRecord) -> Result<()> {
105    if let Some(parent) = path.parent() {
106        fs::create_dir_all(parent)?;
107    }
108    let mut line = serde_json::to_string(record)?;
109    line.push('\n');
110    let mut file = fs::OpenOptions::new()
111        .create(true)
112        .append(true)
113        .open(path)?;
114    file.write_all(line.as_bytes())?;
115    Ok(())
116}
117
118// ============================================================================
119// Tests
120// ============================================================================
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use tempfile::TempDir;
126
127    fn sample_record(outcome: &str) -> GateRecord {
128        GateRecord {
129            schema_version: GATE_LOG_SCHEMA_VERSION,
130            ts: now_rfc3339(),
131            ctx_version: env!("CARGO_PKG_VERSION").to_string(),
132            source: "score".to_string(),
133            against: "main".to_string(),
134            fail_on: Some("new_duplication>0".to_string()),
135            metrics: serde_json::json!({"new_duplication": 1}),
136            failed_conditions: vec!["new_duplication > 0".to_string()],
137            outcome: outcome.to_string(),
138            blocking: false,
139            session_id: None,
140        }
141    }
142
143    #[test]
144    fn test_resolve_disabled_values() {
145        let root = Path::new("/repo");
146        assert_eq!(resolve(None, root), None);
147        assert_eq!(resolve(Some(""), root), None);
148        assert_eq!(resolve(Some("0"), root), None);
149    }
150
151    #[test]
152    fn test_resolve_default_path_values() {
153        let root = Path::new("/repo");
154        let default = root.join(DEFAULT_GATE_LOG_PATH);
155        assert_eq!(resolve(Some("1"), root), Some(default.clone()));
156        assert_eq!(resolve(Some("true"), root), Some(default));
157    }
158
159    #[test]
160    fn test_resolve_custom_paths() {
161        let root = Path::new("/repo");
162        // Relative values are joined to the root.
163        assert_eq!(
164            resolve(Some("logs/gates.jsonl"), root),
165            Some(root.join("logs/gates.jsonl"))
166        );
167        // Absolute values are used as-is.
168        assert_eq!(
169            resolve(Some("/var/log/gates.jsonl"), root),
170            Some(PathBuf::from("/var/log/gates.jsonl"))
171        );
172    }
173
174    #[test]
175    fn test_append_creates_dirs_and_accumulates_lines() {
176        let temp = TempDir::new().unwrap();
177        let path = temp.path().join("nested/dir/gate-log.jsonl");
178
179        append(&path, &sample_record("fail")).unwrap();
180        append(&path, &sample_record("pass")).unwrap();
181
182        let content = fs::read_to_string(&path).unwrap();
183        assert!(content.ends_with('\n'), "content: {content:?}");
184        let lines: Vec<&str> = content.lines().collect();
185        assert_eq!(lines.len(), 2, "content: {content:?}");
186
187        // Every line is one valid JSON object with the expected shape.
188        for (line, outcome) in lines.iter().zip(["fail", "pass"]) {
189            let doc: serde_json::Value = serde_json::from_str(line).unwrap();
190            assert_eq!(doc["schema_version"], GATE_LOG_SCHEMA_VERSION);
191            assert_eq!(doc["source"], "score");
192            assert_eq!(doc["outcome"], outcome);
193            // ts round-trips as RFC3339.
194            let ts = doc["ts"].as_str().unwrap();
195            assert!(OffsetDateTime::parse(ts, &Rfc3339).is_ok(), "ts: {ts}");
196        }
197    }
198}