use serde_json::{json, Value};
const MAX_ARGS_LENGTH: usize = 80;
fn is_leap(year: u32) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
pub fn ts_now() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_ms = dur.as_millis() as u64;
let total_s = total_ms / 1000;
let frac_ms = total_ms % 1000;
let time_of_day = (total_s % 86400) as u32;
let h = time_of_day / 3600;
let mi = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let mut remaining_days = (total_s / 86400) as u32;
let mut year = 1970u32;
loop {
let days_in_year = if is_leap(year) { 366u32 } else { 365u32 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let month_lengths: [u32; 12] = [
31,
if is_leap(year) { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 1u32;
for (i, &ml) in month_lengths.iter().enumerate() {
if remaining_days < ml {
month = i as u32 + 1;
break;
}
remaining_days -= ml;
}
let day = remaining_days + 1;
format!("{year:04}-{month:02}-{day:02}T{h:02}:{mi:02}:{s:02}.{frac_ms:03}Z")
}
fn truncate(s: &str) -> String {
let units: Vec<u16> = s.encode_utf16().collect();
if units.len() <= MAX_ARGS_LENGTH {
s.to_string()
} else {
format!(
"{}...",
String::from_utf16_lossy(&units[..MAX_ARGS_LENGTH - 3])
)
}
}
pub fn summarize_args(input: &Value) -> String {
let obj = match input.as_object() {
Some(o) if !o.is_empty() => o,
_ => return String::new(),
};
if let Some(fp) = obj.get("file_path").and_then(|v| v.as_str()) {
return truncate(fp);
}
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
let combined = match obj.get("path").and_then(|v| v.as_str()) {
Some(path) => format!("{pattern} {path}"),
None => pattern.to_string(),
};
return truncate(&combined);
}
if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
return truncate(cmd);
}
if let Some(query) = obj.get("query").and_then(|v| v.as_str()) {
return truncate(query);
}
if let Some(url) = obj.get("url").and_then(|v| v.as_str()) {
return truncate(url);
}
if let Some((key, val)) = obj.iter().next() {
let serialized = serde_json::to_string(val).unwrap_or_default();
return truncate(&format!("{key}:{serialized}"));
}
String::new()
}
pub struct FingerprintInput {
pub model: String,
pub claude_md_hash: String,
pub rules_loaded: Vec<String>,
pub promises_active: u64,
pub registry_version: String,
pub cxpak_available: bool,
pub cxpak_version: Option<String>,
}
pub fn build_fingerprint(fp: FingerprintInput) -> Value {
let cxpak_version: Value = if fp.cxpak_available {
fp.cxpak_version.map(Value::String).unwrap_or(Value::Null)
} else {
Value::Null
};
json!({
"ts": ts_now(),
"ev": "fingerprint",
"model": fp.model,
"claude_md_hash": fp.claude_md_hash,
"rules_loaded": fp.rules_loaded,
"promises_active": fp.promises_active,
"registry_version": fp.registry_version,
"cxpak_available": fp.cxpak_available,
"cxpak_version": cxpak_version,
})
}
pub fn build_trace(messages: &[Value]) -> Vec<Value> {
let mut events: Vec<Value> = Vec::new();
let mut total_in: u64 = 0;
let mut total_out: u64 = 0;
events.push(json!({ "ts": ts_now(), "ev": "start" }));
for msg in messages {
let message = &msg["message"];
if message.is_null() || message["role"].as_str() != Some("assistant") {
continue;
}
let usage = &msg["usage"];
let in_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
let out_tokens = usage["output_tokens"].as_u64().unwrap_or(0);
if in_tokens != 0 {
total_in += in_tokens;
}
if out_tokens != 0 {
total_out += out_tokens;
}
let content = match message["content"].as_array() {
Some(a) => a,
None => continue,
};
for block in content {
if block["type"].as_str() != Some("tool_use") {
continue;
}
let block_input = &block["input"];
let args_summary = summarize_args(block_input);
let file_path = block_input.get("file_path").cloned().unwrap_or(Value::Null);
let name = block["name"].as_str().unwrap_or("").to_string();
events.push(json!({
"ts": ts_now(),
"ev": "tool",
"name": name,
"args_summary": args_summary,
"file_path": file_path,
"tokens": {
"in": in_tokens,
"out": out_tokens,
},
}));
}
}
events.push(json!({
"ts": ts_now(),
"ev": "end",
"total_tokens": { "in": total_in, "out": total_out },
}));
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_multibyte_does_not_panic_and_uses_utf16_length() {
let long = "é".repeat(90);
let v = serde_json::json!({ "file_path": &long });
let r = summarize_args(&v);
assert!(r.ends_with("..."), "must truncate: {r}");
assert_eq!(
r.encode_utf16().count(),
80,
"truncated result is 77 units + 3 for the ellipsis = 80 UTF-16 units"
);
let short = "é".repeat(79);
let v2 = serde_json::json!({ "file_path": &short });
let r2 = summarize_args(&v2);
assert_eq!(
r2, short,
"under-limit multibyte must pass through untouched"
);
}
#[test]
fn summarize_args_truncation_boundary() {
let s79 = "a".repeat(79);
let v79 = serde_json::json!({"file_path": &s79});
let r79 = summarize_args(&v79);
assert_eq!(r79.len(), 79, "79-char path should not be truncated");
assert!(!r79.ends_with("..."));
let s80 = "a".repeat(80);
let v80 = serde_json::json!({"file_path": &s80});
let r80 = summarize_args(&v80);
assert_eq!(r80.len(), 80, "80-char path should not be truncated");
assert!(!r80.ends_with("..."));
let s81 = "a".repeat(81);
let v81 = serde_json::json!({"file_path": &s81});
let r81 = summarize_args(&v81);
assert_eq!(r81.len(), 80, "81-char path should be truncated to 80");
assert!(r81.ends_with("..."), "truncated result must end with '...'");
assert_eq!(&r81[..77], &s81[..77], "first 77 chars must be preserved");
}
}