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