1use 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
24pub const GATE_LOG_SCHEMA_VERSION: u32 = 1;
26
27pub const DEFAULT_GATE_LOG_PATH: &str = ".ctx/gate-log.jsonl";
29
30#[derive(Debug, Serialize)]
32pub struct GateRecord {
33 pub schema_version: u32,
35 pub ts: String,
38 pub ctx_version: String,
40 pub source: String,
42 pub against: String,
44 pub fail_on: Option<String>,
46 pub metrics: serde_json::Value,
49 pub failed_conditions: Vec<String>,
51 pub outcome: String,
53 pub blocking: bool,
55 pub session_id: Option<String>,
57}
58
59pub fn gate_log_target(root: &Path) -> Option<PathBuf> {
62 resolve(std::env::var("CTX_GATE_LOG").ok().as_deref(), root)
63}
64
65fn 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
82pub fn blocking_enabled() -> bool {
84 std::env::var("CTX_GATE_BLOCKING").as_deref() == Ok("1")
85}
86
87pub fn session_id() -> Option<String> {
89 std::env::var("CLAUDE_SESSION_ID").ok()
90}
91
92pub fn now_rfc3339() -> String {
94 OffsetDateTime::now_utc()
95 .format(&Rfc3339)
96 .unwrap_or_default()
97}
98
99pub 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#[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 assert_eq!(
164 resolve(Some("logs/gates.jsonl"), root),
165 Some(root.join("logs/gates.jsonl"))
166 );
167 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 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 let ts = doc["ts"].as_str().unwrap();
195 assert!(OffsetDateTime::parse(ts, &Rfc3339).is_ok(), "ts: {ts}");
196 }
197 }
198}