1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashSet;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ToolSideEffect {
20 ReadOnly,
24 Mutating,
27 External,
32}
33
34pub const AUDIT_ERR_MAX_BYTES: usize = 512;
37
38#[inline]
39fn is_false(b: &bool) -> bool {
40 !b
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum ExitStatus {
47 Ok,
48 Err {
49 message: String,
51 #[serde(default, skip_serializing_if = "is_false")]
53 truncated: bool,
54 },
55}
56
57pub type AuditKey = (u32 , String );
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct AuditMeta {
67 pub step_id: String,
69 pub started_at: i64,
71 pub finished_at: i64,
73 pub args_hash: String,
76 pub side_effect: ToolSideEffect,
78 pub exit_status: ExitStatus,
80}
81
82impl AuditMeta {
83 pub fn synthetic_unknown_tool(name: &str) -> Self {
86 let now = unix_millis();
87 Self {
88 step_id: uuid::Uuid::now_v7().hyphenated().to_string(),
89 started_at: now,
90 finished_at: now,
91 args_hash: String::new(),
92 side_effect: ToolSideEffect::External,
93 exit_status: ExitStatus::Err {
94 message: format!("unknown tool: {name}"),
95 truncated: false,
96 },
97 }
98 }
99}
100
101#[derive(Debug, Default, Clone)]
106pub struct TouchedFiles {
107 pub paths: HashSet<String>,
109 pub saw_shell: bool,
112}
113
114impl TouchedFiles {
115 pub fn new() -> Self {
116 Self::default()
117 }
118 pub fn is_empty(&self) -> bool {
119 self.paths.is_empty() && !self.saw_shell
120 }
121 pub fn paths_sorted(&self) -> Vec<String> {
122 let mut v: Vec<_> = self.paths.iter().cloned().collect();
123 v.sort();
124 v
125 }
126}
127
128pub(crate) fn unix_millis() -> i64 {
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .map(|d| d.as_millis() as i64)
134 .unwrap_or(0)
135}
136
137pub(crate) fn truncate_for_audit(s: &str) -> (String, bool) {
140 if s.len() <= AUDIT_ERR_MAX_BYTES {
141 return (s.to_string(), false);
142 }
143 let mut end = AUDIT_ERR_MAX_BYTES;
144 while !s.is_char_boundary(end) {
145 end -= 1;
146 }
147 (s[..end].to_string(), true)
148}
149
150pub(crate) fn blake3_canonical_json(v: &Value) -> String {
152 let canonical = v.to_string();
153 let hash = blake3::hash(canonical.as_bytes());
154 hash.to_hex().to_string()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
164 fn is_false_returns_true_for_false() {
165 assert!(is_false(&false), "is_false(&false) must be true");
166 }
167
168 #[test]
169 fn is_false_returns_false_for_true() {
170 assert!(!is_false(&true), "is_false(&true) must be false");
171 }
172
173 #[test]
176 fn synthetic_unknown_tool_has_reasonable_fields() {
177 let meta = AuditMeta::synthetic_unknown_tool("my_tool");
178 assert!(!meta.step_id.is_empty(), "step_id must be non-empty");
179 assert!(meta.started_at > 0, "started_at must be positive");
180 assert_eq!(meta.side_effect, ToolSideEffect::External);
181 match &meta.exit_status {
182 ExitStatus::Err { message, .. } => {
183 assert!(
184 message.contains("my_tool"),
185 "error message must contain tool name"
186 );
187 }
188 ExitStatus::Ok => panic!("unknown tool must produce ExitStatus::Err"),
189 }
190 }
191
192 #[test]
195 fn touched_files_empty_paths_no_shell_is_empty() {
196 let tf = TouchedFiles::new();
197 assert!(tf.is_empty(), "no paths + no shell must be empty");
198 }
199
200 #[test]
201 fn touched_files_with_path_is_not_empty() {
202 let mut tf = TouchedFiles::new();
203 tf.paths.insert("src/main.rs".into());
204 assert!(!tf.is_empty(), "non-empty paths must not be empty");
205 }
206
207 #[test]
208 fn touched_files_saw_shell_is_not_empty() {
209 let mut tf = TouchedFiles::new();
210 tf.saw_shell = true;
211 assert!(
212 !tf.is_empty(),
213 "saw_shell=true must not be empty (kills &&→|| mutant)"
214 );
215 }
216
217 #[test]
220 fn paths_sorted_returns_sorted_vec() {
221 let mut tf = TouchedFiles::new();
222 tf.paths.insert("z.rs".into());
223 tf.paths.insert("a.rs".into());
224 tf.paths.insert("m.rs".into());
225 let sorted = tf.paths_sorted();
226 assert_eq!(sorted, vec!["a.rs", "m.rs", "z.rs"]);
227 }
228
229 #[test]
230 fn paths_sorted_on_empty_is_empty_vec() {
231 let tf = TouchedFiles::new();
232 assert!(tf.paths_sorted().is_empty());
233 }
234
235 #[test]
238 fn unix_millis_is_positive() {
239 let ms = unix_millis();
240 assert!(
241 ms > 0,
242 "unix_millis must return a positive timestamp; got {ms}"
243 );
244 assert!(
246 ms > 1_704_067_200_000,
247 "unix_millis must be after 2024-01-01"
248 );
249 }
250
251 #[test]
254 fn truncate_short_string_unchanged() {
255 let short = "hello";
256 let (out, truncated) = truncate_for_audit(short);
257 assert_eq!(out, short);
258 assert!(!truncated, "short string must not be truncated");
259 }
260
261 #[test]
262 fn truncate_long_string_is_clipped_and_flagged() {
263 let long = "x".repeat(AUDIT_ERR_MAX_BYTES + 10);
264 let (out, truncated) = truncate_for_audit(&long);
265 assert!(truncated, "long string must be flagged as truncated");
266 assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
267 }
268
269 #[test]
270 fn truncate_exactly_max_bytes_not_truncated() {
271 let exact = "a".repeat(AUDIT_ERR_MAX_BYTES);
272 let (out, truncated) = truncate_for_audit(&exact);
273 assert!(!truncated, "exactly max bytes must NOT be truncated");
274 assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
275 }
276
277 #[test]
280 fn blake3_canonical_json_is_nonempty_and_not_placeholder() {
281 let hash = blake3_canonical_json(&serde_json::json!({"key": "value"}));
282 assert!(!hash.is_empty(), "blake3 hash must not be empty");
283 assert_ne!(hash, "xyzzy", "blake3 hash must not be placeholder");
284 }
285
286 #[test]
287 fn blake3_canonical_json_differs_for_different_inputs() {
288 let h1 = blake3_canonical_json(&serde_json::json!({"a": 1}));
289 let h2 = blake3_canonical_json(&serde_json::json!({"a": 2}));
290 assert_ne!(h1, h2, "different inputs must produce different hashes");
291 }
292}