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 ghost commands -- commands that exist on only one side of the IPC boundary.
176#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
177pub struct GhostCommandReport {
178    /// Commands found on only the frontend or only the backend.
179    pub ghost_commands: Vec<GhostCommand>,
180    /// Total unique commands observed from the frontend.
181    pub total_frontend_commands: usize,
182    /// Total commands registered in the backend registry.
183    pub total_registry_commands: usize,
184}
185
186/// A command that exists on only one side of the frontend/backend boundary.
187#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
188pub struct GhostCommand {
189    /// Command name as invoked or registered.
190    pub name: String,
191    /// Which side the command was found on.
192    pub source: GhostSource,
193    /// Optional description, if available from the registry.
194    pub description: Option<String>,
195}
196
197/// Indicates which side of the IPC boundary a ghost command was found on.
198#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
199#[non_exhaustive]
200pub enum GhostSource {
201    /// Command invoked from the frontend but not registered in the backend.
202    FrontendOnly,
203    /// Command registered in the backend but never invoked from the frontend.
204    RegistryOnly,
205}
206
207impl fmt::Display for GhostSource {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        let s = match self {
210            Self::FrontendOnly => "frontend-only",
211            Self::RegistryOnly => "registry-only",
212        };
213        f.write_str(s)
214    }
215}
216
217impl fmt::Display for GhostCommand {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "{} ({})", self.name, self.source)
220    }
221}
222
223/// # Examples
224///
225/// ```
226/// use victauri_core::{GhostCommandReport, GhostCommand, GhostSource};
227///
228/// let report = GhostCommandReport {
229///     ghost_commands: vec![
230///         GhostCommand {
231///             name: "delete".to_string(),
232///             source: GhostSource::FrontendOnly,
233///             description: None,
234///         },
235///     ],
236///     total_frontend_commands: 3,
237///     total_registry_commands: 2,
238/// };
239/// assert_eq!(
240///     report.to_string(),
241///     "1 ghost command(s) (3 frontend, 2 registry)"
242/// );
243/// ```
244impl fmt::Display for GhostCommandReport {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        let n = self.ghost_commands.len();
247        write!(
248            f,
249            "{n} ghost command(s) ({} frontend, {} registry)",
250            self.total_frontend_commands, self.total_registry_commands
251        )
252    }
253}
254
255/// # Examples
256///
257/// ```
258/// use victauri_core::verification::IpcIntegrityReport;
259///
260/// let healthy = IpcIntegrityReport {
261///     total_calls: 10,
262///     completed: 10,
263///     pending: 0,
264///     errored: 0,
265///     stale_calls: vec![],
266///     error_calls: vec![],
267///     healthy: true,
268/// };
269/// assert_eq!(
270///     healthy.to_string(),
271///     "IPC healthy: 10/10 completed"
272/// );
273///
274/// let unhealthy = IpcIntegrityReport {
275///     total_calls: 10,
276///     completed: 7,
277///     pending: 2,
278///     errored: 1,
279///     stale_calls: vec![],
280///     error_calls: vec![],
281///     healthy: false,
282/// };
283/// assert_eq!(
284///     unhealthy.to_string(),
285///     "IPC unhealthy: 0 stale, 1 errored of 10 calls"
286/// );
287/// ```
288impl fmt::Display for IpcIntegrityReport {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        if self.healthy {
291            write!(
292                f,
293                "IPC healthy: {}/{} completed",
294                self.completed, self.total_calls
295            )
296        } else {
297            let stale = self.stale_calls.len();
298            write!(
299                f,
300                "IPC unhealthy: {stale} stale, {} errored of {} calls",
301                self.errored, self.total_calls
302            )
303        }
304    }
305}
306
307/// Detects commands that exist on only one side of the IPC boundary (frontend vs registry).
308///
309/// # Examples
310///
311/// ```
312/// use victauri_core::verification::detect_ghost_commands;
313/// use victauri_core::{CommandRegistry, CommandInfo};
314///
315/// let registry = CommandRegistry::new();
316/// registry.register(CommandInfo::new("save").with_description("Save data"));
317///
318/// let frontend_cmds = vec!["save".to_string(), "delete".to_string()];
319/// let report = detect_ghost_commands(&frontend_cmds, &registry);
320/// assert_eq!(report.ghost_commands.len(), 1); // "delete" is frontend-only
321/// ```
322#[must_use]
323pub fn detect_ghost_commands(
324    frontend_commands: &[String],
325    registry: &CommandRegistry,
326) -> GhostCommandReport {
327    let registry_list = registry.list();
328    let registry_names: std::collections::HashSet<&str> =
329        registry_list.iter().map(|c| c.name.as_str()).collect();
330    let frontend_set: std::collections::HashSet<&str> = frontend_commands
331        .iter()
332        .map(std::string::String::as_str)
333        .collect();
334
335    let mut ghost_commands = Vec::new();
336
337    for name in &frontend_set {
338        if !registry_names.contains(name) {
339            ghost_commands.push(GhostCommand {
340                name: name.to_string(),
341                source: GhostSource::FrontendOnly,
342                description: Some(
343                    "Command invoked from frontend but not registered in backend".to_string(),
344                ),
345            });
346        }
347    }
348
349    for cmd in &registry_list {
350        if !frontend_set.contains(cmd.name.as_str()) {
351            ghost_commands.push(GhostCommand {
352                name: cmd.name.clone(),
353                source: GhostSource::RegistryOnly,
354                description: cmd.description.clone(),
355            });
356        }
357    }
358
359    ghost_commands.sort_by(|a, b| a.name.cmp(&b.name));
360
361    GhostCommandReport {
362        ghost_commands,
363        total_frontend_commands: frontend_set.len(),
364        total_registry_commands: registry_list.len(),
365    }
366}
367
368// ── IPC round-trip integrity ────────────────────────────────────────────────
369
370/// Summary of IPC round-trip health: completed, pending, errored, and stale calls.
371#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
372pub struct IpcIntegrityReport {
373    /// Total number of IPC calls analyzed.
374    pub total_calls: usize,
375    /// Calls that completed successfully.
376    pub completed: usize,
377    /// Calls still awaiting a response.
378    pub pending: usize,
379    /// Calls that returned an error.
380    pub errored: usize,
381    /// Pending calls that have exceeded the staleness threshold.
382    pub stale_calls: Vec<StaleCall>,
383    /// Calls that resulted in errors.
384    pub error_calls: Vec<ErrorCall>,
385    /// True if there are no stale or errored calls.
386    pub healthy: bool,
387}
388
389/// An IPC call that has been pending longer than the staleness threshold.
390#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
391pub struct StaleCall {
392    /// Unique call identifier.
393    pub id: String,
394    /// Name of the invoked command.
395    pub command: String,
396    /// When the call was initiated.
397    pub timestamp: DateTime<Utc>,
398    /// How long the call has been pending, in milliseconds.
399    pub age_ms: i64,
400    /// Webview that initiated the call.
401    pub webview_label: String,
402}
403
404/// An IPC call that returned an error result.
405#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
406pub struct ErrorCall {
407    /// Unique call identifier.
408    pub id: String,
409    /// Name of the invoked command.
410    pub command: String,
411    /// When the call was initiated.
412    pub timestamp: DateTime<Utc>,
413    /// Error message returned by the backend.
414    pub error: String,
415    /// Webview that initiated the call.
416    pub webview_label: String,
417}
418
419/// Analyzes the event log for IPC health, flagging stale and errored calls.
420///
421/// # Examples
422///
423/// ```
424/// use victauri_core::verification::check_ipc_integrity;
425/// use victauri_core::EventLog;
426///
427/// let log = EventLog::new(100);
428/// let report = check_ipc_integrity(&log, 5000);
429/// assert!(report.healthy);
430/// assert_eq!(report.total_calls, 0);
431/// ```
432#[must_use]
433pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
434    let now = Utc::now();
435    let calls = event_log.ipc_calls();
436    let total_calls = calls.len();
437    let mut completed = 0usize;
438    let mut pending = 0usize;
439    let mut errored = 0usize;
440    let mut stale_calls = Vec::new();
441    let mut error_calls = Vec::new();
442
443    for call in &calls {
444        match &call.result {
445            IpcResult::Ok(_) => completed += 1,
446            IpcResult::Pending => {
447                pending += 1;
448                let age_ms = (now - call.timestamp).num_milliseconds();
449                if age_ms >= stale_threshold_ms {
450                    stale_calls.push(StaleCall {
451                        id: call.id.clone(),
452                        command: call.command.clone(),
453                        timestamp: call.timestamp,
454                        age_ms,
455                        webview_label: call.webview_label.clone(),
456                    });
457                }
458            }
459            IpcResult::Err(e) => {
460                errored += 1;
461                error_calls.push(ErrorCall {
462                    id: call.id.clone(),
463                    command: call.command.clone(),
464                    timestamp: call.timestamp,
465                    error: e.clone(),
466                    webview_label: call.webview_label.clone(),
467                });
468            }
469        }
470    }
471
472    let healthy = stale_calls.is_empty() && errored == 0;
473
474    IpcIntegrityReport {
475        total_calls,
476        completed,
477        pending,
478        errored,
479        stale_calls,
480        error_calls,
481        healthy,
482    }
483}
484
485// ── Semantic test assertions ────────────────────────────────────────────────
486
487/// Recognized assertion condition operators for [`evaluate_assertion`].
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
490#[serde(rename_all = "snake_case")]
491#[non_exhaustive]
492pub enum AssertionCondition {
493    /// Actual value must equal the expected value.
494    Equals,
495    /// Actual value must not equal the expected value.
496    NotEquals,
497    /// String must contain substring, or array must contain element.
498    Contains,
499    /// Numeric actual must be greater than expected.
500    GreaterThan,
501    /// Numeric actual must be less than expected.
502    LessThan,
503    /// Value must be truthy (non-null, non-false, non-empty, non-zero).
504    Truthy,
505    /// Value must be falsy (null, false, empty string, zero).
506    Falsy,
507    /// Value must not be null.
508    Exists,
509    /// Value must be of the specified JSON type (string, number, boolean, array, object, null).
510    TypeIs,
511}
512
513impl std::str::FromStr for AssertionCondition {
514    type Err = crate::error::VictauriError;
515
516    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
517        match s {
518            "equals" => Ok(Self::Equals),
519            "not_equals" => Ok(Self::NotEquals),
520            "contains" => Ok(Self::Contains),
521            "greater_than" => Ok(Self::GreaterThan),
522            "less_than" => Ok(Self::LessThan),
523            "truthy" => Ok(Self::Truthy),
524            "falsy" => Ok(Self::Falsy),
525            "exists" => Ok(Self::Exists),
526            "type_is" => Ok(Self::TypeIs),
527            other => Err(crate::error::VictauriError::UnknownCondition {
528                condition: other.to_string(),
529            }),
530        }
531    }
532}
533
534impl std::fmt::Display for AssertionCondition {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        let s = match self {
537            Self::Equals => "equals",
538            Self::NotEquals => "not_equals",
539            Self::Contains => "contains",
540            Self::GreaterThan => "greater_than",
541            Self::LessThan => "less_than",
542            Self::Truthy => "truthy",
543            Self::Falsy => "falsy",
544            Self::Exists => "exists",
545            Self::TypeIs => "type_is",
546        };
547        f.write_str(s)
548    }
549}
550
551/// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy").
552#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
553pub struct SemanticAssertion {
554    /// Human-readable label describing what is being asserted.
555    pub label: String,
556    /// Condition operator to evaluate.
557    pub condition: AssertionCondition,
558    /// Expected value to compare against (interpretation depends on condition).
559    pub expected: serde_json::Value,
560}
561
562/// Outcome of evaluating a semantic assertion against an actual value.
563#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
564pub struct AssertionResult {
565    /// Label from the original assertion.
566    pub label: String,
567    /// Whether the assertion passed.
568    pub passed: bool,
569    /// The actual value that was evaluated.
570    pub actual: serde_json::Value,
571    /// The expected value from the assertion.
572    pub expected: serde_json::Value,
573    /// Failure message explaining why the assertion failed, if it did.
574    pub message: Option<String>,
575}
576
577fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
578    v.as_f64()
579        .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
580}
581
582fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
583    if a == b {
584        return true;
585    }
586    if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
587        (na - nb).abs() < f64::EPSILON
588    } else {
589        let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
590        let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
591        sa == sb
592    }
593}
594
595/// Evaluates a semantic assertion against an actual runtime value.
596///
597/// ```
598/// use victauri_core::verification::{evaluate_assertion, AssertionCondition, SemanticAssertion};
599/// use serde_json::json;
600///
601/// let assertion = SemanticAssertion {
602///     label: "check count".to_string(),
603///     condition: AssertionCondition::Equals,
604///     expected: json!(42),
605/// };
606/// let result = evaluate_assertion(json!(42), &assertion);
607/// assert!(result.passed);
608/// ```
609#[must_use]
610pub fn evaluate_assertion(
611    actual: serde_json::Value,
612    assertion: &SemanticAssertion,
613) -> AssertionResult {
614    let passed = match assertion.condition {
615        AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
616        AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
617        AssertionCondition::Contains => match (&actual, &assertion.expected) {
618            (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
619            (serde_json::Value::Array(arr), val) => arr.contains(val),
620            _ => false,
621        },
622        AssertionCondition::GreaterThan => {
623            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
624                (Some(a), Some(e)) => a > e,
625                _ => false,
626            }
627        }
628        AssertionCondition::LessThan => {
629            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
630                (Some(a), Some(e)) => a < e,
631                _ => false,
632            }
633        }
634        AssertionCondition::Truthy => match &actual {
635            serde_json::Value::Null | serde_json::Value::Bool(false) => false,
636            serde_json::Value::String(s) => !s.is_empty(),
637            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
638            _ => true,
639        },
640        AssertionCondition::Falsy => match &actual {
641            serde_json::Value::Null | serde_json::Value::Bool(false) => true,
642            serde_json::Value::String(s) => s.is_empty(),
643            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
644            _ => false,
645        },
646        AssertionCondition::Exists => actual != serde_json::Value::Null,
647        AssertionCondition::TypeIs => {
648            let type_name = assertion.expected.as_str().unwrap_or("");
649            match type_name {
650                "string" => actual.is_string(),
651                "number" => actual.is_number(),
652                "boolean" => actual.is_boolean(),
653                "array" => actual.is_array(),
654                "object" => actual.is_object(),
655                "null" => actual.is_null(),
656                _ => false,
657            }
658        }
659    };
660
661    let message = if passed {
662        None
663    } else {
664        Some(format!(
665            "Assertion '{}' failed: expected {} {:?}, got {:?}",
666            assertion.label, assertion.condition, assertion.expected, actual
667        ))
668    };
669
670    AssertionResult {
671        label: assertion.label.clone(),
672        passed,
673        actual,
674        expected: assertion.expected.clone(),
675        message,
676    }
677}