Skip to main content

teaql_tool_core/
formatter.rs

1use std::fmt::Write;
2use std::time::Duration;
3
4use crate::audit::{AuditLevel, Module};
5
6/// A key-value pair for module-specific audit details.
7pub 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
18/// Formats structured audit log entries based on the configured audit level.
19/// This is the single source of truth for all audit output formats.
20/// Application layer code never calls this directly — only the framework
21/// infrastructure layer uses it inside `IntoFuture` and `MustComment` internals.
22pub struct AuditFormatter;
23
24impl AuditFormatter {
25    /// Format an audit log entry. Returns `None` if the level is `Silent`.
26    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    /// Returns the uppercase tag for a module, used in log output.
98    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        // Silent
155        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        // Summary
163        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        // Full
171        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        // FullWithPayload
179        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}