1use 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
12const FLOAT_EPSILON: f64 = 1e-9;
14
15#[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
43const 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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
190pub struct GhostCommandReport {
191 pub frontend_only: Vec<GhostCommand>,
194 pub registry_only: Vec<GhostCommand>,
196 pub total_frontend_commands: usize,
198 pub total_registry_commands: usize,
200}
201
202#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
204pub struct GhostCommand {
205 pub name: String,
207 pub source: GhostSource,
209 pub description: Option<String>,
211}
212
213#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
215#[non_exhaustive]
216pub enum GhostSource {
217 FrontendOnly,
221 RegistryOnly,
223}
224
225impl fmt::Display for GhostSource {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 let s = match self {
228 Self::FrontendOnly => "frontend-only",
229 Self::RegistryOnly => "registry-only",
230 };
231 f.write_str(s)
232 }
233}
234
235impl fmt::Display for GhostCommand {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 write!(f, "{} ({})", self.name, self.source)
238 }
239}
240
241impl fmt::Display for GhostCommandReport {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 let n = self.frontend_only.len();
266 write!(
267 f,
268 "{n} ghost command(s) ({} frontend, {} registry)",
269 self.total_frontend_commands, self.total_registry_commands
270 )
271 }
272}
273
274impl fmt::Display for IpcIntegrityReport {
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 if self.healthy {
310 write!(
311 f,
312 "IPC healthy: {}/{} completed",
313 self.completed, self.total_calls
314 )
315 } else {
316 let stale = self.stale_calls.len();
317 write!(
318 f,
319 "IPC unhealthy: {stale} stale, {} errored of {} calls",
320 self.errored, self.total_calls
321 )
322 }
323 }
324}
325
326#[must_use]
345pub fn detect_ghost_commands(
346 frontend_commands: &[String],
347 registry: &CommandRegistry,
348) -> GhostCommandReport {
349 let registry_list = registry.list();
350 let registry_names: std::collections::HashSet<&str> =
351 registry_list.iter().map(|c| c.name.as_str()).collect();
352 let frontend_set: std::collections::HashSet<&str> = frontend_commands
353 .iter()
354 .map(std::string::String::as_str)
355 .collect();
356
357 let mut frontend_only = Vec::new();
358 let mut registry_only = Vec::new();
359
360 for name in &frontend_set {
361 if !registry_names.contains(name) {
362 frontend_only.push(GhostCommand {
363 name: name.to_string(),
364 source: GhostSource::FrontendOnly,
365 description: Some(
366 "Invoked from the frontend but absent from Victauri's introspection \
367 registry (#[inspectable]/register_command_names). NOT necessarily a \
368 real ghost — only a missing-handler bug if the registry mirrors the \
369 app's full command set."
370 .to_string(),
371 ),
372 });
373 }
374 }
375
376 for cmd in ®istry_list {
377 if !frontend_set.contains(cmd.name.as_str()) {
378 registry_only.push(GhostCommand {
379 name: cmd.name.clone(),
380 source: GhostSource::RegistryOnly,
381 description: cmd.description.clone(),
382 });
383 }
384 }
385
386 frontend_only.sort_by(|a, b| a.name.cmp(&b.name));
387 registry_only.sort_by(|a, b| a.name.cmp(&b.name));
388
389 GhostCommandReport {
390 frontend_only,
391 registry_only,
392 total_frontend_commands: frontend_set.len(),
393 total_registry_commands: registry_list.len(),
394 }
395}
396
397#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
401pub struct IpcIntegrityReport {
402 pub total_calls: usize,
404 pub completed: usize,
406 pub pending: usize,
408 pub errored: usize,
410 pub stale_calls: Vec<StaleCall>,
412 pub error_calls: Vec<ErrorCall>,
414 pub healthy: bool,
416}
417
418#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
420pub struct StaleCall {
421 pub id: String,
423 pub command: String,
425 pub timestamp: DateTime<Utc>,
427 pub age_ms: i64,
429 pub webview_label: String,
431}
432
433#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
435pub struct ErrorCall {
436 pub id: String,
438 pub command: String,
440 pub timestamp: DateTime<Utc>,
442 pub error: String,
444 pub webview_label: String,
446}
447
448#[must_use]
462pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
463 let now = Utc::now();
464 let calls = event_log.ipc_calls();
465 let total_calls = calls.len();
466 let mut completed = 0usize;
467 let mut pending = 0usize;
468 let mut errored = 0usize;
469 let mut stale_calls = Vec::new();
470 let mut error_calls = Vec::new();
471
472 for call in &calls {
473 match &call.result {
474 IpcResult::Ok(_) => completed += 1,
475 IpcResult::Pending => {
476 pending += 1;
477 let age_ms = (now - call.timestamp).num_milliseconds();
478 if age_ms >= stale_threshold_ms {
479 stale_calls.push(StaleCall {
480 id: call.id.clone(),
481 command: call.command.clone(),
482 timestamp: call.timestamp,
483 age_ms,
484 webview_label: call.webview_label.clone(),
485 });
486 }
487 }
488 IpcResult::Err(e) => {
489 errored += 1;
490 error_calls.push(ErrorCall {
491 id: call.id.clone(),
492 command: call.command.clone(),
493 timestamp: call.timestamp,
494 error: e.clone(),
495 webview_label: call.webview_label.clone(),
496 });
497 }
498 }
499 }
500
501 let healthy = stale_calls.is_empty() && errored == 0;
502
503 IpcIntegrityReport {
504 total_calls,
505 completed,
506 pending,
507 errored,
508 stale_calls,
509 error_calls,
510 healthy,
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
519#[serde(rename_all = "snake_case")]
520#[non_exhaustive]
521pub enum AssertionCondition {
522 Equals,
524 NotEquals,
526 Contains,
528 GreaterThan,
530 LessThan,
532 Truthy,
534 Falsy,
536 Exists,
538 TypeIs,
540}
541
542impl std::str::FromStr for AssertionCondition {
543 type Err = crate::error::VictauriError;
544
545 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
546 match s {
547 "equals" => Ok(Self::Equals),
548 "not_equals" => Ok(Self::NotEquals),
549 "contains" => Ok(Self::Contains),
550 "greater_than" => Ok(Self::GreaterThan),
551 "less_than" => Ok(Self::LessThan),
552 "truthy" => Ok(Self::Truthy),
553 "falsy" => Ok(Self::Falsy),
554 "exists" => Ok(Self::Exists),
555 "type_is" => Ok(Self::TypeIs),
556 other => Err(crate::error::VictauriError::UnknownCondition {
557 condition: other.to_string(),
558 }),
559 }
560 }
561}
562
563impl std::fmt::Display for AssertionCondition {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 let s = match self {
566 Self::Equals => "equals",
567 Self::NotEquals => "not_equals",
568 Self::Contains => "contains",
569 Self::GreaterThan => "greater_than",
570 Self::LessThan => "less_than",
571 Self::Truthy => "truthy",
572 Self::Falsy => "falsy",
573 Self::Exists => "exists",
574 Self::TypeIs => "type_is",
575 };
576 f.write_str(s)
577 }
578}
579
580#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
582pub struct SemanticAssertion {
583 pub label: String,
585 pub condition: AssertionCondition,
587 pub expected: serde_json::Value,
589}
590
591#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
593pub struct AssertionResult {
594 pub label: String,
596 pub passed: bool,
598 pub actual: serde_json::Value,
600 pub expected: serde_json::Value,
602 pub message: Option<String>,
604}
605
606fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
607 v.as_f64()
608 .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
609}
610
611fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
612 if a == b {
613 return true;
614 }
615 if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
616 (na - nb).abs() < f64::EPSILON
617 } else {
618 let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
619 let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
620 sa == sb
621 }
622}
623
624#[must_use]
639pub fn evaluate_assertion(
640 actual: serde_json::Value,
641 assertion: &SemanticAssertion,
642) -> AssertionResult {
643 let passed = match assertion.condition {
644 AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
645 AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
646 AssertionCondition::Contains => match (&actual, &assertion.expected) {
647 (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
648 (serde_json::Value::Array(arr), val) => arr.contains(val),
649 _ => false,
650 },
651 AssertionCondition::GreaterThan => {
652 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
653 (Some(a), Some(e)) => a > e,
654 _ => false,
655 }
656 }
657 AssertionCondition::LessThan => {
658 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
659 (Some(a), Some(e)) => a < e,
660 _ => false,
661 }
662 }
663 AssertionCondition::Truthy => match &actual {
664 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
665 serde_json::Value::String(s) => !s.is_empty(),
666 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
667 _ => true,
668 },
669 AssertionCondition::Falsy => match &actual {
670 serde_json::Value::Null | serde_json::Value::Bool(false) => true,
671 serde_json::Value::String(s) => s.is_empty(),
672 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
673 _ => false,
674 },
675 AssertionCondition::Exists => actual != serde_json::Value::Null,
676 AssertionCondition::TypeIs => {
677 let type_name = assertion.expected.as_str().unwrap_or("");
678 match type_name {
679 "string" => actual.is_string(),
680 "number" => actual.is_number(),
681 "boolean" => actual.is_boolean(),
682 "array" => actual.is_array(),
683 "object" => actual.is_object(),
684 "null" => actual.is_null(),
685 _ => false,
686 }
687 }
688 };
689
690 let message = if passed {
691 None
692 } else {
693 Some(format!(
694 "Assertion '{}' failed: expected {} {:?}, got {:?}",
695 assertion.label, assertion.condition, assertion.expected, actual
696 ))
697 };
698
699 AssertionResult {
700 label: assertion.label.clone(),
701 passed,
702 actual,
703 expected: assertion.expected.clone(),
704 message,
705 }
706}