Skip to main content

victauri_core/
verification.rs

1use crate::event::{EventLog, IpcResult};
2use crate::registry::CommandRegistry;
3use crate::types::{Divergence, DivergenceSeverity, VerificationResult};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7// ── Cross-boundary state verification ───────────────────────────────────────
8
9/// Compares frontend and backend state trees, returning all divergences found.
10///
11/// ```
12/// use victauri_core::verification::verify_state;
13/// use serde_json::json;
14///
15/// let result = verify_state(json!({"title": "App"}), json!({"title": "App"}));
16/// assert!(result.passed);
17/// assert!(result.divergences.is_empty());
18/// ```
19pub fn verify_state(
20    frontend_state: serde_json::Value,
21    backend_state: serde_json::Value,
22) -> VerificationResult {
23    let mut divergences = Vec::new();
24    compare_values("", &frontend_state, &backend_state, &mut divergences);
25    let passed = divergences.is_empty();
26    VerificationResult {
27        passed,
28        frontend_state,
29        backend_state,
30        divergences,
31    }
32}
33
34fn compare_values(
35    path: &str,
36    frontend: &serde_json::Value,
37    backend: &serde_json::Value,
38    divergences: &mut Vec<Divergence>,
39) {
40    if frontend == backend {
41        return;
42    }
43
44    match (frontend, backend) {
45        (serde_json::Value::Object(f_map), serde_json::Value::Object(b_map)) => {
46            for (key, f_val) in f_map {
47                let child_path = if path.is_empty() {
48                    key.clone()
49                } else {
50                    format!("{path}.{key}")
51                };
52                match b_map.get(key) {
53                    Some(b_val) => compare_values(&child_path, f_val, b_val, divergences),
54                    None => divergences.push(Divergence {
55                        path: child_path,
56                        frontend_value: f_val.clone(),
57                        backend_value: serde_json::Value::Null,
58                        severity: DivergenceSeverity::Warning,
59                    }),
60                }
61            }
62            for key in b_map.keys() {
63                if !f_map.contains_key(key) {
64                    let child_path = if path.is_empty() {
65                        key.clone()
66                    } else {
67                        format!("{path}.{key}")
68                    };
69                    divergences.push(Divergence {
70                        path: child_path,
71                        frontend_value: serde_json::Value::Null,
72                        backend_value: b_map[key].clone(),
73                        severity: DivergenceSeverity::Warning,
74                    });
75                }
76            }
77        }
78        (serde_json::Value::Array(f_arr), serde_json::Value::Array(b_arr)) => {
79            let max_len = f_arr.len().max(b_arr.len());
80            for i in 0..max_len {
81                let child_path = if path.is_empty() {
82                    format!("[{i}]")
83                } else {
84                    format!("{path}[{i}]")
85                };
86                match (f_arr.get(i), b_arr.get(i)) {
87                    (Some(f_val), Some(b_val)) => {
88                        compare_values(&child_path, f_val, b_val, divergences);
89                    }
90                    (Some(f_val), None) => divergences.push(Divergence {
91                        path: child_path,
92                        frontend_value: f_val.clone(),
93                        backend_value: serde_json::Value::Null,
94                        severity: DivergenceSeverity::Warning,
95                    }),
96                    (None, Some(b_val)) => divergences.push(Divergence {
97                        path: child_path,
98                        frontend_value: serde_json::Value::Null,
99                        backend_value: b_val.clone(),
100                        severity: DivergenceSeverity::Warning,
101                    }),
102                    (None, None) => {}
103                }
104            }
105        }
106        _ => {
107            let severity = classify_severity(frontend, backend);
108            divergences.push(Divergence {
109                path: if path.is_empty() {
110                    "$".to_string()
111                } else {
112                    path.to_string()
113                },
114                frontend_value: frontend.clone(),
115                backend_value: backend.clone(),
116                severity,
117            });
118        }
119    }
120}
121
122fn classify_severity(
123    frontend: &serde_json::Value,
124    backend: &serde_json::Value,
125) -> DivergenceSeverity {
126    match (frontend, backend) {
127        (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => DivergenceSeverity::Warning,
128        (serde_json::Value::Number(f), serde_json::Value::Number(b)) => {
129            match (f.as_f64(), b.as_f64()) {
130                (Some(fv), Some(bv)) if (fv - bv).abs() < f64::EPSILON => DivergenceSeverity::Info,
131                _ => DivergenceSeverity::Error,
132            }
133        }
134        _ => DivergenceSeverity::Error,
135    }
136}
137
138// ── Ghost command detection ─────────────────────────────────────────────────
139
140/// Report of ghost commands -- commands that exist on only one side of the IPC boundary.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct GhostCommandReport {
143    /// Commands found on only the frontend or only the backend.
144    pub ghost_commands: Vec<GhostCommand>,
145    /// Total unique commands observed from the frontend.
146    pub total_frontend_commands: usize,
147    /// Total commands registered in the backend registry.
148    pub total_registry_commands: usize,
149}
150
151/// A command that exists on only one side of the frontend/backend boundary.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct GhostCommand {
154    /// Command name as invoked or registered.
155    pub name: String,
156    /// Which side the command was found on.
157    pub source: GhostSource,
158    /// Optional description, if available from the registry.
159    pub description: Option<String>,
160}
161
162/// Indicates which side of the IPC boundary a ghost command was found on.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[non_exhaustive]
165pub enum GhostSource {
166    /// Command invoked from the frontend but not registered in the backend.
167    FrontendOnly,
168    /// Command registered in the backend but never invoked from the frontend.
169    RegistryOnly,
170}
171
172/// Detects commands that exist on only one side of the IPC boundary (frontend vs registry).
173pub fn detect_ghost_commands(
174    frontend_commands: &[String],
175    registry: &CommandRegistry,
176) -> GhostCommandReport {
177    let registry_list = registry.list();
178    let registry_names: std::collections::HashSet<&str> =
179        registry_list.iter().map(|c| c.name.as_str()).collect();
180    let frontend_set: std::collections::HashSet<&str> =
181        frontend_commands.iter().map(|s| s.as_str()).collect();
182
183    let mut ghost_commands = Vec::new();
184
185    for name in &frontend_set {
186        if !registry_names.contains(name) {
187            ghost_commands.push(GhostCommand {
188                name: name.to_string(),
189                source: GhostSource::FrontendOnly,
190                description: Some(
191                    "Command invoked from frontend but not registered in backend".to_string(),
192                ),
193            });
194        }
195    }
196
197    for cmd in &registry_list {
198        if !frontend_set.contains(cmd.name.as_str()) {
199            ghost_commands.push(GhostCommand {
200                name: cmd.name.clone(),
201                source: GhostSource::RegistryOnly,
202                description: cmd.description.clone(),
203            });
204        }
205    }
206
207    ghost_commands.sort_by(|a, b| a.name.cmp(&b.name));
208
209    GhostCommandReport {
210        ghost_commands,
211        total_frontend_commands: frontend_commands.len(),
212        total_registry_commands: registry_list.len(),
213    }
214}
215
216// ── IPC round-trip integrity ────────────────────────────────────────────────
217
218/// Summary of IPC round-trip health: completed, pending, errored, and stale calls.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct IpcIntegrityReport {
221    /// Total number of IPC calls analyzed.
222    pub total_calls: usize,
223    /// Calls that completed successfully.
224    pub completed: usize,
225    /// Calls still awaiting a response.
226    pub pending: usize,
227    /// Calls that returned an error.
228    pub errored: usize,
229    /// Pending calls that have exceeded the staleness threshold.
230    pub stale_calls: Vec<StaleCall>,
231    /// Calls that resulted in errors.
232    pub error_calls: Vec<ErrorCall>,
233    /// True if there are no stale or errored calls.
234    pub healthy: bool,
235}
236
237/// An IPC call that has been pending longer than the staleness threshold.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct StaleCall {
240    /// Unique call identifier.
241    pub id: String,
242    /// Name of the invoked command.
243    pub command: String,
244    /// When the call was initiated.
245    pub timestamp: DateTime<Utc>,
246    /// How long the call has been pending, in milliseconds.
247    pub age_ms: i64,
248    /// Webview that initiated the call.
249    pub webview_label: String,
250}
251
252/// An IPC call that returned an error result.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ErrorCall {
255    /// Unique call identifier.
256    pub id: String,
257    /// Name of the invoked command.
258    pub command: String,
259    /// When the call was initiated.
260    pub timestamp: DateTime<Utc>,
261    /// Error message returned by the backend.
262    pub error: String,
263    /// Webview that initiated the call.
264    pub webview_label: String,
265}
266
267/// Analyzes the event log for IPC health, flagging stale and errored calls.
268pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
269    let now = Utc::now();
270    let calls = event_log.ipc_calls();
271    let total_calls = calls.len();
272    let mut completed = 0usize;
273    let mut pending = 0usize;
274    let mut errored = 0usize;
275    let mut stale_calls = Vec::new();
276    let mut error_calls = Vec::new();
277
278    for call in &calls {
279        match &call.result {
280            IpcResult::Ok(_) => completed += 1,
281            IpcResult::Pending => {
282                pending += 1;
283                let age_ms = (now - call.timestamp).num_milliseconds();
284                if age_ms >= stale_threshold_ms {
285                    stale_calls.push(StaleCall {
286                        id: call.id.clone(),
287                        command: call.command.clone(),
288                        timestamp: call.timestamp,
289                        age_ms,
290                        webview_label: call.webview_label.clone(),
291                    });
292                }
293            }
294            IpcResult::Err(e) => {
295                errored += 1;
296                error_calls.push(ErrorCall {
297                    id: call.id.clone(),
298                    command: call.command.clone(),
299                    timestamp: call.timestamp,
300                    error: e.clone(),
301                    webview_label: call.webview_label.clone(),
302                });
303            }
304        }
305    }
306
307    let healthy = stale_calls.is_empty() && errored == 0;
308
309    IpcIntegrityReport {
310        total_calls,
311        completed,
312        pending,
313        errored,
314        stale_calls,
315        error_calls,
316        healthy,
317    }
318}
319
320// ── Semantic test assertions ────────────────────────────────────────────────
321
322/// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy").
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SemanticAssertion {
325    /// Human-readable label describing what is being asserted.
326    pub label: String,
327    /// Condition operator: "equals", "contains", "greater_than", "truthy", etc.
328    pub condition: String,
329    /// Expected value to compare against (interpretation depends on condition).
330    pub expected: serde_json::Value,
331}
332
333/// Outcome of evaluating a semantic assertion against an actual value.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AssertionResult {
336    /// Label from the original assertion.
337    pub label: String,
338    /// Whether the assertion passed.
339    pub passed: bool,
340    /// The actual value that was evaluated.
341    pub actual: serde_json::Value,
342    /// The expected value from the assertion.
343    pub expected: serde_json::Value,
344    /// Failure message explaining why the assertion failed, if it did.
345    pub message: Option<String>,
346}
347
348/// Evaluates a semantic assertion against an actual runtime value.
349///
350/// ```
351/// use victauri_core::verification::{evaluate_assertion, SemanticAssertion};
352/// use serde_json::json;
353///
354/// let assertion = SemanticAssertion {
355///     label: "check count".to_string(),
356///     condition: "equals".to_string(),
357///     expected: json!(42),
358/// };
359/// let result = evaluate_assertion(json!(42), &assertion);
360/// assert!(result.passed);
361/// ```
362pub fn evaluate_assertion(
363    actual: serde_json::Value,
364    assertion: &SemanticAssertion,
365) -> AssertionResult {
366    let passed = match assertion.condition.as_str() {
367        "equals" => actual == assertion.expected,
368        "not_equals" => actual != assertion.expected,
369        "contains" => match (&actual, &assertion.expected) {
370            (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
371            (serde_json::Value::Array(arr), val) => arr.contains(val),
372            _ => false,
373        },
374        "greater_than" => match (actual.as_f64(), assertion.expected.as_f64()) {
375            (Some(a), Some(e)) => a > e,
376            _ => false,
377        },
378        "less_than" => match (actual.as_f64(), assertion.expected.as_f64()) {
379            (Some(a), Some(e)) => a < e,
380            _ => false,
381        },
382        "truthy" => {
383            matches!(
384                &actual,
385                serde_json::Value::Bool(true)
386                    | serde_json::Value::Number(_)
387                    | serde_json::Value::String(_)
388                    | serde_json::Value::Array(_)
389                    | serde_json::Value::Object(_)
390            ) && actual != serde_json::Value::String(String::new())
391        }
392        "falsy" => {
393            matches!(
394                &actual,
395                serde_json::Value::Null | serde_json::Value::Bool(false)
396            ) || actual == serde_json::Value::String(String::new())
397                || actual == serde_json::json!(0)
398        }
399        "exists" => actual != serde_json::Value::Null,
400        "type_is" => {
401            let type_name = assertion.expected.as_str().unwrap_or("");
402            match type_name {
403                "string" => actual.is_string(),
404                "number" => actual.is_number(),
405                "boolean" => actual.is_boolean(),
406                "array" => actual.is_array(),
407                "object" => actual.is_object(),
408                "null" => actual.is_null(),
409                _ => false,
410            }
411        }
412        unknown => {
413            return AssertionResult {
414                label: assertion.label.clone(),
415                passed: false,
416                actual,
417                expected: assertion.expected.clone(),
418                message: Some(format!(
419                    "Unknown assertion condition '{}' in '{}'",
420                    unknown, assertion.label
421                )),
422            };
423        }
424    };
425
426    let message = if !passed {
427        Some(format!(
428            "Assertion '{}' failed: expected {} {:?}, got {:?}",
429            assertion.label, assertion.condition, assertion.expected, actual
430        ))
431    } else {
432        None
433    };
434
435    AssertionResult {
436        label: assertion.label.clone(),
437        passed,
438        actual,
439        expected: assertion.expected.clone(),
440        message,
441    }
442}