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
9pub 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
19pub 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}