Skip to main content

rustant_core/
audit.rs

1//! Audit trail — unified execution trace, querying, export, and analytics.
2//!
3//! Provides a complete audit system that unifies safety audit events,
4//! tool execution records, and token/cost tracking into a single
5//! queryable, exportable trace.
6
7use crate::merkle::{MerkleChain, VerificationResult};
8use crate::safety::AuditEvent;
9use crate::types::{CostEstimate, RiskLevel, TokenUsage};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::Path;
14use uuid::Uuid;
15
16// ---------------------------------------------------------------------------
17// TraceEvent + TraceEventKind
18// ---------------------------------------------------------------------------
19
20/// A single event within an execution trace, tagged with a monotonically
21/// increasing sequence number and a wall-clock timestamp.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct TraceEvent {
24    /// Monotonically increasing sequence number within the trace.
25    pub sequence: usize,
26    /// Wall-clock time at which the event was recorded.
27    pub timestamp: DateTime<Utc>,
28    /// The payload describing what happened.
29    pub kind: TraceEventKind,
30}
31
32/// Discriminated union of all event kinds that may appear in a trace.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum TraceEventKind {
36    TaskStarted {
37        task_id: Uuid,
38        goal: String,
39    },
40    TaskCompleted {
41        task_id: Uuid,
42        success: bool,
43        iterations: usize,
44    },
45    ToolRequested {
46        tool: String,
47        risk_level: RiskLevel,
48        args_summary: String,
49    },
50    ToolApproved {
51        tool: String,
52    },
53    ToolDenied {
54        tool: String,
55        reason: String,
56    },
57    ApprovalRequested {
58        tool: String,
59        context: String,
60    },
61    ApprovalDecision {
62        tool: String,
63        approved: bool,
64    },
65    ToolExecuted {
66        tool: String,
67        success: bool,
68        duration_ms: u64,
69        output_preview: String,
70    },
71    LlmCall {
72        model: String,
73        input_tokens: usize,
74        output_tokens: usize,
75        cost: f64,
76    },
77    StatusChange {
78        from: String,
79        to: String,
80    },
81    Error {
82        message: String,
83    },
84}
85
86impl TraceEventKind {
87    /// Convert a safety-layer [`AuditEvent`] into the corresponding
88    /// [`TraceEventKind`].
89    pub fn from_audit_event(event: &AuditEvent) -> Self {
90        match event {
91            AuditEvent::ActionRequested {
92                tool,
93                risk_level,
94                description,
95            } => TraceEventKind::ToolRequested {
96                tool: tool.clone(),
97                risk_level: *risk_level,
98                args_summary: description.clone(),
99            },
100            AuditEvent::ActionApproved { tool } => {
101                TraceEventKind::ToolApproved { tool: tool.clone() }
102            }
103            AuditEvent::ActionDenied { tool, reason } => TraceEventKind::ToolDenied {
104                tool: tool.clone(),
105                reason: reason.clone(),
106            },
107            AuditEvent::ActionExecuted {
108                tool,
109                success,
110                duration_ms,
111            } => TraceEventKind::ToolExecuted {
112                tool: tool.clone(),
113                success: *success,
114                duration_ms: *duration_ms,
115                output_preview: String::new(),
116            },
117            AuditEvent::ApprovalRequested { tool, context } => TraceEventKind::ApprovalRequested {
118                tool: tool.clone(),
119                context: context.clone(),
120            },
121            AuditEvent::ApprovalDecision { tool, approved } => TraceEventKind::ApprovalDecision {
122                tool: tool.clone(),
123                approved: *approved,
124            },
125        }
126    }
127
128    /// Return the event type as a human-readable tag (e.g. `"tool_requested"`).
129    fn type_tag(&self) -> &'static str {
130        match self {
131            TraceEventKind::TaskStarted { .. } => "task_started",
132            TraceEventKind::TaskCompleted { .. } => "task_completed",
133            TraceEventKind::ToolRequested { .. } => "tool_requested",
134            TraceEventKind::ToolApproved { .. } => "tool_approved",
135            TraceEventKind::ToolDenied { .. } => "tool_denied",
136            TraceEventKind::ApprovalRequested { .. } => "approval_requested",
137            TraceEventKind::ApprovalDecision { .. } => "approval_decision",
138            TraceEventKind::ToolExecuted { .. } => "tool_executed",
139            TraceEventKind::LlmCall { .. } => "llm_call",
140            TraceEventKind::StatusChange { .. } => "status_change",
141            TraceEventKind::Error { .. } => "error",
142        }
143    }
144
145    /// Return the tool name referenced by this event, if any.
146    fn tool_name(&self) -> Option<&str> {
147        match self {
148            TraceEventKind::ToolRequested { tool, .. }
149            | TraceEventKind::ToolApproved { tool }
150            | TraceEventKind::ToolDenied { tool, .. }
151            | TraceEventKind::ApprovalRequested { tool, .. }
152            | TraceEventKind::ApprovalDecision { tool, .. }
153            | TraceEventKind::ToolExecuted { tool, .. } => Some(tool),
154            _ => None,
155        }
156    }
157
158    /// Produce a short, single-line human-readable summary of the event.
159    fn summary(&self) -> String {
160        match self {
161            TraceEventKind::TaskStarted { goal, .. } => format!("Task started: {}", goal),
162            TraceEventKind::TaskCompleted {
163                success,
164                iterations,
165                ..
166            } => {
167                let tag = if *success { "SUCCESS" } else { "FAILED" };
168                format!("Task completed [{}] after {} iterations", tag, iterations)
169            }
170            TraceEventKind::ToolRequested {
171                tool, risk_level, ..
172            } => format!("Tool requested: {} (risk: {})", tool, risk_level),
173            TraceEventKind::ToolApproved { tool } => format!("Tool approved: {}", tool),
174            TraceEventKind::ToolDenied { tool, reason } => {
175                format!("Tool denied: {} — {}", tool, reason)
176            }
177            TraceEventKind::ApprovalRequested { tool, .. } => {
178                format!("Approval requested for: {}", tool)
179            }
180            TraceEventKind::ApprovalDecision { tool, approved } => {
181                let decision = if *approved { "approved" } else { "denied" };
182                format!("Approval decision for {}: {}", tool, decision)
183            }
184            TraceEventKind::ToolExecuted {
185                tool,
186                success,
187                duration_ms,
188                ..
189            } => {
190                let tag = if *success { "OK" } else { "ERR" };
191                format!("Tool executed: {} [{}] ({}ms)", tool, tag, duration_ms)
192            }
193            TraceEventKind::LlmCall {
194                model,
195                input_tokens,
196                output_tokens,
197                cost,
198            } => format!(
199                "LLM call: {} ({}/{} tokens, ${:.4})",
200                model, input_tokens, output_tokens, cost
201            ),
202            TraceEventKind::StatusChange { from, to } => {
203                format!("Status: {} -> {}", from, to)
204            }
205            TraceEventKind::Error { message } => format!("Error: {}", message),
206        }
207    }
208
209    /// Extract CSV detail string (tool name + extra info) for tabular export.
210    fn csv_details(&self) -> (String, String) {
211        match self {
212            TraceEventKind::TaskStarted { goal, .. } => (String::new(), goal.clone()),
213            TraceEventKind::TaskCompleted {
214                success,
215                iterations,
216                ..
217            } => (
218                String::new(),
219                format!("success={} iterations={}", success, iterations),
220            ),
221            TraceEventKind::ToolRequested {
222                tool,
223                risk_level,
224                args_summary,
225            } => (
226                tool.clone(),
227                format!("risk={} args={}", risk_level, args_summary),
228            ),
229            TraceEventKind::ToolApproved { tool } => (tool.clone(), String::new()),
230            TraceEventKind::ToolDenied { tool, reason } => (tool.clone(), reason.clone()),
231            TraceEventKind::ApprovalRequested { tool, context } => (tool.clone(), context.clone()),
232            TraceEventKind::ApprovalDecision { tool, approved } => {
233                (tool.clone(), format!("approved={}", approved))
234            }
235            TraceEventKind::ToolExecuted {
236                tool,
237                success,
238                duration_ms,
239                output_preview,
240            } => (
241                tool.clone(),
242                format!(
243                    "success={} duration_ms={} output={}",
244                    success, duration_ms, output_preview
245                ),
246            ),
247            TraceEventKind::LlmCall {
248                model,
249                input_tokens,
250                output_tokens,
251                cost,
252            } => (
253                String::new(),
254                format!(
255                    "model={} in={} out={} cost={:.6}",
256                    model, input_tokens, output_tokens, cost
257                ),
258            ),
259            TraceEventKind::StatusChange { from, to } => {
260                (String::new(), format!("{} -> {}", from, to))
261            }
262            TraceEventKind::Error { message } => (String::new(), message.clone()),
263        }
264    }
265}
266
267// ---------------------------------------------------------------------------
268// ExecutionTrace
269// ---------------------------------------------------------------------------
270
271/// The full execution history of a single task.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ExecutionTrace {
274    pub trace_id: Uuid,
275    pub session_id: Uuid,
276    pub task_id: Uuid,
277    pub goal: String,
278    pub started_at: DateTime<Utc>,
279    pub completed_at: Option<DateTime<Utc>>,
280    pub success: Option<bool>,
281    pub iterations: usize,
282    pub events: Vec<TraceEvent>,
283    pub total_usage: TokenUsage,
284    pub total_cost: CostEstimate,
285}
286
287impl ExecutionTrace {
288    /// Create a new, in-progress execution trace.
289    pub fn new(session_id: Uuid, task_id: Uuid, goal: impl Into<String>) -> Self {
290        let goal = goal.into();
291        let now = Utc::now();
292        let mut trace = Self {
293            trace_id: Uuid::new_v4(),
294            session_id,
295            task_id,
296            goal: goal.clone(),
297            started_at: now,
298            completed_at: None,
299            success: None,
300            iterations: 0,
301            events: Vec::new(),
302            total_usage: TokenUsage::default(),
303            total_cost: CostEstimate::default(),
304        };
305        // Record the initial task-started event.
306        trace.push_event(TraceEventKind::TaskStarted { task_id, goal });
307        trace
308    }
309
310    /// Append an event to the trace with an auto-assigned sequence number.
311    pub fn push_event(&mut self, kind: TraceEventKind) {
312        let seq = self.events.len();
313        self.events.push(TraceEvent {
314            sequence: seq,
315            timestamp: Utc::now(),
316            kind,
317        });
318    }
319
320    /// Mark the trace as completed.
321    pub fn complete(&mut self, success: bool) {
322        self.completed_at = Some(Utc::now());
323        self.success = Some(success);
324        self.push_event(TraceEventKind::TaskCompleted {
325            task_id: self.task_id,
326            success,
327            iterations: self.iterations,
328        });
329    }
330
331    /// Compute the wall-clock duration in milliseconds, if completed.
332    pub fn duration_ms(&self) -> Option<u64> {
333        self.completed_at.map(|end| {
334            let dur = end - self.started_at;
335            dur.num_milliseconds().max(0) as u64
336        })
337    }
338
339    /// Return references to all tool-related events.
340    pub fn tool_events(&self) -> Vec<&TraceEvent> {
341        self.events
342            .iter()
343            .filter(|e| {
344                matches!(
345                    e.kind,
346                    TraceEventKind::ToolRequested { .. }
347                        | TraceEventKind::ToolApproved { .. }
348                        | TraceEventKind::ToolDenied { .. }
349                        | TraceEventKind::ToolExecuted { .. }
350                )
351            })
352            .collect()
353    }
354
355    /// Return references to all error events.
356    pub fn error_events(&self) -> Vec<&TraceEvent> {
357        self.events
358            .iter()
359            .filter(|e| matches!(e.kind, TraceEventKind::Error { .. }))
360            .collect()
361    }
362
363    /// Return references to all LLM call events.
364    pub fn llm_events(&self) -> Vec<&TraceEvent> {
365        self.events
366            .iter()
367            .filter(|e| matches!(e.kind, TraceEventKind::LlmCall { .. }))
368            .collect()
369    }
370}
371
372// ---------------------------------------------------------------------------
373// AuditError
374// ---------------------------------------------------------------------------
375
376/// Errors that may occur during audit operations.
377#[derive(Debug, Clone, thiserror::Error)]
378pub enum AuditError {
379    #[error("serialization failed: {0}")]
380    SerializationFailed(String),
381    #[error("io error: {0}")]
382    IoError(String),
383    #[error("store is empty")]
384    EmptyStore,
385    #[error("trace not found: {0}")]
386    TraceNotFound(Uuid),
387}
388
389// ---------------------------------------------------------------------------
390// AuditStore
391// ---------------------------------------------------------------------------
392
393/// Persistent, capacity-bounded store of execution traces.
394pub struct AuditStore {
395    traces: Vec<ExecutionTrace>,
396    max_traces: usize,
397    merkle_chain: Option<MerkleChain>,
398}
399
400impl AuditStore {
401    /// Create a new store with the default capacity of 1 000 traces.
402    pub fn new() -> Self {
403        Self {
404            traces: Vec::new(),
405            max_traces: 1000,
406            merkle_chain: None,
407        }
408    }
409
410    /// Create a new store with Merkle chain integrity verification enabled.
411    pub fn with_merkle_chain() -> Self {
412        Self {
413            traces: Vec::new(),
414            max_traces: 1000,
415            merkle_chain: Some(MerkleChain::new()),
416        }
417    }
418
419    /// Add a trace to the store, evicting the oldest trace when capacity is
420    /// reached. If the Merkle chain is enabled, the serialized trace is
421    /// appended to the chain for tamper-evident integrity.
422    pub fn add_trace(&mut self, trace: ExecutionTrace) {
423        // Append to Merkle chain if enabled
424        if let Some(ref mut chain) = self.merkle_chain
425            && let Ok(serialized) = serde_json::to_vec(&trace)
426        {
427            chain.append(&serialized);
428        }
429        if self.traces.len() >= self.max_traces {
430            self.traces.remove(0);
431        }
432        self.traces.push(trace);
433    }
434
435    /// Verify the integrity of the Merkle chain.
436    ///
437    /// Returns `Some(VerificationResult)` if the chain is enabled, or `None`.
438    pub fn verify_integrity(&self) -> Option<VerificationResult> {
439        self.merkle_chain.as_ref().map(|chain| chain.verify_chain())
440    }
441
442    /// Return the root hash of the Merkle chain, if enabled.
443    pub fn merkle_root_hash(&self) -> Option<String> {
444        self.merkle_chain
445            .as_ref()
446            .and_then(|chain| chain.root_hash().map(|h| h.to_string()))
447    }
448
449    /// Access the underlying Merkle chain, if present.
450    pub fn merkle_chain(&self) -> Option<&MerkleChain> {
451        self.merkle_chain.as_ref()
452    }
453
454    /// Get a slice of all stored traces.
455    pub fn traces(&self) -> &[ExecutionTrace] {
456        &self.traces
457    }
458
459    /// Look up a trace by its `trace_id`.
460    pub fn get_trace(&self, trace_id: Uuid) -> Option<&ExecutionTrace> {
461        self.traces.iter().find(|t| t.trace_id == trace_id)
462    }
463
464    /// Run a structured query against the store and return matching traces.
465    pub fn query(&self, query: &AuditQuery) -> Vec<&ExecutionTrace> {
466        self.traces.iter().filter(|t| query.matches(t)).collect()
467    }
468
469    /// Return the *n* most recently added traces (most recent last).
470    pub fn latest(&self, n: usize) -> Vec<&ExecutionTrace> {
471        let start = self.traces.len().saturating_sub(n);
472        self.traces[start..].iter().collect()
473    }
474
475    /// Persist the store to a JSON file at the given `path`.
476    pub fn save(&self, path: &Path) -> Result<(), AuditError> {
477        let json = serde_json::to_string_pretty(&self.traces)
478            .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
479
480        if let Some(parent) = path.parent() {
481            std::fs::create_dir_all(parent).map_err(|e| AuditError::IoError(e.to_string()))?;
482        }
483
484        std::fs::write(path, json).map_err(|e| AuditError::IoError(e.to_string()))?;
485        Ok(())
486    }
487
488    /// Load a store from a JSON file at the given `path`.
489    pub fn load(path: &Path) -> Result<Self, AuditError> {
490        let json = std::fs::read_to_string(path).map_err(|e| AuditError::IoError(e.to_string()))?;
491
492        let traces: Vec<ExecutionTrace> = serde_json::from_str(&json)
493            .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
494
495        Ok(Self {
496            max_traces: 1000,
497            traces,
498            merkle_chain: None,
499        })
500    }
501
502    /// Load a store from a JSON file and rebuild the Merkle chain from
503    /// the loaded traces.
504    pub fn load_with_merkle(path: &Path) -> Result<Self, AuditError> {
505        let mut store = Self::load(path)?;
506        let mut chain = MerkleChain::new();
507        for trace in &store.traces {
508            if let Ok(serialized) = serde_json::to_vec(trace) {
509                chain.append(&serialized);
510            }
511        }
512        store.merkle_chain = Some(chain);
513        Ok(store)
514    }
515
516    /// Return the number of stored traces.
517    pub fn len(&self) -> usize {
518        self.traces.len()
519    }
520
521    /// Check whether the store is empty.
522    pub fn is_empty(&self) -> bool {
523        self.traces.is_empty()
524    }
525}
526
527impl Default for AuditStore {
528    fn default() -> Self {
529        Self::new()
530    }
531}
532
533// ---------------------------------------------------------------------------
534// AuditQuery
535// ---------------------------------------------------------------------------
536
537/// Filtering criteria for querying the [`AuditStore`].
538///
539/// Uses a builder pattern so callers can compose predicates fluently:
540/// ```ignore
541/// let q = AuditQuery::new()
542///     .for_session(session_id)
543///     .min_risk(RiskLevel::Execute)
544///     .successful();
545/// ```
546#[derive(Debug, Clone, Default)]
547pub struct AuditQuery {
548    pub session_id: Option<Uuid>,
549    pub task_id: Option<Uuid>,
550    pub tool_name: Option<String>,
551    pub risk_level_min: Option<RiskLevel>,
552    pub success_only: Option<bool>,
553    pub since: Option<DateTime<Utc>>,
554    pub until: Option<DateTime<Utc>>,
555}
556
557impl AuditQuery {
558    pub fn new() -> Self {
559        Self::default()
560    }
561
562    pub fn for_session(mut self, id: Uuid) -> Self {
563        self.session_id = Some(id);
564        self
565    }
566
567    pub fn for_task(mut self, id: Uuid) -> Self {
568        self.task_id = Some(id);
569        self
570    }
571
572    pub fn for_tool(mut self, name: impl Into<String>) -> Self {
573        self.tool_name = Some(name.into());
574        self
575    }
576
577    pub fn min_risk(mut self, level: RiskLevel) -> Self {
578        self.risk_level_min = Some(level);
579        self
580    }
581
582    pub fn successful(mut self) -> Self {
583        self.success_only = Some(true);
584        self
585    }
586
587    pub fn failed(mut self) -> Self {
588        self.success_only = Some(false);
589        self
590    }
591
592    pub fn since(mut self, dt: DateTime<Utc>) -> Self {
593        self.since = Some(dt);
594        self
595    }
596
597    pub fn until(mut self, dt: DateTime<Utc>) -> Self {
598        self.until = Some(dt);
599        self
600    }
601
602    /// Determine whether a given [`ExecutionTrace`] satisfies every non-`None`
603    /// predicate in this query.
604    fn matches(&self, trace: &ExecutionTrace) -> bool {
605        if let Some(sid) = self.session_id
606            && trace.session_id != sid
607        {
608            return false;
609        }
610        if let Some(tid) = self.task_id
611            && trace.task_id != tid
612        {
613            return false;
614        }
615        if let Some(ref tool) = self.tool_name {
616            let has_tool = trace
617                .events
618                .iter()
619                .any(|e| e.kind.tool_name() == Some(tool.as_str()));
620            if !has_tool {
621                return false;
622            }
623        }
624        if let Some(min_risk) = self.risk_level_min {
625            let has_risk = trace.events.iter().any(|e| {
626                if let TraceEventKind::ToolRequested { risk_level, .. } = &e.kind {
627                    *risk_level >= min_risk
628                } else {
629                    false
630                }
631            });
632            if !has_risk {
633                return false;
634            }
635        }
636        if let Some(want_success) = self.success_only {
637            match trace.success {
638                Some(s) if s == want_success => {}
639                _ => return false,
640            }
641        }
642        if let Some(since) = self.since
643            && trace.started_at < since
644        {
645            return false;
646        }
647        if let Some(until) = self.until
648            && trace.started_at > until
649        {
650            return false;
651        }
652        true
653    }
654}
655
656// ---------------------------------------------------------------------------
657// AuditExporter
658// ---------------------------------------------------------------------------
659
660/// Stateless exporter capable of rendering execution traces in several
661/// formats.
662pub struct AuditExporter;
663
664impl AuditExporter {
665    /// Export trace(s) to pretty-printed JSON.
666    pub fn to_json(traces: &[&ExecutionTrace]) -> Result<String, AuditError> {
667        serde_json::to_string_pretty(traces)
668            .map_err(|e| AuditError::SerializationFailed(e.to_string()))
669    }
670
671    /// Export trace(s) to JSON Lines format (one JSON object per line).
672    pub fn to_jsonl(traces: &[&ExecutionTrace]) -> Result<String, AuditError> {
673        let mut buf = String::new();
674        for trace in traces {
675            let line = serde_json::to_string(trace)
676                .map_err(|e| AuditError::SerializationFailed(e.to_string()))?;
677            buf.push_str(&line);
678            buf.push('\n');
679        }
680        Ok(buf)
681    }
682
683    /// Export to a human-readable text summary.
684    pub fn to_text(traces: &[&ExecutionTrace]) -> String {
685        let mut buf = String::new();
686        for trace in traces {
687            buf.push_str(&format!(
688                "Trace {} | Task: {}\n",
689                trace.trace_id, trace.goal
690            ));
691            buf.push_str(&format!(
692                "Started: {} | Completed: {} | Duration: {}ms\n",
693                trace.started_at.to_rfc3339(),
694                trace
695                    .completed_at
696                    .map(|t| t.to_rfc3339())
697                    .unwrap_or_else(|| "in-progress".to_string()),
698                trace
699                    .duration_ms()
700                    .map(|d| d.to_string())
701                    .unwrap_or_else(|| "N/A".to_string()),
702            ));
703            buf.push_str(&format!(
704                "Iterations: {} | Tokens: {}/{} | Cost: ${:.4}\n",
705                trace.iterations,
706                trace.total_usage.input_tokens,
707                trace.total_usage.output_tokens,
708                trace.total_cost.total(),
709            ));
710            buf.push_str("Events:\n");
711            for event in &trace.events {
712                buf.push_str(&format!(
713                    "  [{}] {} {}\n",
714                    event.sequence,
715                    event.timestamp.to_rfc3339(),
716                    event.kind.summary()
717                ));
718            }
719            buf.push_str("---\n");
720        }
721        buf
722    }
723
724    /// Export to CSV (one row per trace event).
725    ///
726    /// Columns: `trace_id,sequence,timestamp,event_type,tool,details`
727    pub fn to_csv(traces: &[&ExecutionTrace]) -> String {
728        let mut buf = String::from("trace_id,sequence,timestamp,event_type,tool,details\n");
729        for trace in traces {
730            for event in &trace.events {
731                let (tool, details) = event.kind.csv_details();
732                buf.push_str(&format!(
733                    "{},{},{},{},{},{}\n",
734                    trace.trace_id,
735                    event.sequence,
736                    event.timestamp.to_rfc3339(),
737                    event.kind.type_tag(),
738                    csv_escape(&tool),
739                    csv_escape(&details),
740                ));
741            }
742        }
743        buf
744    }
745}
746
747/// Minimal CSV field escaping: wrap the value in double-quotes if it contains
748/// a comma, newline, or double-quote, doubling any embedded double-quotes.
749fn csv_escape(value: &str) -> String {
750    if value.contains(',') || value.contains('"') || value.contains('\n') {
751        let escaped = value.replace('"', "\"\"");
752        format!("\"{}\"", escaped)
753    } else {
754        value.to_string()
755    }
756}
757
758// ---------------------------------------------------------------------------
759// Analytics helpers
760// ---------------------------------------------------------------------------
761
762/// Summary of tool usage across a set of execution traces.
763#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct ToolUsageSummary {
765    pub tool_counts: HashMap<String, usize>,
766    pub tool_success_rates: HashMap<String, f64>,
767    pub tool_avg_duration_ms: HashMap<String, f64>,
768    pub most_used: Option<String>,
769    pub most_denied: Option<String>,
770}
771
772/// Per-model cost and token breakdown.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct ModelCostEntry {
775    pub calls: usize,
776    pub total_tokens: usize,
777    pub total_cost: f64,
778}
779
780/// Aggregate cost breakdown across all models.
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct CostBreakdown {
783    pub total_cost: f64,
784    pub total_tokens: usize,
785    pub by_model: HashMap<String, ModelCostEntry>,
786}
787
788/// A detected pattern or anomaly in the audit data.
789#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct Pattern {
791    pub kind: PatternKind,
792    pub description: String,
793    pub occurrences: usize,
794}
795
796/// Enumeration of detectable pattern kinds.
797#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(rename_all = "snake_case")]
799pub enum PatternKind {
800    FrequentDenial,
801    ApprovalBottleneck,
802    HighCostTool,
803    RepeatedError,
804    SlowTool,
805}
806
807/// Stateless analytics engine operating over slices of execution traces.
808pub struct Analytics;
809
810impl Analytics {
811    /// Compute a summary of tool usage across the provided traces.
812    pub fn tool_usage_summary(traces: &[&ExecutionTrace]) -> ToolUsageSummary {
813        let mut counts: HashMap<String, usize> = HashMap::new();
814        let mut successes: HashMap<String, usize> = HashMap::new();
815        let mut exec_counts: HashMap<String, usize> = HashMap::new();
816        let mut durations: HashMap<String, Vec<u64>> = HashMap::new();
817        let mut denials: HashMap<String, usize> = HashMap::new();
818
819        for trace in traces {
820            for event in &trace.events {
821                match &event.kind {
822                    TraceEventKind::ToolRequested { tool, .. } => {
823                        *counts.entry(tool.clone()).or_insert(0) += 1;
824                    }
825                    TraceEventKind::ToolExecuted {
826                        tool,
827                        success,
828                        duration_ms,
829                        ..
830                    } => {
831                        *exec_counts.entry(tool.clone()).or_insert(0) += 1;
832                        if *success {
833                            *successes.entry(tool.clone()).or_insert(0) += 1;
834                        }
835                        durations
836                            .entry(tool.clone())
837                            .or_default()
838                            .push(*duration_ms);
839                    }
840                    TraceEventKind::ToolDenied { tool, .. } => {
841                        *denials.entry(tool.clone()).or_insert(0) += 1;
842                    }
843                    _ => {}
844                }
845            }
846        }
847
848        let tool_success_rates: HashMap<String, f64> = exec_counts
849            .iter()
850            .map(|(tool, &total)| {
851                let ok = *successes.get(tool).unwrap_or(&0);
852                let rate = if total > 0 {
853                    ok as f64 / total as f64
854                } else {
855                    0.0
856                };
857                (tool.clone(), rate)
858            })
859            .collect();
860
861        let tool_avg_duration_ms: HashMap<String, f64> = durations
862            .iter()
863            .map(|(tool, durs)| {
864                let avg = if durs.is_empty() {
865                    0.0
866                } else {
867                    durs.iter().sum::<u64>() as f64 / durs.len() as f64
868                };
869                (tool.clone(), avg)
870            })
871            .collect();
872
873        let most_used = counts
874            .iter()
875            .max_by_key(|&(_, &c)| c)
876            .map(|(t, _)| t.clone());
877
878        let most_denied = denials
879            .iter()
880            .max_by_key(|&(_, &c)| c)
881            .map(|(t, _)| t.clone());
882
883        ToolUsageSummary {
884            tool_counts: counts,
885            tool_success_rates,
886            tool_avg_duration_ms,
887            most_used,
888            most_denied,
889        }
890    }
891
892    /// Compute cost breakdown by model.
893    pub fn cost_breakdown(traces: &[&ExecutionTrace]) -> CostBreakdown {
894        let mut by_model: HashMap<String, ModelCostEntry> = HashMap::new();
895        let mut total_cost = 0.0_f64;
896        let mut total_tokens = 0_usize;
897
898        for trace in traces {
899            for event in &trace.events {
900                if let TraceEventKind::LlmCall {
901                    model,
902                    input_tokens,
903                    output_tokens,
904                    cost,
905                } = &event.kind
906                {
907                    let tokens = input_tokens + output_tokens;
908                    total_cost += cost;
909                    total_tokens += tokens;
910
911                    let entry = by_model.entry(model.clone()).or_insert(ModelCostEntry {
912                        calls: 0,
913                        total_tokens: 0,
914                        total_cost: 0.0,
915                    });
916                    entry.calls += 1;
917                    entry.total_tokens += tokens;
918                    entry.total_cost += cost;
919                }
920            }
921        }
922
923        CostBreakdown {
924            total_cost,
925            total_tokens,
926            by_model,
927        }
928    }
929
930    /// Detect patterns such as frequent denials, approval bottlenecks, slow
931    /// tools, repeated errors, and high-cost tools.
932    pub fn detect_patterns(traces: &[&ExecutionTrace]) -> Vec<Pattern> {
933        let mut patterns = Vec::new();
934
935        // --- Frequent denial ---
936        let mut denial_counts: HashMap<String, usize> = HashMap::new();
937        // --- Approval bottleneck ---
938        let mut approval_counts: HashMap<String, usize> = HashMap::new();
939        // --- Slow tools ---
940        let mut durations: HashMap<String, Vec<u64>> = HashMap::new();
941        // --- Repeated errors ---
942        let mut error_counts: HashMap<String, usize> = HashMap::new();
943        // --- High-cost tools ---
944        let mut tool_costs: HashMap<String, f64> = HashMap::new();
945
946        for trace in traces {
947            for event in &trace.events {
948                match &event.kind {
949                    TraceEventKind::ToolDenied { tool, .. } => {
950                        *denial_counts.entry(tool.clone()).or_insert(0) += 1;
951                    }
952                    TraceEventKind::ApprovalRequested { tool, .. } => {
953                        *approval_counts.entry(tool.clone()).or_insert(0) += 1;
954                    }
955                    TraceEventKind::ToolExecuted {
956                        tool, duration_ms, ..
957                    } => {
958                        durations
959                            .entry(tool.clone())
960                            .or_default()
961                            .push(*duration_ms);
962                    }
963                    TraceEventKind::Error { message } => {
964                        *error_counts.entry(message.clone()).or_insert(0) += 1;
965                    }
966                    TraceEventKind::LlmCall { cost, .. } => {
967                        // Attribute LLM costs to the most recent tool in
968                        // context (simplified heuristic).
969                        *tool_costs.entry("_llm".to_string()).or_insert(0.0) += cost;
970                    }
971                    _ => {}
972                }
973            }
974        }
975
976        // Emit patterns that exceed reasonable thresholds.
977        let threshold = 3_usize;
978
979        for (tool, count) in &denial_counts {
980            if *count >= threshold {
981                patterns.push(Pattern {
982                    kind: PatternKind::FrequentDenial,
983                    description: format!("Tool '{}' was denied {} times", tool, count),
984                    occurrences: *count,
985                });
986            }
987        }
988
989        for (tool, count) in &approval_counts {
990            if *count >= threshold {
991                patterns.push(Pattern {
992                    kind: PatternKind::ApprovalBottleneck,
993                    description: format!("Tool '{}' required approval {} times", tool, count),
994                    occurrences: *count,
995                });
996            }
997        }
998
999        let slow_threshold_ms = 5000_u64;
1000        for (tool, durs) in &durations {
1001            let slow = durs.iter().filter(|&&d| d >= slow_threshold_ms).count();
1002            if slow >= threshold {
1003                patterns.push(Pattern {
1004                    kind: PatternKind::SlowTool,
1005                    description: format!(
1006                        "Tool '{}' was slow (>={}ms) {} times",
1007                        tool, slow_threshold_ms, slow
1008                    ),
1009                    occurrences: slow,
1010                });
1011            }
1012        }
1013
1014        for (message, count) in &error_counts {
1015            if *count >= threshold {
1016                let preview = if message.len() > 60 {
1017                    format!("{}...", &message[..60])
1018                } else {
1019                    message.clone()
1020                };
1021                patterns.push(Pattern {
1022                    kind: PatternKind::RepeatedError,
1023                    description: format!("Error '{}' occurred {} times", preview, count),
1024                    occurrences: *count,
1025                });
1026            }
1027        }
1028
1029        let cost_threshold = 1.0_f64;
1030        for (label, cost) in &tool_costs {
1031            if *cost >= cost_threshold {
1032                patterns.push(Pattern {
1033                    kind: PatternKind::HighCostTool,
1034                    description: format!("'{}' accumulated ${:.4} in costs", label, cost),
1035                    occurrences: 1,
1036                });
1037            }
1038        }
1039
1040        patterns
1041    }
1042
1043    /// Compute the fraction of traces that completed successfully.
1044    ///
1045    /// Traces without a `success` outcome are excluded from the denominator.
1046    pub fn success_rate(traces: &[&ExecutionTrace]) -> f64 {
1047        let completed: Vec<_> = traces.iter().filter(|t| t.success.is_some()).collect();
1048        if completed.is_empty() {
1049            return 0.0;
1050        }
1051        let ok = completed.iter().filter(|t| t.success == Some(true)).count();
1052        ok as f64 / completed.len() as f64
1053    }
1054
1055    /// Compute the average number of iterations across all traces.
1056    pub fn avg_iterations(traces: &[&ExecutionTrace]) -> f64 {
1057        if traces.is_empty() {
1058            return 0.0;
1059        }
1060        let total: usize = traces.iter().map(|t| t.iterations).sum();
1061        total as f64 / traces.len() as f64
1062    }
1063}
1064
1065// ---------------------------------------------------------------------------
1066// Tests
1067// ---------------------------------------------------------------------------
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072    use chrono::Duration;
1073
1074    /// Helper: create a minimal execution trace.
1075    fn make_trace(goal: &str) -> ExecutionTrace {
1076        let session = Uuid::new_v4();
1077        let task = Uuid::new_v4();
1078        let mut trace = ExecutionTrace::new(session, task, goal);
1079        trace.iterations = 3;
1080        trace
1081    }
1082
1083    /// Helper: create a trace with tool events.
1084    fn make_trace_with_tools() -> ExecutionTrace {
1085        let mut trace = make_trace("test task");
1086        trace.push_event(TraceEventKind::ToolRequested {
1087            tool: "file_read".into(),
1088            risk_level: RiskLevel::ReadOnly,
1089            args_summary: "reading main.rs".into(),
1090        });
1091        trace.push_event(TraceEventKind::ToolApproved {
1092            tool: "file_read".into(),
1093        });
1094        trace.push_event(TraceEventKind::ToolExecuted {
1095            tool: "file_read".into(),
1096            success: true,
1097            duration_ms: 42,
1098            output_preview: "fn main() ...".into(),
1099        });
1100        trace.push_event(TraceEventKind::LlmCall {
1101            model: "claude-opus-4-5-20251101".into(),
1102            input_tokens: 1000,
1103            output_tokens: 500,
1104            cost: 0.05,
1105        });
1106        trace.push_event(TraceEventKind::Error {
1107            message: "timeout".into(),
1108        });
1109        trace
1110    }
1111
1112    // 1
1113    #[test]
1114    fn test_trace_event_creation() {
1115        let mut trace = make_trace("goal");
1116        // The constructor pushes a TaskStarted event at sequence 0.
1117        assert_eq!(trace.events.len(), 1);
1118        assert_eq!(trace.events[0].sequence, 0);
1119
1120        trace.push_event(TraceEventKind::StatusChange {
1121            from: "idle".into(),
1122            to: "thinking".into(),
1123        });
1124        assert_eq!(trace.events[1].sequence, 1);
1125
1126        trace.push_event(TraceEventKind::Error {
1127            message: "oops".into(),
1128        });
1129        assert_eq!(trace.events[2].sequence, 2);
1130    }
1131
1132    // 2
1133    #[test]
1134    fn test_execution_trace_new() {
1135        let session = Uuid::new_v4();
1136        let task = Uuid::new_v4();
1137        let trace = ExecutionTrace::new(session, task, "my goal");
1138
1139        assert_eq!(trace.session_id, session);
1140        assert_eq!(trace.task_id, task);
1141        assert_eq!(trace.goal, "my goal");
1142        assert!(trace.completed_at.is_none());
1143        assert!(trace.success.is_none());
1144        assert_eq!(trace.iterations, 0);
1145        assert_eq!(trace.total_usage.total(), 0);
1146        assert!((trace.total_cost.total() - 0.0).abs() < f64::EPSILON);
1147        // The constructor auto-pushes a TaskStarted event.
1148        assert_eq!(trace.events.len(), 1);
1149        assert!(matches!(
1150            &trace.events[0].kind,
1151            TraceEventKind::TaskStarted { goal, .. } if goal == "my goal"
1152        ));
1153    }
1154
1155    // 3
1156    #[test]
1157    fn test_execution_trace_push_event() {
1158        let mut trace = make_trace("push test");
1159        let initial_len = trace.events.len();
1160
1161        trace.push_event(TraceEventKind::ToolRequested {
1162            tool: "shell_exec".into(),
1163            risk_level: RiskLevel::Execute,
1164            args_summary: "cargo test".into(),
1165        });
1166        assert_eq!(trace.events.len(), initial_len + 1);
1167        assert_eq!(trace.events.last().unwrap().sequence, initial_len);
1168    }
1169
1170    // 4
1171    #[test]
1172    fn test_execution_trace_complete() {
1173        let mut trace = make_trace("complete test");
1174        assert!(trace.completed_at.is_none());
1175        assert!(trace.success.is_none());
1176
1177        trace.complete(true);
1178        assert!(trace.completed_at.is_some());
1179        assert_eq!(trace.success, Some(true));
1180
1181        // A TaskCompleted event should have been appended.
1182        let last = trace.events.last().unwrap();
1183        assert!(matches!(
1184            &last.kind,
1185            TraceEventKind::TaskCompleted { success: true, .. }
1186        ));
1187    }
1188
1189    // 5
1190    #[test]
1191    fn test_execution_trace_duration() {
1192        let mut trace = make_trace("duration test");
1193        // Not yet completed — duration is None.
1194        assert!(trace.duration_ms().is_none());
1195
1196        trace.complete(true);
1197        // Now duration should be >= 0.
1198        let dur = trace.duration_ms().unwrap();
1199        assert!(dur < 5000); // sanity: should be effectively instant in tests
1200    }
1201
1202    // 6
1203    #[test]
1204    fn test_execution_trace_tool_events() {
1205        let trace = make_trace_with_tools();
1206        let tool_evts = trace.tool_events();
1207        // ToolRequested, ToolApproved, ToolExecuted
1208        assert_eq!(tool_evts.len(), 3);
1209        assert!(matches!(
1210            &tool_evts[0].kind,
1211            TraceEventKind::ToolRequested { .. }
1212        ));
1213    }
1214
1215    // 7
1216    #[test]
1217    fn test_execution_trace_error_events() {
1218        let trace = make_trace_with_tools();
1219        let errs = trace.error_events();
1220        assert_eq!(errs.len(), 1);
1221        assert!(matches!(&errs[0].kind, TraceEventKind::Error { message } if message == "timeout"));
1222    }
1223
1224    // 8
1225    #[test]
1226    fn test_execution_trace_llm_events() {
1227        let trace = make_trace_with_tools();
1228        let llm = trace.llm_events();
1229        assert_eq!(llm.len(), 1);
1230        assert!(matches!(
1231            &llm[0].kind,
1232            TraceEventKind::LlmCall { model, .. } if model == "claude-opus-4-5-20251101"
1233        ));
1234    }
1235
1236    // 9
1237    #[test]
1238    fn test_audit_store_add_and_get() {
1239        let mut store = AuditStore::new();
1240        let trace = make_trace("store test");
1241        let id = trace.trace_id;
1242
1243        store.add_trace(trace);
1244        assert_eq!(store.len(), 1);
1245        assert!(!store.is_empty());
1246
1247        let found = store.get_trace(id).unwrap();
1248        assert_eq!(found.goal, "store test");
1249
1250        // Non-existent ID.
1251        assert!(store.get_trace(Uuid::new_v4()).is_none());
1252    }
1253
1254    // 10
1255    #[test]
1256    fn test_audit_store_capacity() {
1257        let mut store = AuditStore {
1258            traces: Vec::new(),
1259            max_traces: 3,
1260            merkle_chain: None,
1261        };
1262
1263        for i in 0..5 {
1264            store.add_trace(make_trace(&format!("trace {}", i)));
1265        }
1266
1267        assert_eq!(store.len(), 3);
1268        // The oldest traces (0 and 1) should have been evicted.
1269        let goals: Vec<&str> = store.traces().iter().map(|t| t.goal.as_str()).collect();
1270        assert_eq!(goals, vec!["trace 2", "trace 3", "trace 4"]);
1271    }
1272
1273    // 11
1274    #[test]
1275    fn test_audit_store_latest() {
1276        let mut store = AuditStore::new();
1277        for i in 0..5 {
1278            store.add_trace(make_trace(&format!("trace {}", i)));
1279        }
1280
1281        let latest = store.latest(2);
1282        assert_eq!(latest.len(), 2);
1283        assert_eq!(latest[0].goal, "trace 3");
1284        assert_eq!(latest[1].goal, "trace 4");
1285
1286        // Requesting more than available.
1287        let all = store.latest(100);
1288        assert_eq!(all.len(), 5);
1289    }
1290
1291    // 12
1292    #[test]
1293    fn test_audit_store_query_by_session() {
1294        let session_a = Uuid::new_v4();
1295        let session_b = Uuid::new_v4();
1296
1297        let mut store = AuditStore::new();
1298
1299        let mut t1 = make_trace("a1");
1300        t1.session_id = session_a;
1301        let mut t2 = make_trace("b1");
1302        t2.session_id = session_b;
1303        let mut t3 = make_trace("a2");
1304        t3.session_id = session_a;
1305
1306        store.add_trace(t1);
1307        store.add_trace(t2);
1308        store.add_trace(t3);
1309
1310        let results = store.query(&AuditQuery::new().for_session(session_a));
1311        assert_eq!(results.len(), 2);
1312        assert!(results.iter().all(|t| t.session_id == session_a));
1313    }
1314
1315    // 13
1316    #[test]
1317    fn test_audit_store_query_by_tool() {
1318        let mut store = AuditStore::new();
1319
1320        let mut t1 = make_trace("with tool");
1321        t1.push_event(TraceEventKind::ToolRequested {
1322            tool: "file_read".into(),
1323            risk_level: RiskLevel::ReadOnly,
1324            args_summary: "src/main.rs".into(),
1325        });
1326
1327        let t2 = make_trace("no tool");
1328
1329        store.add_trace(t1);
1330        store.add_trace(t2);
1331
1332        let results = store.query(&AuditQuery::new().for_tool("file_read"));
1333        assert_eq!(results.len(), 1);
1334        assert_eq!(results[0].goal, "with tool");
1335    }
1336
1337    // 14
1338    #[test]
1339    fn test_audit_store_query_by_risk() {
1340        let mut store = AuditStore::new();
1341
1342        let mut low = make_trace("low risk");
1343        low.push_event(TraceEventKind::ToolRequested {
1344            tool: "ls".into(),
1345            risk_level: RiskLevel::ReadOnly,
1346            args_summary: ".".into(),
1347        });
1348
1349        let mut high = make_trace("high risk");
1350        high.push_event(TraceEventKind::ToolRequested {
1351            tool: "rm".into(),
1352            risk_level: RiskLevel::Destructive,
1353            args_summary: "tmp/".into(),
1354        });
1355
1356        store.add_trace(low);
1357        store.add_trace(high);
1358
1359        let results = store.query(&AuditQuery::new().min_risk(RiskLevel::Execute));
1360        assert_eq!(results.len(), 1);
1361        assert_eq!(results[0].goal, "high risk");
1362    }
1363
1364    // 15
1365    #[test]
1366    fn test_audit_store_query_success_only() {
1367        let mut store = AuditStore::new();
1368
1369        let mut ok = make_trace("good");
1370        ok.complete(true);
1371
1372        let mut fail = make_trace("bad");
1373        fail.complete(false);
1374
1375        store.add_trace(ok);
1376        store.add_trace(fail);
1377
1378        let successes = store.query(&AuditQuery::new().successful());
1379        assert_eq!(successes.len(), 1);
1380        assert_eq!(successes[0].goal, "good");
1381
1382        let failures = store.query(&AuditQuery::new().failed());
1383        assert_eq!(failures.len(), 1);
1384        assert_eq!(failures[0].goal, "bad");
1385    }
1386
1387    // 16
1388    #[test]
1389    fn test_audit_store_query_time_range() {
1390        let mut store = AuditStore::new();
1391
1392        let now = Utc::now();
1393        let one_hour_ago = now - Duration::hours(1);
1394        let two_hours_ago = now - Duration::hours(2);
1395
1396        let mut old = make_trace("old");
1397        old.started_at = two_hours_ago;
1398
1399        let mut recent = make_trace("recent");
1400        recent.started_at = now;
1401
1402        store.add_trace(old);
1403        store.add_trace(recent);
1404
1405        let results = store.query(&AuditQuery::new().since(one_hour_ago));
1406        assert_eq!(results.len(), 1);
1407        assert_eq!(results[0].goal, "recent");
1408
1409        let results = store.query(&AuditQuery::new().until(one_hour_ago));
1410        assert_eq!(results.len(), 1);
1411        assert_eq!(results[0].goal, "old");
1412    }
1413
1414    // 17
1415    #[test]
1416    fn test_audit_store_save_load_roundtrip() {
1417        let dir = tempfile::tempdir().unwrap();
1418        let path = dir.path().join("audit.json");
1419
1420        let mut store = AuditStore::new();
1421        let mut trace = make_trace_with_tools();
1422        trace.complete(true);
1423        let id = trace.trace_id;
1424        store.add_trace(trace);
1425
1426        store.save(&path).unwrap();
1427        assert!(path.exists());
1428
1429        let loaded = AuditStore::load(&path).unwrap();
1430        assert_eq!(loaded.len(), 1);
1431        let t = loaded.get_trace(id).unwrap();
1432        assert_eq!(t.goal, "test task");
1433    }
1434
1435    // 18
1436    #[test]
1437    fn test_exporter_json() {
1438        let trace = make_trace_with_tools();
1439        let refs = vec![&trace];
1440
1441        let json = AuditExporter::to_json(&refs).unwrap();
1442        assert!(json.contains("test task"));
1443        assert!(json.contains("file_read"));
1444
1445        // Should be valid JSON.
1446        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1447        assert!(parsed.is_array());
1448    }
1449
1450    // 19
1451    #[test]
1452    fn test_exporter_jsonl() {
1453        let t1 = make_trace("one");
1454        let t2 = make_trace("two");
1455        let refs = vec![&t1, &t2];
1456
1457        let jsonl = AuditExporter::to_jsonl(&refs).unwrap();
1458        let lines: Vec<&str> = jsonl.trim().split('\n').collect();
1459        assert_eq!(lines.len(), 2);
1460
1461        // Each line should be valid JSON.
1462        for line in &lines {
1463            serde_json::from_str::<serde_json::Value>(line).unwrap();
1464        }
1465    }
1466
1467    // 20
1468    #[test]
1469    fn test_exporter_text() {
1470        let mut trace = make_trace_with_tools();
1471        trace.total_usage = TokenUsage {
1472            input_tokens: 1000,
1473            output_tokens: 500,
1474        };
1475        trace.total_cost = CostEstimate {
1476            input_cost: 0.01,
1477            output_cost: 0.03,
1478        };
1479        trace.complete(true);
1480        let refs = vec![&trace];
1481
1482        let text = AuditExporter::to_text(&refs);
1483        assert!(text.contains("Trace"));
1484        assert!(text.contains("test task"));
1485        assert!(text.contains("Tokens: 1000/500"));
1486        assert!(text.contains("$0.0400"));
1487        assert!(text.contains("Events:"));
1488        assert!(text.contains("---"));
1489    }
1490
1491    // 21
1492    #[test]
1493    fn test_exporter_csv() {
1494        let trace = make_trace_with_tools();
1495        let refs = vec![&trace];
1496
1497        let csv = AuditExporter::to_csv(&refs);
1498        let lines: Vec<&str> = csv.trim().split('\n').collect();
1499
1500        // Header + events.
1501        assert!(lines[0].starts_with("trace_id,sequence,timestamp,event_type,tool,details"));
1502        assert!(lines.len() > 1);
1503        // First data row should be task_started.
1504        assert!(lines[1].contains("task_started"));
1505    }
1506
1507    // 22
1508    #[test]
1509    fn test_analytics_tool_usage() {
1510        let trace = make_trace_with_tools();
1511        let refs = vec![&trace];
1512
1513        let summary = Analytics::tool_usage_summary(&refs);
1514        assert_eq!(summary.tool_counts.get("file_read"), Some(&1));
1515        assert_eq!(summary.most_used, Some("file_read".to_string()));
1516
1517        let rate = summary.tool_success_rates.get("file_read").unwrap();
1518        assert!((rate - 1.0).abs() < f64::EPSILON);
1519
1520        let avg = summary.tool_avg_duration_ms.get("file_read").unwrap();
1521        assert!((avg - 42.0).abs() < f64::EPSILON);
1522    }
1523
1524    // 23
1525    #[test]
1526    fn test_analytics_cost_breakdown() {
1527        let mut trace = make_trace("cost test");
1528        trace.push_event(TraceEventKind::LlmCall {
1529            model: "gpt-4".into(),
1530            input_tokens: 1000,
1531            output_tokens: 500,
1532            cost: 0.06,
1533        });
1534        trace.push_event(TraceEventKind::LlmCall {
1535            model: "gpt-4".into(),
1536            input_tokens: 2000,
1537            output_tokens: 1000,
1538            cost: 0.12,
1539        });
1540        trace.push_event(TraceEventKind::LlmCall {
1541            model: "claude-sonnet".into(),
1542            input_tokens: 500,
1543            output_tokens: 200,
1544            cost: 0.02,
1545        });
1546
1547        let refs = vec![&trace];
1548        let breakdown = Analytics::cost_breakdown(&refs);
1549
1550        assert!((breakdown.total_cost - 0.20).abs() < 1e-9);
1551        assert_eq!(breakdown.total_tokens, 1000 + 500 + 2000 + 1000 + 500 + 200);
1552
1553        let gpt4 = breakdown.by_model.get("gpt-4").unwrap();
1554        assert_eq!(gpt4.calls, 2);
1555        assert!((gpt4.total_cost - 0.18).abs() < 1e-9);
1556
1557        let claude = breakdown.by_model.get("claude-sonnet").unwrap();
1558        assert_eq!(claude.calls, 1);
1559    }
1560
1561    // 24
1562    #[test]
1563    fn test_analytics_detect_patterns() {
1564        let mut trace = make_trace("pattern test");
1565
1566        // Three denials for the same tool -> FrequentDenial
1567        for _ in 0..4 {
1568            trace.push_event(TraceEventKind::ToolDenied {
1569                tool: "rm_rf".into(),
1570                reason: "too dangerous".into(),
1571            });
1572        }
1573
1574        // Three approval requests -> ApprovalBottleneck
1575        for _ in 0..3 {
1576            trace.push_event(TraceEventKind::ApprovalRequested {
1577                tool: "deploy".into(),
1578                context: "production".into(),
1579            });
1580        }
1581
1582        // Three repeated errors -> RepeatedError
1583        for _ in 0..3 {
1584            trace.push_event(TraceEventKind::Error {
1585                message: "connection reset".into(),
1586            });
1587        }
1588
1589        let refs = vec![&trace];
1590        let patterns = Analytics::detect_patterns(&refs);
1591
1592        let kinds: Vec<_> = patterns.iter().map(|p| &p.kind).collect();
1593        assert!(
1594            kinds
1595                .iter()
1596                .any(|k| matches!(k, PatternKind::FrequentDenial))
1597        );
1598        assert!(
1599            kinds
1600                .iter()
1601                .any(|k| matches!(k, PatternKind::ApprovalBottleneck))
1602        );
1603        assert!(
1604            kinds
1605                .iter()
1606                .any(|k| matches!(k, PatternKind::RepeatedError))
1607        );
1608    }
1609
1610    // 25
1611    #[test]
1612    fn test_analytics_success_rate() {
1613        let mut t1 = make_trace("ok");
1614        t1.complete(true);
1615        let mut t2 = make_trace("ok2");
1616        t2.complete(true);
1617        let mut t3 = make_trace("fail");
1618        t3.complete(false);
1619        let t4 = make_trace("in-progress");
1620
1621        let refs = vec![&t1, &t2, &t3, &t4];
1622        let rate = Analytics::success_rate(&refs);
1623        // 2 successes out of 3 completed (t4 excluded from denominator).
1624        assert!((rate - 2.0 / 3.0).abs() < 1e-9);
1625    }
1626
1627    // 26
1628    #[test]
1629    fn test_analytics_avg_iterations() {
1630        let mut t1 = make_trace("a");
1631        t1.iterations = 5;
1632        let mut t2 = make_trace("b");
1633        t2.iterations = 10;
1634        let mut t3 = make_trace("c");
1635        t3.iterations = 0;
1636
1637        let refs = vec![&t1, &t2, &t3];
1638        let avg = Analytics::avg_iterations(&refs);
1639        assert!((avg - 5.0).abs() < 1e-9);
1640    }
1641
1642    // 27
1643    #[test]
1644    fn test_trace_event_kind_from_audit_event() {
1645        let requested = AuditEvent::ActionRequested {
1646            tool: "file_write".into(),
1647            risk_level: RiskLevel::Write,
1648            description: "writing config".into(),
1649        };
1650        let kind = TraceEventKind::from_audit_event(&requested);
1651        assert!(matches!(
1652            kind,
1653            TraceEventKind::ToolRequested {
1654                ref tool,
1655                risk_level: RiskLevel::Write,
1656                ..
1657            } if tool == "file_write"
1658        ));
1659
1660        let approved = AuditEvent::ActionApproved {
1661            tool: "file_write".into(),
1662        };
1663        let kind = TraceEventKind::from_audit_event(&approved);
1664        assert!(matches!(kind, TraceEventKind::ToolApproved { ref tool } if tool == "file_write"));
1665
1666        let denied = AuditEvent::ActionDenied {
1667            tool: "rm".into(),
1668            reason: "denied".into(),
1669        };
1670        let kind = TraceEventKind::from_audit_event(&denied);
1671        assert!(matches!(kind, TraceEventKind::ToolDenied { ref tool, .. } if tool == "rm"));
1672
1673        let executed = AuditEvent::ActionExecuted {
1674            tool: "grep".into(),
1675            success: true,
1676            duration_ms: 99,
1677        };
1678        let kind = TraceEventKind::from_audit_event(&executed);
1679        assert!(
1680            matches!(kind, TraceEventKind::ToolExecuted { ref tool, success: true, duration_ms: 99, .. } if tool == "grep")
1681        );
1682
1683        let approval_req = AuditEvent::ApprovalRequested {
1684            tool: "deploy".into(),
1685            context: "prod".into(),
1686        };
1687        let kind = TraceEventKind::from_audit_event(&approval_req);
1688        assert!(
1689            matches!(kind, TraceEventKind::ApprovalRequested { ref tool, ref context } if tool == "deploy" && context == "prod")
1690        );
1691
1692        let decision = AuditEvent::ApprovalDecision {
1693            tool: "deploy".into(),
1694            approved: false,
1695        };
1696        let kind = TraceEventKind::from_audit_event(&decision);
1697        assert!(
1698            matches!(kind, TraceEventKind::ApprovalDecision { ref tool, approved: false } if tool == "deploy")
1699        );
1700    }
1701
1702    // 28
1703    #[test]
1704    fn test_audit_query_builder() {
1705        let session = Uuid::new_v4();
1706        let task = Uuid::new_v4();
1707        let since = Utc::now() - Duration::hours(1);
1708        let until = Utc::now();
1709
1710        let q = AuditQuery::new()
1711            .for_session(session)
1712            .for_task(task)
1713            .for_tool("file_read")
1714            .min_risk(RiskLevel::Execute)
1715            .successful()
1716            .since(since)
1717            .until(until);
1718
1719        assert_eq!(q.session_id, Some(session));
1720        assert_eq!(q.task_id, Some(task));
1721        assert_eq!(q.tool_name.as_deref(), Some("file_read"));
1722        assert_eq!(q.risk_level_min, Some(RiskLevel::Execute));
1723        assert_eq!(q.success_only, Some(true));
1724        assert_eq!(q.since, Some(since));
1725        assert_eq!(q.until, Some(until));
1726
1727        // Also test the `failed()` variant.
1728        let q2 = AuditQuery::new().failed();
1729        assert_eq!(q2.success_only, Some(false));
1730    }
1731
1732    // 29
1733    #[test]
1734    fn test_execution_trace_serde_roundtrip() {
1735        let mut trace = make_trace_with_tools();
1736        trace.total_usage = TokenUsage {
1737            input_tokens: 1234,
1738            output_tokens: 567,
1739        };
1740        trace.total_cost = CostEstimate {
1741            input_cost: 0.01,
1742            output_cost: 0.03,
1743        };
1744        trace.complete(true);
1745
1746        let json = serde_json::to_string(&trace).unwrap();
1747        let restored: ExecutionTrace = serde_json::from_str(&json).unwrap();
1748
1749        assert_eq!(restored.trace_id, trace.trace_id);
1750        assert_eq!(restored.goal, trace.goal);
1751        assert_eq!(restored.events.len(), trace.events.len());
1752        assert_eq!(restored.total_usage.input_tokens, 1234);
1753        assert_eq!(restored.total_usage.output_tokens, 567);
1754        assert!((restored.total_cost.total() - 0.04).abs() < f64::EPSILON);
1755        assert_eq!(restored.success, Some(true));
1756    }
1757
1758    // --- Merkle chain integration tests ---
1759
1760    // 30
1761    #[test]
1762    fn test_audit_store_with_merkle_chain() {
1763        let store = AuditStore::with_merkle_chain();
1764        assert!(store.merkle_chain().is_some());
1765        assert!(store.is_empty());
1766    }
1767
1768    // 31
1769    #[test]
1770    fn test_audit_store_merkle_appends_on_add() {
1771        let mut store = AuditStore::with_merkle_chain();
1772        store.add_trace(make_trace("trace 1"));
1773        store.add_trace(make_trace("trace 2"));
1774
1775        let chain = store.merkle_chain().unwrap();
1776        assert_eq!(chain.len(), 2);
1777    }
1778
1779    // 32
1780    #[test]
1781    fn test_audit_store_verify_integrity_valid() {
1782        let mut store = AuditStore::with_merkle_chain();
1783        store.add_trace(make_trace("a"));
1784        store.add_trace(make_trace("b"));
1785        store.add_trace(make_trace("c"));
1786
1787        let result = store.verify_integrity().unwrap();
1788        assert!(result.is_valid);
1789        assert_eq!(result.checked_nodes, 3);
1790        assert!(result.first_invalid.is_none());
1791    }
1792
1793    // 33
1794    #[test]
1795    fn test_audit_store_verify_integrity_without_merkle() {
1796        let store = AuditStore::new();
1797        assert!(store.verify_integrity().is_none());
1798    }
1799
1800    // 34
1801    #[test]
1802    fn test_audit_store_merkle_root_hash_changes() {
1803        let mut store = AuditStore::with_merkle_chain();
1804        assert!(store.merkle_root_hash().is_none()); // empty chain
1805
1806        store.add_trace(make_trace("first"));
1807        let hash1 = store.merkle_root_hash().unwrap();
1808
1809        store.add_trace(make_trace("second"));
1810        let hash2 = store.merkle_root_hash().unwrap();
1811
1812        assert_ne!(hash1, hash2);
1813    }
1814
1815    // 35
1816    #[test]
1817    fn test_audit_store_load_with_merkle_rebuilds() {
1818        let dir = tempfile::tempdir().unwrap();
1819        let path = dir.path().join("audit_merkle.json");
1820
1821        // Save without merkle
1822        let mut store = AuditStore::new();
1823        store.add_trace(make_trace("alpha"));
1824        store.add_trace(make_trace("beta"));
1825        store.save(&path).unwrap();
1826
1827        // Load with merkle — should rebuild chain
1828        let loaded = AuditStore::load_with_merkle(&path).unwrap();
1829        assert!(loaded.merkle_chain().is_some());
1830        assert_eq!(loaded.merkle_chain().unwrap().len(), 2);
1831
1832        let result = loaded.verify_integrity().unwrap();
1833        assert!(result.is_valid);
1834    }
1835
1836    // 36
1837    #[test]
1838    fn test_audit_store_no_merkle_by_default() {
1839        let store = AuditStore::new();
1840        assert!(store.merkle_chain().is_none());
1841        assert!(store.merkle_root_hash().is_none());
1842    }
1843
1844    // 37
1845    #[test]
1846    fn test_audit_error_display() {
1847        let err = AuditError::SerializationFailed("bad json".into());
1848        assert_eq!(err.to_string(), "serialization failed: bad json");
1849
1850        let err = AuditError::IoError("file not found".into());
1851        assert_eq!(err.to_string(), "io error: file not found");
1852
1853        let err = AuditError::EmptyStore;
1854        assert_eq!(err.to_string(), "store is empty");
1855
1856        let id = Uuid::new_v4();
1857        let err = AuditError::TraceNotFound(id);
1858        assert_eq!(err.to_string(), format!("trace not found: {}", id));
1859    }
1860}