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)]
190#[non_exhaustive]
191pub struct GhostCommandReport {
192 pub frontend_only: Vec<GhostCommand>,
195 pub registry_only: Vec<GhostCommand>,
197 pub total_frontend_commands: usize,
199 pub total_registry_commands: usize,
201}
202
203#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
205#[non_exhaustive]
206pub struct GhostCommand {
207 pub name: String,
209 pub source: GhostSource,
211 pub description: Option<String>,
213}
214
215#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
217#[non_exhaustive]
218pub enum GhostSource {
219 FrontendOnly,
223 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
243impl 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
269impl 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#[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 ®istry_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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
390#[non_exhaustive]
391pub struct IpcIntegrityReport {
392 pub total_calls: usize,
394 pub completed: usize,
396 pub pending: usize,
398 pub errored: usize,
400 pub stale_calls: Vec<StaleCall>,
402 pub error_calls: Vec<ErrorCall>,
404 pub healthy: bool,
406}
407
408#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
410#[non_exhaustive]
411pub struct StaleCall {
412 pub id: String,
414 pub command: String,
416 pub timestamp: DateTime<Utc>,
418 pub age_ms: i64,
420 pub webview_label: String,
422}
423
424#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
426#[non_exhaustive]
427pub struct ErrorCall {
428 pub id: String,
430 pub command: String,
432 pub timestamp: DateTime<Utc>,
434 pub error: String,
436 pub webview_label: String,
438}
439
440#[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 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#[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 Equals,
518 NotEquals,
520 Contains,
522 GreaterThan,
524 LessThan,
526 Truthy,
528 Falsy,
530 Exists,
532 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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
576#[non_exhaustive]
577pub struct SemanticAssertion {
578 pub label: String,
580 pub condition: AssertionCondition,
582 pub expected: serde_json::Value,
584}
585
586impl SemanticAssertion {
587 #[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
613#[non_exhaustive]
614pub struct AssertionResult {
615 pub label: String,
617 pub passed: bool,
619 pub actual: serde_json::Value,
621 pub expected: serde_json::Value,
623 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#[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}