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