1use std::fmt::Write;
2use std::time::Duration;
3
4use crate::audit::{AuditLevel, Module};
5
6pub struct AuditDetail<'a> {
8 pub key: &'a str,
9 pub value: &'a str,
10}
11
12impl<'a> AuditDetail<'a> {
13 pub fn new(key: &'a str, value: &'a str) -> Self {
14 Self { key, value }
15 }
16}
17
18pub struct AuditFormatter;
23
24impl AuditFormatter {
25 pub fn format(
27 module: Module,
28 level: AuditLevel,
29 timestamp: &str,
30 trace_id: &str,
31 user: &str,
32 operation: &str,
33 comment: &str,
34 elapsed: Duration,
35 status: &str,
36 details: &[AuditDetail<'_>],
37 ) -> Option<String> {
38 match level {
39 AuditLevel::Silent => None,
40
41 AuditLevel::Summary => {
42 Some(format!(
43 "{} | {} | {} | {} | {} | {}ms | {}",
44 timestamp,
45 trace_id,
46 user,
47 Self::module_tag(module),
48 operation,
49 elapsed.as_millis(),
50 status,
51 ))
52 }
53
54 AuditLevel::Full => {
55 let mut out = format!(
56 "{} | {} | {} | {} | {} | {}ms | {}",
57 timestamp,
58 trace_id,
59 user,
60 Self::module_tag(module),
61 operation,
62 elapsed.as_millis(),
63 status,
64 );
65 let _ = write!(out, "\n Intent: {}", comment);
66 for d in details {
67 let _ = write!(out, "\n {}: {}", d.key, d.value);
68 }
69 Some(out)
70 }
71
72 AuditLevel::FullWithPayload { max_bytes } => {
73 let mut out = format!(
74 "{} | {} | {} | {} | {} | {}ms | {}",
75 timestamp,
76 trace_id,
77 user,
78 Self::module_tag(module),
79 operation,
80 elapsed.as_millis(),
81 status,
82 );
83 let _ = write!(out, "\n Intent: {}", comment);
84 for d in details {
85 let value = if d.value.len() > max_bytes {
86 format!("{}... (truncated to {}B)", &d.value[..max_bytes], max_bytes)
87 } else {
88 d.value.to_string()
89 };
90 let _ = write!(out, "\n {}: {}", d.key, value);
91 }
92 Some(out)
93 }
94 }
95 }
96
97 pub fn module_tag(module: Module) -> &'static str {
99 match module {
100 Module::Http => "HTTP",
101 Module::File => "FILE",
102 Module::Cmd => "CMD",
103 Module::Email => "EMAIL",
104 Module::Kv => "KV",
105 Module::Crypto => "CRYPTO",
106 Module::Jwt => "JWT",
107 Module::Time => "TIME",
108 Module::Id => "ID",
109 Module::Text => "TEXT",
110 Module::Decimal => "DECIMAL",
111 Module::Money => "MONEY",
112 Module::Json => "JSON",
113 Module::Regex => "REGEX",
114 Module::Codec => "CODEC",
115 Module::List => "LIST",
116 Module::Map => "MAP",
117 Module::Diff => "DIFF",
118 Module::Url => "URL",
119 Module::Validate => "VALIDATE",
120 Module::Color => "COLOR",
121 Module::Unit => "UNIT",
122 Module::DateRange => "DATERANGE",
123 Module::Desensitize => "DESENSITIZE",
124 Module::Filter => "FILTER",
125 Module::Tree => "TREE",
126 Module::System => "SYSTEM",
127 _ => "UNKNOWN",
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 const TS: &str = "2026-06-04T09:15:00Z";
137 const TRACE: &str = "a1b2c3d4";
138 const USER: &str = "philip";
139
140 fn print_module_card(
141 module: Module,
142 operation: &str,
143 comment: &str,
144 elapsed: Duration,
145 status: &str,
146 details: &[AuditDetail<'_>],
147 payload_details: &[AuditDetail<'_>],
148 ) {
149 let tag = AuditFormatter::module_tag(module);
150 println!("═══════════════════════════════════════════════════");
151 println!("Module: {} | {:?}", tag, module);
152 println!("═══════════════════════════════════════════════════");
153
154 let result = AuditFormatter::format(
156 module, AuditLevel::Silent, TS, TRACE, USER,
157 operation, comment, elapsed, status, details,
158 );
159 println!("\n[Silent]\n(no output)");
160 assert!(result.is_none());
161
162 let result = AuditFormatter::format(
164 module, AuditLevel::Summary, TS, TRACE, USER,
165 operation, comment, elapsed, status, details,
166 ).unwrap();
167 println!("\n[Summary]\n{}", result);
168 assert!(!result.contains("Intent:"));
169
170 let result = AuditFormatter::format(
172 module, AuditLevel::Full, TS, TRACE, USER,
173 operation, comment, elapsed, status, details,
174 ).unwrap();
175 println!("\n[Full]\n{}", result);
176 assert!(result.contains("Intent:"));
177
178 let result = AuditFormatter::format(
180 module, AuditLevel::FullWithPayload { max_bytes: 128 }, TS, TRACE, USER,
181 operation, comment, elapsed, status, payload_details,
182 ).unwrap();
183 println!("\n[FullWithPayload]\n{}", result);
184 assert!(result.contains("Intent:"));
185
186 println!();
187 }
188
189 #[test]
190 fn audit_format_http() {
191 print_module_card(
192 Module::Http, "GET", "Fetch exchange rates for settlement",
193 Duration::from_millis(127), "200 OK",
194 &[AuditDetail::new("URL", "https://api.partner.com/v2/rates?base=USD")],
195 &[
196 AuditDetail::new("URL", "https://api.partner.com/v2/rates?base=USD"),
197 AuditDetail::new("Response", r#"{"USD":1.0,"EUR":0.92,"CNY":7.24}"#),
198 ],
199 );
200 }
201
202 #[test]
203 fn audit_format_file() {
204 print_module_card(
205 Module::File, "READ", "Load tenant-specific encryption key",
206 Duration::from_millis(3), "OK 2048B",
207 &[AuditDetail::new("Path", "/etc/teaql/tenants/1024/secret.pem")],
208 &[
209 AuditDetail::new("Path", "/etc/teaql/tenants/1024/secret.pem"),
210 AuditDetail::new("Content", "[binary 2048 bytes]"),
211 ],
212 );
213 }
214
215 #[test]
216 fn audit_format_cmd() {
217 print_module_card(
218 Module::Cmd, "exec", "Generate PDF invoice via wkhtmltopdf",
219 Duration::from_millis(1200), "Exit(0)",
220 &[AuditDetail::new("Command", "wkhtmltopdf /tmp/invoice.html /tmp/invoice.pdf")],
221 &[
222 AuditDetail::new("Command", "wkhtmltopdf /tmp/invoice.html /tmp/invoice.pdf"),
223 AuditDetail::new("Stdout", "Loading page (1/2)\nPrinting pages (2/2)\nDone"),
224 ],
225 );
226 }
227
228 #[test]
229 fn audit_format_time() {
230 print_module_card(
231 Module::Time, "today.add_days(7)", "Calculate 7-day grace period for overdue payment",
232 Duration::from_nanos(800), "OK",
233 &[
234 AuditDetail::new("Timezone", "Asia/Shanghai"),
235 AuditDetail::new("Result", "2026-06-11"),
236 ],
237 &[
238 AuditDetail::new("Timezone", "Asia/Shanghai"),
239 AuditDetail::new("Result", "2026-06-11"),
240 ],
241 );
242 }
243
244 #[test]
245 fn audit_format_id() {
246 print_module_card(
247 Module::Id, "uuid_v7", "Generate idempotency key for payment callback",
248 Duration::from_nanos(500), "OK",
249 &[AuditDetail::new("Result", "019abc12-def3-7000-8000-000000000001")],
250 &[AuditDetail::new("Result", "019abc12-def3-7000-8000-000000000001")],
251 );
252 }
253
254 #[test]
255 fn audit_format_money() {
256 print_module_card(
257 Module::Money, "add", "Calculate total including 6% VAT",
258 Duration::from_nanos(200), "OK",
259 &[
260 AuditDetail::new("Input", "1000.00 USD + 60.00 USD"),
261 AuditDetail::new("Result", "1060.00 USD"),
262 ],
263 &[
264 AuditDetail::new("Input", "1000.00 USD + 60.00 USD"),
265 AuditDetail::new("Result", "1060.00 USD"),
266 ],
267 );
268 }
269
270 #[test]
271 fn audit_format_crypto() {
272 print_module_card(
273 Module::Crypto, "aes_gcm_encrypt", "Encrypt PII before persisting to database",
274 Duration::from_millis(2), "OK",
275 &[
276 AuditDetail::new("Algorithm", "AES-256-GCM"),
277 AuditDetail::new("Size", "256B -> 272B"),
278 ],
279 &[
280 AuditDetail::new("Algorithm", "AES-256-GCM"),
281 AuditDetail::new("Size", "256B -> 272B"),
282 AuditDetail::new("Input", "[REDACTED]"),
283 ],
284 );
285 }
286
287 #[test]
288 fn audit_format_json() {
289 print_module_card(
290 Module::Json, "parse", "Parse incoming webhook payload from Stripe",
291 Duration::from_nanos(1500), "OK",
292 &[AuditDetail::new("Size", "4096B")],
293 &[
294 AuditDetail::new("Size", "4096B"),
295 AuditDetail::new("Content", r#"{"id":"evt_1234","type":"payment_intent.succeeded","data":{"object":{"id":"pi_abc","amount":10000,"currency":"usd","status":"succeeded"}}}"#),
296 ],
297 );
298 }
299}