use super::{
config::{Settings, review_log_path},
policy::Decision,
};
use crate::utils::{time::now_rfc3339_secs, truncate::truncate_chars};
use serde_json::json;
use std::{
fs::{self, OpenOptions},
io::Write,
};
const VALUE_MAX_CHARS: usize = 1000;
pub(super) fn record(
settings: &Settings,
tool: &str,
decision: &Decision,
value: &str,
approved: bool,
) {
if !settings.review_log {
return;
}
let path = review_log_path();
if let Some(parent) = path.parent()
&& fs::create_dir_all(parent).is_err()
{
return;
}
let line = json!({
"ts": now_rfc3339_secs(),
"tool": tool,
"surface": decision.surface,
"pattern": decision.pattern,
"action": decision.action.to_string(),
"value": truncate_chars(value, VALUE_MAX_CHARS, Some("…"), false),
"reason": decision.reason,
"sessionApproved": approved,
});
let Ok(text) = serde_json::to_string(&line) else {
return;
};
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
_ = writeln!(file, "{text}");
}
}
pub(super) fn tail(limit: usize) -> Vec<String> {
let path = review_log_path();
let Ok(text) = fs::read_to_string(&path) else {
return Vec::new();
};
let mut lines: Vec<String> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect();
if lines.len() > limit {
lines.drain(..lines.len() - limit);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_is_well_formed() {
let ts = now_rfc3339_secs();
assert_eq!(ts.len(), 20, "{ts}");
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
}
#[test]
fn truncation_respects_chars() {
let long = "汉".repeat(VALUE_MAX_CHARS + 10);
let cut = truncate_chars(&long, VALUE_MAX_CHARS, Some("…"), false);
assert_eq!(cut.chars().count(), VALUE_MAX_CHARS + 1);
assert!(cut.ends_with('…'));
assert_eq!(
truncate_chars("short", VALUE_MAX_CHARS, Some("…"), false),
"short"
);
}
}