use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSideEffect {
ReadOnly,
Mutating,
External,
}
pub const AUDIT_ERR_MAX_BYTES: usize = 512;
#[inline]
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExitStatus {
Ok,
Err {
message: String,
#[serde(default, skip_serializing_if = "is_false")]
truncated: bool,
},
}
pub type AuditKey = (u32 , String );
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditMeta {
pub step_id: String,
pub started_at: i64,
pub finished_at: i64,
pub args_hash: String,
pub side_effect: ToolSideEffect,
pub exit_status: ExitStatus,
}
impl AuditMeta {
pub fn synthetic_unknown_tool(name: &str) -> Self {
let now = unix_millis();
Self {
step_id: uuid::Uuid::now_v7().hyphenated().to_string(),
started_at: now,
finished_at: now,
args_hash: String::new(),
side_effect: ToolSideEffect::External,
exit_status: ExitStatus::Err {
message: format!("unknown tool: {name}"),
truncated: false,
},
}
}
}
#[derive(Debug, Default, Clone)]
pub struct TouchedFiles {
pub paths: HashSet<String>,
pub saw_shell: bool,
}
impl TouchedFiles {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty() && !self.saw_shell
}
pub fn paths_sorted(&self) -> Vec<String> {
let mut v: Vec<_> = self.paths.iter().cloned().collect();
v.sort();
v
}
}
pub(crate) fn unix_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub(crate) fn truncate_for_audit(s: &str) -> (String, bool) {
if s.len() <= AUDIT_ERR_MAX_BYTES {
return (s.to_string(), false);
}
let mut end = AUDIT_ERR_MAX_BYTES;
while !s.is_char_boundary(end) {
end -= 1;
}
(s[..end].to_string(), true)
}
pub(crate) fn blake3_canonical_json(v: &Value) -> String {
let canonical = v.to_string();
let hash = blake3::hash(canonical.as_bytes());
hash.to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_false_returns_true_for_false() {
assert!(is_false(&false), "is_false(&false) must be true");
}
#[test]
fn is_false_returns_false_for_true() {
assert!(!is_false(&true), "is_false(&true) must be false");
}
#[test]
fn synthetic_unknown_tool_has_reasonable_fields() {
let meta = AuditMeta::synthetic_unknown_tool("my_tool");
assert!(!meta.step_id.is_empty(), "step_id must be non-empty");
assert!(meta.started_at > 0, "started_at must be positive");
assert_eq!(meta.side_effect, ToolSideEffect::External);
match &meta.exit_status {
ExitStatus::Err { message, .. } => {
assert!(
message.contains("my_tool"),
"error message must contain tool name"
);
}
ExitStatus::Ok => panic!("unknown tool must produce ExitStatus::Err"),
}
}
#[test]
fn touched_files_empty_paths_no_shell_is_empty() {
let tf = TouchedFiles::new();
assert!(tf.is_empty(), "no paths + no shell must be empty");
}
#[test]
fn touched_files_with_path_is_not_empty() {
let mut tf = TouchedFiles::new();
tf.paths.insert("src/main.rs".into());
assert!(!tf.is_empty(), "non-empty paths must not be empty");
}
#[test]
fn touched_files_saw_shell_is_not_empty() {
let mut tf = TouchedFiles::new();
tf.saw_shell = true;
assert!(
!tf.is_empty(),
"saw_shell=true must not be empty (kills &&→|| mutant)"
);
}
#[test]
fn paths_sorted_returns_sorted_vec() {
let mut tf = TouchedFiles::new();
tf.paths.insert("z.rs".into());
tf.paths.insert("a.rs".into());
tf.paths.insert("m.rs".into());
let sorted = tf.paths_sorted();
assert_eq!(sorted, vec!["a.rs", "m.rs", "z.rs"]);
}
#[test]
fn paths_sorted_on_empty_is_empty_vec() {
let tf = TouchedFiles::new();
assert!(tf.paths_sorted().is_empty());
}
#[test]
fn unix_millis_is_positive() {
let ms = unix_millis();
assert!(
ms > 0,
"unix_millis must return a positive timestamp; got {ms}"
);
assert!(
ms > 1_704_067_200_000,
"unix_millis must be after 2024-01-01"
);
}
#[test]
fn truncate_short_string_unchanged() {
let short = "hello";
let (out, truncated) = truncate_for_audit(short);
assert_eq!(out, short);
assert!(!truncated, "short string must not be truncated");
}
#[test]
fn truncate_long_string_is_clipped_and_flagged() {
let long = "x".repeat(AUDIT_ERR_MAX_BYTES + 10);
let (out, truncated) = truncate_for_audit(&long);
assert!(truncated, "long string must be flagged as truncated");
assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
}
#[test]
fn truncate_exactly_max_bytes_not_truncated() {
let exact = "a".repeat(AUDIT_ERR_MAX_BYTES);
let (out, truncated) = truncate_for_audit(&exact);
assert!(!truncated, "exactly max bytes must NOT be truncated");
assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
}
#[test]
fn blake3_canonical_json_is_nonempty_and_not_placeholder() {
let hash = blake3_canonical_json(&serde_json::json!({"key": "value"}));
assert!(!hash.is_empty(), "blake3 hash must not be empty");
assert_ne!(hash, "xyzzy", "blake3 hash must not be placeholder");
}
#[test]
fn blake3_canonical_json_differs_for_different_inputs() {
let h1 = blake3_canonical_json(&serde_json::json!({"a": 1}));
let h2 = blake3_canonical_json(&serde_json::json!({"a": 2}));
assert_ne!(h1, h2, "different inputs must produce different hashes");
}
}