Skip to main content

rippy_cli/
logging.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4use std::time::SystemTime;
5
6use crate::mode::Mode;
7use crate::verdict::Verdict;
8
9/// Parameters for a log entry.
10pub struct LogEntry<'a> {
11    pub log_file: &'a Path,
12    pub log_full: bool,
13    pub command: Option<&'a str>,
14    pub verdict: &'a Verdict,
15    pub mode: Mode,
16    pub raw_payload: Option<&'a serde_json::Value>,
17}
18
19/// Write a JSON log line to the configured log file.
20/// Errors are printed to stderr but never block the hook.
21pub fn write_log_entry(entry: &LogEntry<'_>) {
22    let timestamp = SystemTime::now()
23        .duration_since(SystemTime::UNIX_EPOCH)
24        .map(|d| d.as_secs())
25        .unwrap_or(0);
26
27    let mut json = serde_json::json!({
28        "timestamp": timestamp,
29        "decision": entry.verdict.decision.as_str(),
30        "reason": entry.verdict.reason,
31    });
32
33    if let Some(cmd) = entry.command {
34        json["command"] = serde_json::Value::String(cmd.to_owned());
35    }
36
37    if entry.log_full {
38        json["mode"] = serde_json::Value::String(format!("{:?}", entry.mode));
39        if let Some(payload) = entry.raw_payload {
40            json["payload"] = payload.clone();
41        }
42    }
43
44    let Ok(mut file) = OpenOptions::new()
45        .create(true)
46        .append(true)
47        .open(entry.log_file)
48    else {
49        eprintln!(
50            "[rippy] warning: could not open log file: {}",
51            entry.log_file.display()
52        );
53        return;
54    };
55
56    if let Ok(line) = serde_json::to_string(&json)
57        && writeln!(file, "{line}").is_err()
58    {
59        eprintln!(
60            "[rippy] warning: could not write to log: {}",
61            entry.log_file.display()
62        );
63    }
64}
65
66#[cfg(test)]
67#[allow(clippy::unwrap_used)]
68mod tests {
69    use super::*;
70    use crate::verdict::Decision;
71
72    fn entry_to<'a>(
73        log_path: &'a Path,
74        log_full: bool,
75        command: Option<&'a str>,
76        verdict: &'a Verdict,
77        raw_payload: Option<&'a serde_json::Value>,
78    ) -> LogEntry<'a> {
79        LogEntry {
80            log_file: log_path,
81            log_full,
82            command,
83            verdict,
84            mode: Mode::Claude,
85            raw_payload,
86        }
87    }
88
89    #[test]
90    fn writes_json_line() {
91        let dir = tempfile::tempdir().unwrap();
92        let log_path = dir.path().join("test.log");
93        let verdict = Verdict::allow("test reason");
94
95        write_log_entry(&entry_to(&log_path, false, Some("ls"), &verdict, None));
96
97        let content = std::fs::read_to_string(&log_path).unwrap();
98        let e: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
99        assert_eq!(e["decision"], "allow");
100        assert_eq!(e["reason"], "test reason");
101        assert_eq!(e["command"], "ls");
102        assert!(e["timestamp"].is_u64());
103    }
104
105    #[test]
106    fn log_full_includes_payload() {
107        let dir = tempfile::tempdir().unwrap();
108        let log_path = dir.path().join("test.log");
109        let verdict = Verdict::ask("needs review");
110        let payload = serde_json::json!({"tool_name": "Bash"});
111
112        write_log_entry(&entry_to(
113            &log_path,
114            true,
115            Some("rm -rf /"),
116            &verdict,
117            Some(&payload),
118        ));
119
120        let content = std::fs::read_to_string(&log_path).unwrap();
121        let e: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
122        assert_eq!(e["decision"], "ask");
123        assert_eq!(e["mode"], "Claude");
124        assert_eq!(e["payload"]["tool_name"], "Bash");
125    }
126
127    #[test]
128    fn bad_path_does_not_panic() {
129        let verdict = Verdict::allow("ok");
130        write_log_entry(&entry_to(
131            Path::new("/nonexistent/dir/file.log"),
132            false,
133            Some("ls"),
134            &verdict,
135            None,
136        ));
137    }
138
139    #[test]
140    fn no_command_field_when_none() {
141        let dir = tempfile::tempdir().unwrap();
142        let log_path = dir.path().join("test.log");
143        let verdict = Verdict::allow("no command");
144
145        write_log_entry(&entry_to(&log_path, false, None, &verdict, None));
146
147        let content = std::fs::read_to_string(&log_path).unwrap();
148        let e: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
149        assert!(e.get("command").is_none());
150        assert_eq!(e["decision"], "allow");
151    }
152
153    #[test]
154    fn appends_multiple_entries() {
155        let dir = tempfile::tempdir().unwrap();
156        let log_path = dir.path().join("test.log");
157        let v1 = Verdict::allow("safe");
158        let v2 = Verdict {
159            decision: Decision::Ask,
160            reason: "dangerous".into(),
161            resolved_command: None,
162        };
163
164        write_log_entry(&entry_to(&log_path, false, Some("ls"), &v1, None));
165        write_log_entry(&entry_to(&log_path, false, Some("rm"), &v2, None));
166
167        let content = std::fs::read_to_string(&log_path).unwrap();
168        let line_count = content.trim().lines().count();
169        assert_eq!(line_count, 2);
170    }
171}