Skip to main content

victauri_plugin/
introspection.rs

1//! Backend introspection and chaos engineering types.
2//!
3//! These types support Victauri's intervention capabilities — features that exploit
4//! the plugin's position inside the Rust process to provide insights and control
5//! that browser-external tools like CDP cannot access.
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::RwLock;
9use std::sync::atomic::AtomicBool;
10use std::time::{Duration, Instant};
11
12use serde::Serialize;
13
14/// Per-command timing statistics aggregated from IPC invocations.
15#[derive(Debug, Clone, Serialize)]
16pub struct CommandTimingStats {
17    /// Command name.
18    pub command: String,
19    /// Number of invocations recorded.
20    pub count: u64,
21    /// Minimum execution time in milliseconds.
22    pub min_ms: f64,
23    /// Maximum execution time in milliseconds.
24    pub max_ms: f64,
25    /// Mean execution time in milliseconds.
26    pub avg_ms: f64,
27    /// 95th percentile execution time in milliseconds.
28    pub p95_ms: f64,
29    /// Total execution time across all invocations.
30    pub total_ms: f64,
31}
32
33/// Accumulated raw timing samples for a single command.
34#[derive(Debug, Default)]
35pub struct TimingSamples {
36    /// Duration of each invocation, in order.
37    pub samples: Vec<Duration>,
38}
39
40impl TimingSamples {
41    /// Add a timing sample.
42    pub fn record(&mut self, duration: Duration) {
43        self.samples.push(duration);
44    }
45
46    /// Compute aggregate statistics.
47    #[must_use]
48    pub fn stats(&self, command: &str) -> CommandTimingStats {
49        if self.samples.is_empty() {
50            return CommandTimingStats {
51                command: command.to_string(),
52                count: 0,
53                min_ms: 0.0,
54                max_ms: 0.0,
55                avg_ms: 0.0,
56                p95_ms: 0.0,
57                total_ms: 0.0,
58            };
59        }
60        let mut sorted: Vec<f64> = self
61            .samples
62            .iter()
63            .map(|d| d.as_secs_f64() * 1000.0)
64            .collect();
65        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
66
67        let count = sorted.len() as u64;
68        let total: f64 = sorted.iter().sum();
69        let min = sorted[0];
70        let max = sorted[sorted.len() - 1];
71        let avg = total / sorted.len() as f64;
72        let p95_idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
73        let p95 = sorted[p95_idx.min(sorted.len() - 1)];
74
75        CommandTimingStats {
76            command: command.to_string(),
77            count,
78            min_ms: (min * 100.0).round() / 100.0,
79            max_ms: (max * 100.0).round() / 100.0,
80            avg_ms: (avg * 100.0).round() / 100.0,
81            p95_ms: (p95 * 100.0).round() / 100.0,
82            total_ms: (total * 100.0).round() / 100.0,
83        }
84    }
85}
86
87/// Thread-safe store for per-command timing data.
88pub struct CommandTimings {
89    inner: RwLock<HashMap<String, TimingSamples>>,
90}
91
92impl CommandTimings {
93    /// Create a new empty store.
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            inner: RwLock::new(HashMap::new()),
98        }
99    }
100
101    /// Record a timing sample for a command.
102    pub fn record(&self, command: &str, duration: Duration) {
103        let mut map = self
104            .inner
105            .write()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        map.entry(command.to_string()).or_default().record(duration);
108    }
109
110    /// Get stats for all commands, sorted by total time descending.
111    #[must_use]
112    pub fn all_stats(&self) -> Vec<CommandTimingStats> {
113        let map = self
114            .inner
115            .read()
116            .unwrap_or_else(std::sync::PoisonError::into_inner);
117        let mut stats: Vec<CommandTimingStats> =
118            map.iter().map(|(name, s)| s.stats(name)).collect();
119        stats.sort_by(|a, b| {
120            b.total_ms
121                .partial_cmp(&a.total_ms)
122                .unwrap_or(std::cmp::Ordering::Equal)
123        });
124        stats
125    }
126
127    /// Get stats for a single command.
128    #[must_use]
129    pub fn stats_for(&self, command: &str) -> Option<CommandTimingStats> {
130        let map = self
131            .inner
132            .read()
133            .unwrap_or_else(std::sync::PoisonError::into_inner);
134        map.get(command).map(|s| s.stats(command))
135    }
136
137    /// Clear all timing data.
138    pub fn clear(&self) {
139        let mut map = self
140            .inner
141            .write()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        map.clear();
144    }
145}
146
147impl Default for CommandTimings {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153// ── Fault Injection ─────────────────────────────────────────────────────────
154
155/// The type of fault to inject into a command.
156#[derive(Debug, Clone, Serialize)]
157pub enum FaultType {
158    /// Add artificial latency before command execution.
159    Delay {
160        /// Delay in milliseconds.
161        delay_ms: u64,
162    },
163    /// Return an error without executing the command.
164    Error {
165        /// Error message to return.
166        message: String,
167    },
168    /// Drop the response entirely (return empty/timeout-like response).
169    Drop,
170    /// Execute normally but corrupt the response (randomize field values).
171    Corrupt,
172}
173
174/// Configuration for a single fault injection rule.
175#[derive(Debug, Clone, Serialize)]
176pub struct FaultConfig {
177    /// Target command name.
178    pub command: String,
179    /// Type of fault to inject.
180    pub fault_type: FaultType,
181    /// Number of times this fault has been triggered.
182    pub trigger_count: u64,
183    /// Maximum number of times to trigger (0 = unlimited).
184    pub max_triggers: u64,
185    /// When this fault was created.
186    #[serde(skip)]
187    pub created_at: Instant,
188}
189
190impl FaultConfig {
191    /// Check if this fault should still trigger (based on `max_triggers`).
192    #[must_use]
193    pub fn should_trigger(&self) -> bool {
194        self.max_triggers == 0 || self.trigger_count < self.max_triggers
195    }
196}
197
198/// Thread-safe registry of active fault injection rules.
199pub struct FaultRegistry {
200    inner: RwLock<HashMap<String, FaultConfig>>,
201}
202
203impl FaultRegistry {
204    /// Create an empty registry.
205    #[must_use]
206    pub fn new() -> Self {
207        Self {
208            inner: RwLock::new(HashMap::new()),
209        }
210    }
211
212    /// Register a fault for a command.
213    pub fn inject(&self, config: FaultConfig) {
214        let mut map = self
215            .inner
216            .write()
217            .unwrap_or_else(std::sync::PoisonError::into_inner);
218        map.insert(config.command.clone(), config);
219    }
220
221    /// Look up and optionally trigger a fault for a command.
222    /// Returns the fault type if one is active and should trigger.
223    pub fn check_and_trigger(&self, command: &str) -> Option<FaultType> {
224        let mut map = self
225            .inner
226            .write()
227            .unwrap_or_else(std::sync::PoisonError::into_inner);
228        if let Some(config) = map.get_mut(command)
229            && config.should_trigger()
230        {
231            config.trigger_count += 1;
232            return Some(config.fault_type.clone());
233        }
234        None
235    }
236
237    /// List all active fault rules.
238    #[must_use]
239    pub fn list(&self) -> Vec<FaultConfig> {
240        let map = self
241            .inner
242            .read()
243            .unwrap_or_else(std::sync::PoisonError::into_inner);
244        map.values().cloned().collect()
245    }
246
247    /// Remove a fault rule for a command.
248    pub fn clear(&self, command: &str) -> bool {
249        let mut map = self
250            .inner
251            .write()
252            .unwrap_or_else(std::sync::PoisonError::into_inner);
253        map.remove(command).is_some()
254    }
255
256    /// Remove all fault rules.
257    pub fn clear_all(&self) -> usize {
258        let mut map = self
259            .inner
260            .write()
261            .unwrap_or_else(std::sync::PoisonError::into_inner);
262        let count = map.len();
263        map.clear();
264        count
265    }
266}
267
268impl Default for FaultRegistry {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274// ── IPC Contract Testing ────────────────────────────────────────────────────
275
276/// Describes the shape of a JSON value for contract comparison.
277#[derive(Debug, Clone, Serialize, PartialEq)]
278pub enum JsonShape {
279    /// null
280    Null,
281    /// boolean
282    Bool,
283    /// number (integer or float)
284    Number,
285    /// string
286    String,
287    /// array with element shape (from first element, or Null if empty)
288    Array(Box<Self>),
289    /// object with field names and their shapes
290    Object(HashMap<String, Self>),
291}
292
293impl JsonShape {
294    /// Extract the shape of a JSON value.
295    #[must_use]
296    pub fn from_value(value: &serde_json::Value) -> Self {
297        match value {
298            serde_json::Value::Null => Self::Null,
299            serde_json::Value::Bool(_) => Self::Bool,
300            serde_json::Value::Number(_) => Self::Number,
301            serde_json::Value::String(_) => Self::String,
302            serde_json::Value::Array(arr) => {
303                let elem = arr.first().map_or(Self::Null, Self::from_value);
304                Self::Array(Box::new(elem))
305            }
306            serde_json::Value::Object(obj) => {
307                let fields: HashMap<String, Self> = obj
308                    .iter()
309                    .map(|(k, v)| (k.clone(), Self::from_value(v)))
310                    .collect();
311                Self::Object(fields)
312            }
313        }
314    }
315
316    /// Human-readable type name.
317    #[must_use]
318    pub fn type_name(&self) -> &'static str {
319        match self {
320            Self::Null => "null",
321            Self::Bool => "bool",
322            Self::Number => "number",
323            Self::String => "string",
324            Self::Array(_) => "array",
325            Self::Object(_) => "object",
326        }
327    }
328}
329
330/// A recorded contract baseline for a command's response.
331#[derive(Debug, Clone, Serialize)]
332pub struct ContractBaseline {
333    /// Command name.
334    pub command: String,
335    /// Arguments used when recording.
336    pub args: serde_json::Value,
337    /// Shape of the response.
338    pub shape: JsonShape,
339    /// Raw sample response (first 4KB).
340    pub sample: String,
341    /// When this baseline was recorded.
342    pub recorded_at: String,
343}
344
345/// Differences found when checking a contract against baseline.
346#[derive(Debug, Clone, Serialize)]
347pub struct ContractDrift {
348    /// Command name.
349    pub command: String,
350    /// Fields present in current but not in baseline.
351    pub new_fields: Vec<String>,
352    /// Fields present in baseline but not in current.
353    pub removed_fields: Vec<String>,
354    /// Fields whose type changed.
355    pub type_changes: Vec<TypeChange>,
356    /// Whether the overall shape matches.
357    pub shape_matches: bool,
358}
359
360/// A single field type change between baseline and current.
361#[derive(Debug, Clone, Serialize)]
362pub struct TypeChange {
363    /// Dot-separated field path.
364    pub path: String,
365    /// Type in the baseline.
366    pub baseline_type: String,
367    /// Type in the current response.
368    pub current_type: String,
369}
370
371/// Compare two JSON shapes and report differences.
372#[must_use]
373pub fn diff_shapes(baseline: &JsonShape, current: &JsonShape, prefix: &str) -> ContractDrift {
374    let mut new_fields = Vec::new();
375    let mut removed_fields = Vec::new();
376    let mut type_changes = Vec::new();
377
378    diff_shapes_inner(
379        baseline,
380        current,
381        prefix,
382        &mut new_fields,
383        &mut removed_fields,
384        &mut type_changes,
385    );
386
387    let shape_matches =
388        new_fields.is_empty() && removed_fields.is_empty() && type_changes.is_empty();
389    ContractDrift {
390        command: prefix.to_string(),
391        new_fields,
392        removed_fields,
393        type_changes,
394        shape_matches,
395    }
396}
397
398fn diff_shapes_inner(
399    baseline: &JsonShape,
400    current: &JsonShape,
401    prefix: &str,
402    new_fields: &mut Vec<String>,
403    removed_fields: &mut Vec<String>,
404    type_changes: &mut Vec<TypeChange>,
405) {
406    match (baseline, current) {
407        (JsonShape::Object(b_fields), JsonShape::Object(c_fields)) => {
408            for (key, b_shape) in b_fields {
409                let path = if prefix.is_empty() {
410                    key.clone()
411                } else {
412                    format!("{prefix}.{key}")
413                };
414                if let Some(c_shape) = c_fields.get(key) {
415                    diff_shapes_inner(
416                        b_shape,
417                        c_shape,
418                        &path,
419                        new_fields,
420                        removed_fields,
421                        type_changes,
422                    );
423                } else {
424                    removed_fields.push(path);
425                }
426            }
427            for key in c_fields.keys() {
428                if !b_fields.contains_key(key) {
429                    let path = if prefix.is_empty() {
430                        key.clone()
431                    } else {
432                        format!("{prefix}.{key}")
433                    };
434                    new_fields.push(path);
435                }
436            }
437        }
438        (JsonShape::Array(b_elem), JsonShape::Array(c_elem)) => {
439            let path = format!("{prefix}[]");
440            diff_shapes_inner(
441                b_elem,
442                c_elem,
443                &path,
444                new_fields,
445                removed_fields,
446                type_changes,
447            );
448        }
449        (b, c) if b.type_name() != c.type_name() => {
450            type_changes.push(TypeChange {
451                path: prefix.to_string(),
452                baseline_type: b.type_name().to_string(),
453                current_type: c.type_name().to_string(),
454            });
455        }
456        _ => {}
457    }
458}
459
460/// Thread-safe store for IPC contract baselines.
461pub struct ContractStore {
462    inner: RwLock<HashMap<String, ContractBaseline>>,
463}
464
465impl ContractStore {
466    /// Create an empty store.
467    #[must_use]
468    pub fn new() -> Self {
469        Self {
470            inner: RwLock::new(HashMap::new()),
471        }
472    }
473
474    /// Record a baseline for a command.
475    pub fn record(&self, baseline: ContractBaseline) {
476        let mut map = self
477            .inner
478            .write()
479            .unwrap_or_else(std::sync::PoisonError::into_inner);
480        map.insert(baseline.command.clone(), baseline);
481    }
482
483    /// Get the baseline for a command.
484    #[must_use]
485    pub fn get(&self, command: &str) -> Option<ContractBaseline> {
486        let map = self
487            .inner
488            .read()
489            .unwrap_or_else(std::sync::PoisonError::into_inner);
490        map.get(command).cloned()
491    }
492
493    /// Get all baselines.
494    #[must_use]
495    pub fn all(&self) -> Vec<ContractBaseline> {
496        let map = self
497            .inner
498            .read()
499            .unwrap_or_else(std::sync::PoisonError::into_inner);
500        map.values().cloned().collect()
501    }
502
503    /// Clear all baselines.
504    pub fn clear(&self) -> usize {
505        let mut map = self
506            .inner
507            .write()
508            .unwrap_or_else(std::sync::PoisonError::into_inner);
509        let count = map.len();
510        map.clear();
511        count
512    }
513}
514
515impl Default for ContractStore {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521// ── Startup Profiling ───────────────────────────────────────────────────────
522
523/// A single phase in the startup timeline.
524#[derive(Debug, Clone, Serialize)]
525pub struct StartupPhase {
526    /// Phase name.
527    pub name: String,
528    /// Duration of this phase in milliseconds.
529    pub duration_ms: f64,
530    /// Cumulative time from plugin init start.
531    pub cumulative_ms: f64,
532}
533
534/// Records timestamps at key phases during plugin initialization.
535pub struct StartupTimeline {
536    start: Instant,
537    phases: RwLock<Vec<(String, Instant)>>,
538}
539
540impl StartupTimeline {
541    /// Begin recording from now.
542    #[must_use]
543    pub fn new() -> Self {
544        Self {
545            start: Instant::now(),
546            phases: RwLock::new(Vec::new()),
547        }
548    }
549
550    /// Mark a phase as completed.
551    pub fn mark(&self, name: &str) {
552        let mut phases = self
553            .phases
554            .write()
555            .unwrap_or_else(std::sync::PoisonError::into_inner);
556        phases.push((name.to_string(), Instant::now()));
557    }
558
559    /// Get the timeline as a list of phases with durations.
560    #[must_use]
561    pub fn report(&self) -> Vec<StartupPhase> {
562        let phases = self
563            .phases
564            .read()
565            .unwrap_or_else(std::sync::PoisonError::into_inner);
566        let mut result = Vec::new();
567        let mut prev = self.start;
568
569        for (name, instant) in phases.iter() {
570            let duration = instant.duration_since(prev);
571            let cumulative = instant.duration_since(self.start);
572            result.push(StartupPhase {
573                name: name.clone(),
574                duration_ms: (duration.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
575                cumulative_ms: (cumulative.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
576            });
577            prev = *instant;
578        }
579        result
580    }
581
582    /// Total time from start to last recorded phase.
583    #[must_use]
584    pub fn total_ms(&self) -> f64 {
585        let phases = self
586            .phases
587            .read()
588            .unwrap_or_else(std::sync::PoisonError::into_inner);
589        if let Some((_, last)) = phases.last() {
590            (last.duration_since(self.start).as_secs_f64() * 1000.0 * 100.0).round() / 100.0
591        } else {
592            0.0
593        }
594    }
595}
596
597impl Default for StartupTimeline {
598    fn default() -> Self {
599        Self::new()
600    }
601}
602
603// ── Tauri Event Bus Monitor ─────────────────────────────────────────────
604
605/// A Tauri event captured from the application's native event bus.
606#[derive(Debug, Clone, Serialize)]
607pub struct CapturedTauriEvent {
608    /// Event name (e.g. "notification-added", `tauri://focus`).
609    pub name: String,
610    /// Serialized event payload.
611    pub payload: String,
612    /// ISO 8601 timestamp.
613    pub timestamp: String,
614}
615
616const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000;
617
618/// Thread-safe ring buffer for captured Tauri events.
619#[derive(Clone)]
620pub struct EventBusMonitor {
621    inner: std::sync::Arc<RwLock<VecDeque<CapturedTauriEvent>>>,
622    capacity: usize,
623}
624
625impl EventBusMonitor {
626    /// Create a new monitor with the given capacity.
627    #[must_use]
628    pub fn new(capacity: usize) -> Self {
629        Self {
630            inner: std::sync::Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
631            capacity,
632        }
633    }
634
635    /// Record a captured event.
636    pub fn push(&self, event: CapturedTauriEvent) {
637        let mut buf = self
638            .inner
639            .write()
640            .unwrap_or_else(std::sync::PoisonError::into_inner);
641        if buf.len() >= self.capacity {
642            buf.pop_front();
643        }
644        buf.push_back(event);
645    }
646
647    /// Get all captured events.
648    #[must_use]
649    pub fn events(&self) -> Vec<CapturedTauriEvent> {
650        self.inner
651            .read()
652            .unwrap_or_else(std::sync::PoisonError::into_inner)
653            .iter()
654            .cloned()
655            .collect()
656    }
657
658    /// Get the number of captured events.
659    #[must_use]
660    pub fn len(&self) -> usize {
661        self.inner
662            .read()
663            .unwrap_or_else(std::sync::PoisonError::into_inner)
664            .len()
665    }
666
667    /// Returns true if no events have been captured.
668    #[must_use]
669    pub fn is_empty(&self) -> bool {
670        self.len() == 0
671    }
672
673    /// Clear all captured events, returning how many were removed.
674    pub fn clear(&self) -> usize {
675        let mut buf = self
676            .inner
677            .write()
678            .unwrap_or_else(std::sync::PoisonError::into_inner);
679        let count = buf.len();
680        buf.clear();
681        count
682    }
683}
684
685impl Default for EventBusMonitor {
686    fn default() -> Self {
687        Self::new(DEFAULT_EVENT_BUS_CAPACITY)
688    }
689}
690
691// ── Internal Task Tracker ──────────────────────────────────────────────
692
693/// Info about a tracked async task spawned by Victauri.
694#[derive(Debug, Clone, Serialize)]
695pub struct TrackedTaskInfo {
696    /// Human-readable task name.
697    pub name: String,
698    /// ISO 8601 timestamp when the task was spawned.
699    pub spawned_at: String,
700    /// Whether the task has finished (completed or errored).
701    pub is_finished: bool,
702    /// How long the task has been running in seconds.
703    pub uptime_secs: u64,
704}
705
706struct TrackedTaskEntry {
707    name: String,
708    spawned_at: Instant,
709    spawned_at_wall: String,
710    finished: std::sync::Arc<AtomicBool>,
711}
712
713/// Tracks Victauri's own spawned async tasks for observability.
714pub struct TaskTracker {
715    tasks: RwLock<Vec<TrackedTaskEntry>>,
716}
717
718impl TaskTracker {
719    /// Create a new empty tracker.
720    #[must_use]
721    pub fn new() -> Self {
722        Self {
723            tasks: RwLock::new(Vec::new()),
724        }
725    }
726
727    /// Register a new task. Returns a flag that the task should set to `true` when it finishes.
728    pub fn track(&self, name: &str) -> std::sync::Arc<AtomicBool> {
729        let finished = std::sync::Arc::new(AtomicBool::new(false));
730        let entry = TrackedTaskEntry {
731            name: name.to_string(),
732            spawned_at: Instant::now(),
733            spawned_at_wall: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
734            finished: finished.clone(),
735        };
736        self.tasks
737            .write()
738            .unwrap_or_else(std::sync::PoisonError::into_inner)
739            .push(entry);
740        finished
741    }
742
743    /// List all tracked tasks with their current status.
744    #[must_use]
745    pub fn list(&self) -> Vec<TrackedTaskInfo> {
746        let tasks = self
747            .tasks
748            .read()
749            .unwrap_or_else(std::sync::PoisonError::into_inner);
750        tasks
751            .iter()
752            .map(|t| TrackedTaskInfo {
753                name: t.name.clone(),
754                spawned_at: t.spawned_at_wall.clone(),
755                is_finished: t.finished.load(std::sync::atomic::Ordering::Relaxed),
756                uptime_secs: t.spawned_at.elapsed().as_secs(),
757            })
758            .collect()
759    }
760
761    /// Count of active (non-finished) tasks.
762    #[must_use]
763    pub fn active_count(&self) -> usize {
764        let tasks = self
765            .tasks
766            .read()
767            .unwrap_or_else(std::sync::PoisonError::into_inner);
768        tasks
769            .iter()
770            .filter(|t| !t.finished.load(std::sync::atomic::Ordering::Relaxed))
771            .count()
772    }
773}
774
775impl Default for TaskTracker {
776    fn default() -> Self {
777        Self::new()
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn event_bus_push_and_read() {
787        let bus = EventBusMonitor::new(3);
788        assert!(bus.is_empty());
789        bus.push(CapturedTauriEvent {
790            name: "test".to_string(),
791            payload: "{}".to_string(),
792            timestamp: "2026-01-01T00:00:00Z".to_string(),
793        });
794        assert_eq!(bus.len(), 1);
795        assert_eq!(bus.events()[0].name, "test");
796    }
797
798    #[test]
799    fn event_bus_ring_buffer_eviction() {
800        let bus = EventBusMonitor::new(2);
801        for i in 0..5 {
802            bus.push(CapturedTauriEvent {
803                name: format!("event_{i}"),
804                payload: String::new(),
805                timestamp: String::new(),
806            });
807        }
808        assert_eq!(bus.len(), 2);
809        assert_eq!(bus.events()[0].name, "event_3");
810        assert_eq!(bus.events()[1].name, "event_4");
811    }
812
813    #[test]
814    fn event_bus_clear() {
815        let bus = EventBusMonitor::new(10);
816        bus.push(CapturedTauriEvent {
817            name: "a".to_string(),
818            payload: String::new(),
819            timestamp: String::new(),
820        });
821        assert_eq!(bus.clear(), 1);
822        assert!(bus.is_empty());
823    }
824
825    #[test]
826    fn task_tracker_lifecycle() {
827        let tracker = TaskTracker::new();
828        let flag = tracker.track("mcp_server");
829        let tasks = tracker.list();
830        assert_eq!(tasks.len(), 1);
831        assert_eq!(tasks[0].name, "mcp_server");
832        assert!(!tasks[0].is_finished);
833        assert_eq!(tracker.active_count(), 1);
834
835        flag.store(true, std::sync::atomic::Ordering::Relaxed);
836        let tasks = tracker.list();
837        assert!(tasks[0].is_finished);
838        assert_eq!(tracker.active_count(), 0);
839    }
840
841    #[test]
842    fn timing_samples_basic() {
843        let mut samples = TimingSamples::default();
844        samples.record(Duration::from_millis(10));
845        samples.record(Duration::from_millis(20));
846        samples.record(Duration::from_millis(30));
847        let stats = samples.stats("test_cmd");
848        assert_eq!(stats.count, 3);
849        assert!((stats.min_ms - 10.0).abs() < 1.0);
850        assert!((stats.max_ms - 30.0).abs() < 1.0);
851        assert!((stats.avg_ms - 20.0).abs() < 1.0);
852    }
853
854    #[test]
855    fn timing_samples_empty() {
856        let samples = TimingSamples::default();
857        let stats = samples.stats("empty");
858        assert_eq!(stats.count, 0);
859        assert_eq!(stats.min_ms, 0.0);
860    }
861
862    #[test]
863    fn command_timings_thread_safe() {
864        let timings = CommandTimings::new();
865        timings.record("cmd_a", Duration::from_millis(5));
866        timings.record("cmd_a", Duration::from_millis(15));
867        timings.record("cmd_b", Duration::from_millis(100));
868
869        let all = timings.all_stats();
870        assert_eq!(all.len(), 2);
871        assert_eq!(all[0].command, "cmd_b");
872
873        let a = timings.stats_for("cmd_a").unwrap();
874        assert_eq!(a.count, 2);
875    }
876
877    #[test]
878    fn fault_registry_lifecycle() {
879        let registry = FaultRegistry::new();
880        registry.inject(FaultConfig {
881            command: "slow_cmd".to_string(),
882            fault_type: FaultType::Delay { delay_ms: 500 },
883            trigger_count: 0,
884            max_triggers: 2,
885            created_at: Instant::now(),
886        });
887
888        assert!(registry.check_and_trigger("slow_cmd").is_some());
889        assert!(registry.check_and_trigger("slow_cmd").is_some());
890        assert!(registry.check_and_trigger("slow_cmd").is_none());
891
892        assert_eq!(registry.list().len(), 1);
893        assert!(registry.clear("slow_cmd"));
894        assert_eq!(registry.list().len(), 0);
895    }
896
897    #[test]
898    fn fault_registry_unlimited() {
899        let registry = FaultRegistry::new();
900        registry.inject(FaultConfig {
901            command: "always_fail".to_string(),
902            fault_type: FaultType::Error {
903                message: "injected".to_string(),
904            },
905            trigger_count: 0,
906            max_triggers: 0,
907            created_at: Instant::now(),
908        });
909
910        for _ in 0..100 {
911            assert!(registry.check_and_trigger("always_fail").is_some());
912        }
913    }
914
915    #[test]
916    fn json_shape_extraction() {
917        let value = serde_json::json!({
918            "name": "test",
919            "count": 42,
920            "active": true,
921            "items": [{"id": 1}],
922            "meta": null
923        });
924        let shape = JsonShape::from_value(&value);
925        match &shape {
926            JsonShape::Object(fields) => {
927                assert_eq!(fields.len(), 5);
928                assert_eq!(*fields.get("name").unwrap(), JsonShape::String);
929                assert_eq!(*fields.get("count").unwrap(), JsonShape::Number);
930                assert_eq!(*fields.get("active").unwrap(), JsonShape::Bool);
931                assert_eq!(*fields.get("meta").unwrap(), JsonShape::Null);
932            }
933            _ => panic!("expected object"),
934        }
935    }
936
937    #[test]
938    fn contract_diff_detects_changes() {
939        let baseline = serde_json::json!({"name": "old", "count": 1});
940        let current = serde_json::json!({"name": "new", "count": "not_a_number", "extra": true});
941
942        let b_shape = JsonShape::from_value(&baseline);
943        let c_shape = JsonShape::from_value(&current);
944        let drift = diff_shapes(&b_shape, &c_shape, "test_cmd");
945
946        assert!(!drift.shape_matches);
947        assert_eq!(drift.new_fields, vec!["test_cmd.extra"]);
948        assert_eq!(drift.type_changes.len(), 1);
949        assert_eq!(drift.type_changes[0].path, "test_cmd.count");
950    }
951
952    #[test]
953    fn contract_store_crud() {
954        let store = ContractStore::new();
955        let baseline = ContractBaseline {
956            command: "get_user".to_string(),
957            args: serde_json::json!({}),
958            shape: JsonShape::Object(HashMap::new()),
959            sample: "{}".to_string(),
960            recorded_at: "2026-05-26".to_string(),
961        };
962        store.record(baseline);
963        assert!(store.get("get_user").is_some());
964        assert_eq!(store.all().len(), 1);
965        assert_eq!(store.clear(), 1);
966        assert!(store.get("get_user").is_none());
967    }
968
969    #[test]
970    fn startup_timeline_records_phases() {
971        let timeline = StartupTimeline::new();
972        std::thread::sleep(Duration::from_millis(5));
973        timeline.mark("phase_1");
974        std::thread::sleep(Duration::from_millis(5));
975        timeline.mark("phase_2");
976
977        let report = timeline.report();
978        assert_eq!(report.len(), 2);
979        assert_eq!(report[0].name, "phase_1");
980        assert!(report[1].cumulative_ms >= report[0].cumulative_ms);
981        assert!(timeline.total_ms() > 0.0);
982    }
983}