Skip to main content

victauri_core/
verification.rs

1//! Cross-boundary verification, ghost command detection, IPC integrity
2//! checks, and semantic test assertions.
3
4use std::fmt;
5
6use crate::event::{EventLog, IpcResult};
7use crate::registry::CommandRegistry;
8use crate::types::{Divergence, DivergenceSeverity, VerificationResult};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Tolerance for floating-point comparisons in severity classification.
13const FLOAT_EPSILON: f64 = 1e-9;
14
15// ── Cross-boundary state verification ───────────────────────────────────────
16
17/// Compares frontend and backend state trees, returning all divergences found.
18///
19/// ```
20/// use victauri_core::verification::verify_state;
21/// use serde_json::json;
22///
23/// let result = verify_state(json!({"title": "App"}), json!({"title": "App"}));
24/// assert!(result.passed);
25/// assert!(result.divergences.is_empty());
26/// ```
27#[must_use]
28pub fn verify_state(
29    frontend_state: serde_json::Value,
30    backend_state: serde_json::Value,
31) -> VerificationResult {
32    let mut divergences = Vec::new();
33    compare_values("", &frontend_state, &backend_state, &mut divergences, 0);
34    let passed = divergences.is_empty();
35    VerificationResult {
36        passed,
37        frontend_state,
38        backend_state,
39        divergences,
40    }
41}
42
43/// Maximum nesting depth `compare_values` will recurse through. `verify_state`
44/// takes a fully caller-controlled `backend_state`, so an unbounded recursion
45/// here is a stack-overflow (denial of service) on the in-process app. A depth this large is far
46/// beyond any legitimate UI/backend state shape; deeper structures are reported
47/// as a single bounded divergence instead of being walked.
48const MAX_COMPARE_DEPTH: usize = 128;
49
50fn compare_values(
51    path: &str,
52    frontend: &serde_json::Value,
53    backend: &serde_json::Value,
54    divergences: &mut Vec<Divergence>,
55    depth: usize,
56) {
57    if frontend == backend {
58        return;
59    }
60
61    if depth >= MAX_COMPARE_DEPTH {
62        divergences.push(Divergence {
63            path: if path.is_empty() {
64                "$".to_string()
65            } else {
66                path.to_string()
67            },
68            frontend_value: serde_json::Value::String(format!(
69                "<max compare depth {MAX_COMPARE_DEPTH} exceeded>"
70            )),
71            backend_value: serde_json::Value::Null,
72            severity: DivergenceSeverity::Warning,
73        });
74        return;
75    }
76
77    match (frontend, backend) {
78        (serde_json::Value::Object(f_map), serde_json::Value::Object(b_map)) => {
79            for (key, f_val) in f_map {
80                let child_path = if path.is_empty() {
81                    key.clone()
82                } else {
83                    format!("{path}.{key}")
84                };
85                match b_map.get(key) {
86                    Some(b_val) => {
87                        compare_values(&child_path, f_val, b_val, divergences, depth + 1);
88                    }
89                    None => divergences.push(Divergence {
90                        path: child_path,
91                        frontend_value: f_val.clone(),
92                        backend_value: serde_json::Value::Null,
93                        severity: DivergenceSeverity::Warning,
94                    }),
95                }
96            }
97            for key in b_map.keys() {
98                if !f_map.contains_key(key) {
99                    let child_path = if path.is_empty() {
100                        key.clone()
101                    } else {
102                        format!("{path}.{key}")
103                    };
104                    divergences.push(Divergence {
105                        path: child_path,
106                        frontend_value: serde_json::Value::Null,
107                        backend_value: b_map[key].clone(),
108                        severity: DivergenceSeverity::Warning,
109                    });
110                }
111            }
112        }
113        (serde_json::Value::Array(f_arr), serde_json::Value::Array(b_arr)) => {
114            let max_len = f_arr.len().max(b_arr.len());
115            for i in 0..max_len {
116                let child_path = if path.is_empty() {
117                    format!("[{i}]")
118                } else {
119                    format!("{path}[{i}]")
120                };
121                match (f_arr.get(i), b_arr.get(i)) {
122                    (Some(f_val), Some(b_val)) => {
123                        compare_values(&child_path, f_val, b_val, divergences, depth + 1);
124                    }
125                    (Some(f_val), None) => divergences.push(Divergence {
126                        path: child_path,
127                        frontend_value: f_val.clone(),
128                        backend_value: serde_json::Value::Null,
129                        severity: DivergenceSeverity::Warning,
130                    }),
131                    (None, Some(b_val)) => divergences.push(Divergence {
132                        path: child_path,
133                        frontend_value: serde_json::Value::Null,
134                        backend_value: b_val.clone(),
135                        severity: DivergenceSeverity::Warning,
136                    }),
137                    (None, None) => {}
138                }
139            }
140        }
141        _ => {
142            let severity = classify_severity(frontend, backend);
143            divergences.push(Divergence {
144                path: if path.is_empty() {
145                    "$".to_string()
146                } else {
147                    path.to_string()
148                },
149                frontend_value: frontend.clone(),
150                backend_value: backend.clone(),
151                severity,
152            });
153        }
154    }
155}
156
157fn classify_severity(
158    frontend: &serde_json::Value,
159    backend: &serde_json::Value,
160) -> DivergenceSeverity {
161    match (frontend, backend) {
162        (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => DivergenceSeverity::Warning,
163        (serde_json::Value::Number(f), serde_json::Value::Number(b)) => {
164            match (f.as_f64(), b.as_f64()) {
165                (Some(fv), Some(bv)) if (fv - bv).abs() < FLOAT_EPSILON => DivergenceSeverity::Info,
166                _ => DivergenceSeverity::Error,
167            }
168        }
169        _ => DivergenceSeverity::Error,
170    }
171}
172
173// ── Ghost command detection ─────────────────────────────────────────────────
174
175/// Report of commands that exist on only one side of the IPC boundary.
176///
177/// The two sides are kept in separate fields because they mean very different
178/// things: `frontend_only` is the set of *true ghost commands* (invoked from the
179/// frontend but with no backend handler — likely bugs or typos), while
180/// `registry_only` is purely informational (registered handlers that were never
181/// invoked during the observation window).
182#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
183pub struct GhostCommandReport {
184    /// True ghost commands: invoked from the frontend but not registered in the backend.
185    pub frontend_only: Vec<GhostCommand>,
186    /// Informational: registered in the backend but never invoked from the frontend.
187    pub registry_only: Vec<GhostCommand>,
188    /// Total unique commands observed from the frontend.
189    pub total_frontend_commands: usize,
190    /// Total commands registered in the backend registry.
191    pub total_registry_commands: usize,
192}
193
194/// A command that exists on only one side of the frontend/backend boundary.
195#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
196pub struct GhostCommand {
197    /// Command name as invoked or registered.
198    pub name: String,
199    /// Which side the command was found on.
200    pub source: GhostSource,
201    /// Optional description, if available from the registry.
202    pub description: Option<String>,
203}
204
205/// Indicates which side of the IPC boundary a ghost command was found on.
206#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub enum GhostSource {
209    /// Command invoked from the frontend but not registered in the backend.
210    FrontendOnly,
211    /// Command registered in the backend but never invoked from the frontend.
212    RegistryOnly,
213}
214
215impl fmt::Display for GhostSource {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        let s = match self {
218            Self::FrontendOnly => "frontend-only",
219            Self::RegistryOnly => "registry-only",
220        };
221        f.write_str(s)
222    }
223}
224
225impl fmt::Display for GhostCommand {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        write!(f, "{} ({})", self.name, self.source)
228    }
229}
230
231/// # Examples
232///
233/// ```
234/// use victauri_core::{GhostCommandReport, GhostCommand, GhostSource};
235///
236/// let report = GhostCommandReport {
237///     frontend_only: vec![
238///         GhostCommand {
239///             name: "delete".to_string(),
240///             source: GhostSource::FrontendOnly,
241///             description: None,
242///         },
243///     ],
244///     registry_only: vec![],
245///     total_frontend_commands: 3,
246///     total_registry_commands: 2,
247/// };
248/// assert_eq!(
249///     report.to_string(),
250///     "1 ghost command(s) (3 frontend, 2 registry)"
251/// );
252/// ```
253impl fmt::Display for GhostCommandReport {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        let n = self.frontend_only.len();
256        write!(
257            f,
258            "{n} ghost command(s) ({} frontend, {} registry)",
259            self.total_frontend_commands, self.total_registry_commands
260        )
261    }
262}
263
264/// # Examples
265///
266/// ```
267/// use victauri_core::verification::IpcIntegrityReport;
268///
269/// let healthy = IpcIntegrityReport {
270///     total_calls: 10,
271///     completed: 10,
272///     pending: 0,
273///     errored: 0,
274///     stale_calls: vec![],
275///     error_calls: vec![],
276///     healthy: true,
277/// };
278/// assert_eq!(
279///     healthy.to_string(),
280///     "IPC healthy: 10/10 completed"
281/// );
282///
283/// let unhealthy = IpcIntegrityReport {
284///     total_calls: 10,
285///     completed: 7,
286///     pending: 2,
287///     errored: 1,
288///     stale_calls: vec![],
289///     error_calls: vec![],
290///     healthy: false,
291/// };
292/// assert_eq!(
293///     unhealthy.to_string(),
294///     "IPC unhealthy: 0 stale, 1 errored of 10 calls"
295/// );
296/// ```
297impl fmt::Display for IpcIntegrityReport {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        if self.healthy {
300            write!(
301                f,
302                "IPC healthy: {}/{} completed",
303                self.completed, self.total_calls
304            )
305        } else {
306            let stale = self.stale_calls.len();
307            write!(
308                f,
309                "IPC unhealthy: {stale} stale, {} errored of {} calls",
310                self.errored, self.total_calls
311            )
312        }
313    }
314}
315
316/// Detects commands that exist on only one side of the IPC boundary (frontend vs registry).
317///
318/// # Examples
319///
320/// ```
321/// use victauri_core::verification::detect_ghost_commands;
322/// use victauri_core::{CommandRegistry, CommandInfo};
323///
324/// let registry = CommandRegistry::new();
325/// registry.register(CommandInfo::new("save").with_description("Save data"));
326///
327/// let frontend_cmds = vec!["save".to_string(), "delete".to_string()];
328/// let report = detect_ghost_commands(&frontend_cmds, &registry);
329/// assert_eq!(report.frontend_only.len(), 1); // "delete" is frontend-only (a true ghost)
330/// ```
331#[must_use]
332pub fn detect_ghost_commands(
333    frontend_commands: &[String],
334    registry: &CommandRegistry,
335) -> GhostCommandReport {
336    let registry_list = registry.list();
337    let registry_names: std::collections::HashSet<&str> =
338        registry_list.iter().map(|c| c.name.as_str()).collect();
339    let frontend_set: std::collections::HashSet<&str> = frontend_commands
340        .iter()
341        .map(std::string::String::as_str)
342        .collect();
343
344    let mut frontend_only = Vec::new();
345    let mut registry_only = Vec::new();
346
347    for name in &frontend_set {
348        if !registry_names.contains(name) {
349            frontend_only.push(GhostCommand {
350                name: name.to_string(),
351                source: GhostSource::FrontendOnly,
352                description: Some(
353                    "Command invoked from frontend but not registered in backend".to_string(),
354                ),
355            });
356        }
357    }
358
359    for cmd in &registry_list {
360        if !frontend_set.contains(cmd.name.as_str()) {
361            registry_only.push(GhostCommand {
362                name: cmd.name.clone(),
363                source: GhostSource::RegistryOnly,
364                description: cmd.description.clone(),
365            });
366        }
367    }
368
369    frontend_only.sort_by(|a, b| a.name.cmp(&b.name));
370    registry_only.sort_by(|a, b| a.name.cmp(&b.name));
371
372    GhostCommandReport {
373        frontend_only,
374        registry_only,
375        total_frontend_commands: frontend_set.len(),
376        total_registry_commands: registry_list.len(),
377    }
378}
379
380// ── IPC round-trip integrity ────────────────────────────────────────────────
381
382/// Summary of IPC round-trip health: completed, pending, errored, and stale calls.
383#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
384pub struct IpcIntegrityReport {
385    /// Total number of IPC calls analyzed.
386    pub total_calls: usize,
387    /// Calls that completed successfully.
388    pub completed: usize,
389    /// Calls still awaiting a response.
390    pub pending: usize,
391    /// Calls that returned an error.
392    pub errored: usize,
393    /// Pending calls that have exceeded the staleness threshold.
394    pub stale_calls: Vec<StaleCall>,
395    /// Calls that resulted in errors.
396    pub error_calls: Vec<ErrorCall>,
397    /// True if there are no stale or errored calls.
398    pub healthy: bool,
399}
400
401/// An IPC call that has been pending longer than the staleness threshold.
402#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
403pub struct StaleCall {
404    /// Unique call identifier.
405    pub id: String,
406    /// Name of the invoked command.
407    pub command: String,
408    /// When the call was initiated.
409    pub timestamp: DateTime<Utc>,
410    /// How long the call has been pending, in milliseconds.
411    pub age_ms: i64,
412    /// Webview that initiated the call.
413    pub webview_label: String,
414}
415
416/// An IPC call that returned an error result.
417#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
418pub struct ErrorCall {
419    /// Unique call identifier.
420    pub id: String,
421    /// Name of the invoked command.
422    pub command: String,
423    /// When the call was initiated.
424    pub timestamp: DateTime<Utc>,
425    /// Error message returned by the backend.
426    pub error: String,
427    /// Webview that initiated the call.
428    pub webview_label: String,
429}
430
431/// Analyzes the event log for IPC health, flagging stale and errored calls.
432///
433/// # Examples
434///
435/// ```
436/// use victauri_core::verification::check_ipc_integrity;
437/// use victauri_core::EventLog;
438///
439/// let log = EventLog::new(100);
440/// let report = check_ipc_integrity(&log, 5000);
441/// assert!(report.healthy);
442/// assert_eq!(report.total_calls, 0);
443/// ```
444#[must_use]
445pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
446    let now = Utc::now();
447    let calls = event_log.ipc_calls();
448    let total_calls = calls.len();
449    let mut completed = 0usize;
450    let mut pending = 0usize;
451    let mut errored = 0usize;
452    let mut stale_calls = Vec::new();
453    let mut error_calls = Vec::new();
454
455    for call in &calls {
456        match &call.result {
457            IpcResult::Ok(_) => completed += 1,
458            IpcResult::Pending => {
459                pending += 1;
460                let age_ms = (now - call.timestamp).num_milliseconds();
461                if age_ms >= stale_threshold_ms {
462                    stale_calls.push(StaleCall {
463                        id: call.id.clone(),
464                        command: call.command.clone(),
465                        timestamp: call.timestamp,
466                        age_ms,
467                        webview_label: call.webview_label.clone(),
468                    });
469                }
470            }
471            IpcResult::Err(e) => {
472                errored += 1;
473                error_calls.push(ErrorCall {
474                    id: call.id.clone(),
475                    command: call.command.clone(),
476                    timestamp: call.timestamp,
477                    error: e.clone(),
478                    webview_label: call.webview_label.clone(),
479                });
480            }
481        }
482    }
483
484    let healthy = stale_calls.is_empty() && errored == 0;
485
486    IpcIntegrityReport {
487        total_calls,
488        completed,
489        pending,
490        errored,
491        stale_calls,
492        error_calls,
493        healthy,
494    }
495}
496
497// ── Semantic test assertions ────────────────────────────────────────────────
498
499/// Recognized assertion condition operators for [`evaluate_assertion`].
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
502#[serde(rename_all = "snake_case")]
503#[non_exhaustive]
504pub enum AssertionCondition {
505    /// Actual value must equal the expected value.
506    Equals,
507    /// Actual value must not equal the expected value.
508    NotEquals,
509    /// String must contain substring, or array must contain element.
510    Contains,
511    /// Numeric actual must be greater than expected.
512    GreaterThan,
513    /// Numeric actual must be less than expected.
514    LessThan,
515    /// Value must be truthy (non-null, non-false, non-empty, non-zero).
516    Truthy,
517    /// Value must be falsy (null, false, empty string, zero).
518    Falsy,
519    /// Value must not be null.
520    Exists,
521    /// Value must be of the specified JSON type (string, number, boolean, array, object, null).
522    TypeIs,
523}
524
525impl std::str::FromStr for AssertionCondition {
526    type Err = crate::error::VictauriError;
527
528    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
529        match s {
530            "equals" => Ok(Self::Equals),
531            "not_equals" => Ok(Self::NotEquals),
532            "contains" => Ok(Self::Contains),
533            "greater_than" => Ok(Self::GreaterThan),
534            "less_than" => Ok(Self::LessThan),
535            "truthy" => Ok(Self::Truthy),
536            "falsy" => Ok(Self::Falsy),
537            "exists" => Ok(Self::Exists),
538            "type_is" => Ok(Self::TypeIs),
539            other => Err(crate::error::VictauriError::UnknownCondition {
540                condition: other.to_string(),
541            }),
542        }
543    }
544}
545
546impl std::fmt::Display for AssertionCondition {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        let s = match self {
549            Self::Equals => "equals",
550            Self::NotEquals => "not_equals",
551            Self::Contains => "contains",
552            Self::GreaterThan => "greater_than",
553            Self::LessThan => "less_than",
554            Self::Truthy => "truthy",
555            Self::Falsy => "falsy",
556            Self::Exists => "exists",
557            Self::TypeIs => "type_is",
558        };
559        f.write_str(s)
560    }
561}
562
563/// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy").
564#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
565pub struct SemanticAssertion {
566    /// Human-readable label describing what is being asserted.
567    pub label: String,
568    /// Condition operator to evaluate.
569    pub condition: AssertionCondition,
570    /// Expected value to compare against (interpretation depends on condition).
571    pub expected: serde_json::Value,
572}
573
574/// Outcome of evaluating a semantic assertion against an actual value.
575#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
576pub struct AssertionResult {
577    /// Label from the original assertion.
578    pub label: String,
579    /// Whether the assertion passed.
580    pub passed: bool,
581    /// The actual value that was evaluated.
582    pub actual: serde_json::Value,
583    /// The expected value from the assertion.
584    pub expected: serde_json::Value,
585    /// Failure message explaining why the assertion failed, if it did.
586    pub message: Option<String>,
587}
588
589fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
590    v.as_f64()
591        .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
592}
593
594fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
595    if a == b {
596        return true;
597    }
598    if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
599        (na - nb).abs() < f64::EPSILON
600    } else {
601        let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
602        let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
603        sa == sb
604    }
605}
606
607/// Evaluates a semantic assertion against an actual runtime value.
608///
609/// ```
610/// use victauri_core::verification::{evaluate_assertion, AssertionCondition, SemanticAssertion};
611/// use serde_json::json;
612///
613/// let assertion = SemanticAssertion {
614///     label: "check count".to_string(),
615///     condition: AssertionCondition::Equals,
616///     expected: json!(42),
617/// };
618/// let result = evaluate_assertion(json!(42), &assertion);
619/// assert!(result.passed);
620/// ```
621#[must_use]
622pub fn evaluate_assertion(
623    actual: serde_json::Value,
624    assertion: &SemanticAssertion,
625) -> AssertionResult {
626    let passed = match assertion.condition {
627        AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
628        AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
629        AssertionCondition::Contains => match (&actual, &assertion.expected) {
630            (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
631            (serde_json::Value::Array(arr), val) => arr.contains(val),
632            _ => false,
633        },
634        AssertionCondition::GreaterThan => {
635            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
636                (Some(a), Some(e)) => a > e,
637                _ => false,
638            }
639        }
640        AssertionCondition::LessThan => {
641            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
642                (Some(a), Some(e)) => a < e,
643                _ => false,
644            }
645        }
646        AssertionCondition::Truthy => match &actual {
647            serde_json::Value::Null | serde_json::Value::Bool(false) => false,
648            serde_json::Value::String(s) => !s.is_empty(),
649            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
650            _ => true,
651        },
652        AssertionCondition::Falsy => match &actual {
653            serde_json::Value::Null | serde_json::Value::Bool(false) => true,
654            serde_json::Value::String(s) => s.is_empty(),
655            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
656            _ => false,
657        },
658        AssertionCondition::Exists => actual != serde_json::Value::Null,
659        AssertionCondition::TypeIs => {
660            let type_name = assertion.expected.as_str().unwrap_or("");
661            match type_name {
662                "string" => actual.is_string(),
663                "number" => actual.is_number(),
664                "boolean" => actual.is_boolean(),
665                "array" => actual.is_array(),
666                "object" => actual.is_object(),
667                "null" => actual.is_null(),
668                _ => false,
669            }
670        }
671    };
672
673    let message = if passed {
674        None
675    } else {
676        Some(format!(
677            "Assertion '{}' failed: expected {} {:?}, got {:?}",
678            assertion.label, assertion.condition, assertion.expected, actual
679        ))
680    };
681
682    AssertionResult {
683        label: assertion.label.clone(),
684        passed,
685        actual,
686        expected: assertion.expected.clone(),
687        message,
688    }
689}