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)]
177pub struct GhostCommandReport {
178 pub ghost_commands: Vec<GhostCommand>,
180 pub total_frontend_commands: usize,
182 pub total_registry_commands: usize,
184}
185
186#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
188pub struct GhostCommand {
189 pub name: String,
191 pub source: GhostSource,
193 pub description: Option<String>,
195}
196
197#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
199#[non_exhaustive]
200pub enum GhostSource {
201 FrontendOnly,
203 RegistryOnly,
205}
206
207impl fmt::Display for GhostSource {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 let s = match self {
210 Self::FrontendOnly => "frontend-only",
211 Self::RegistryOnly => "registry-only",
212 };
213 f.write_str(s)
214 }
215}
216
217impl fmt::Display for GhostCommand {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 write!(f, "{} ({})", self.name, self.source)
220 }
221}
222
223impl fmt::Display for GhostCommandReport {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 let n = self.ghost_commands.len();
247 write!(
248 f,
249 "{n} ghost command(s) ({} frontend, {} registry)",
250 self.total_frontend_commands, self.total_registry_commands
251 )
252 }
253}
254
255impl fmt::Display for IpcIntegrityReport {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 if self.healthy {
291 write!(
292 f,
293 "IPC healthy: {}/{} completed",
294 self.completed, self.total_calls
295 )
296 } else {
297 let stale = self.stale_calls.len();
298 write!(
299 f,
300 "IPC unhealthy: {stale} stale, {} errored of {} calls",
301 self.errored, self.total_calls
302 )
303 }
304 }
305}
306
307#[must_use]
323pub fn detect_ghost_commands(
324 frontend_commands: &[String],
325 registry: &CommandRegistry,
326) -> GhostCommandReport {
327 let registry_list = registry.list();
328 let registry_names: std::collections::HashSet<&str> =
329 registry_list.iter().map(|c| c.name.as_str()).collect();
330 let frontend_set: std::collections::HashSet<&str> = frontend_commands
331 .iter()
332 .map(std::string::String::as_str)
333 .collect();
334
335 let mut ghost_commands = Vec::new();
336
337 for name in &frontend_set {
338 if !registry_names.contains(name) {
339 ghost_commands.push(GhostCommand {
340 name: name.to_string(),
341 source: GhostSource::FrontendOnly,
342 description: Some(
343 "Command invoked from frontend but not registered in backend".to_string(),
344 ),
345 });
346 }
347 }
348
349 for cmd in ®istry_list {
350 if !frontend_set.contains(cmd.name.as_str()) {
351 ghost_commands.push(GhostCommand {
352 name: cmd.name.clone(),
353 source: GhostSource::RegistryOnly,
354 description: cmd.description.clone(),
355 });
356 }
357 }
358
359 ghost_commands.sort_by(|a, b| a.name.cmp(&b.name));
360
361 GhostCommandReport {
362 ghost_commands,
363 total_frontend_commands: frontend_set.len(),
364 total_registry_commands: registry_list.len(),
365 }
366}
367
368#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
372pub struct IpcIntegrityReport {
373 pub total_calls: usize,
375 pub completed: usize,
377 pub pending: usize,
379 pub errored: usize,
381 pub stale_calls: Vec<StaleCall>,
383 pub error_calls: Vec<ErrorCall>,
385 pub healthy: bool,
387}
388
389#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
391pub struct StaleCall {
392 pub id: String,
394 pub command: String,
396 pub timestamp: DateTime<Utc>,
398 pub age_ms: i64,
400 pub webview_label: String,
402}
403
404#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
406pub struct ErrorCall {
407 pub id: String,
409 pub command: String,
411 pub timestamp: DateTime<Utc>,
413 pub error: String,
415 pub webview_label: String,
417}
418
419#[must_use]
433pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
434 let now = Utc::now();
435 let calls = event_log.ipc_calls();
436 let total_calls = calls.len();
437 let mut completed = 0usize;
438 let mut pending = 0usize;
439 let mut errored = 0usize;
440 let mut stale_calls = Vec::new();
441 let mut error_calls = Vec::new();
442
443 for call in &calls {
444 match &call.result {
445 IpcResult::Ok(_) => completed += 1,
446 IpcResult::Pending => {
447 pending += 1;
448 let age_ms = (now - call.timestamp).num_milliseconds();
449 if age_ms >= stale_threshold_ms {
450 stale_calls.push(StaleCall {
451 id: call.id.clone(),
452 command: call.command.clone(),
453 timestamp: call.timestamp,
454 age_ms,
455 webview_label: call.webview_label.clone(),
456 });
457 }
458 }
459 IpcResult::Err(e) => {
460 errored += 1;
461 error_calls.push(ErrorCall {
462 id: call.id.clone(),
463 command: call.command.clone(),
464 timestamp: call.timestamp,
465 error: e.clone(),
466 webview_label: call.webview_label.clone(),
467 });
468 }
469 }
470 }
471
472 let healthy = stale_calls.is_empty() && errored == 0;
473
474 IpcIntegrityReport {
475 total_calls,
476 completed,
477 pending,
478 errored,
479 stale_calls,
480 error_calls,
481 healthy,
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
490#[serde(rename_all = "snake_case")]
491#[non_exhaustive]
492pub enum AssertionCondition {
493 Equals,
495 NotEquals,
497 Contains,
499 GreaterThan,
501 LessThan,
503 Truthy,
505 Falsy,
507 Exists,
509 TypeIs,
511}
512
513impl std::str::FromStr for AssertionCondition {
514 type Err = crate::error::VictauriError;
515
516 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
517 match s {
518 "equals" => Ok(Self::Equals),
519 "not_equals" => Ok(Self::NotEquals),
520 "contains" => Ok(Self::Contains),
521 "greater_than" => Ok(Self::GreaterThan),
522 "less_than" => Ok(Self::LessThan),
523 "truthy" => Ok(Self::Truthy),
524 "falsy" => Ok(Self::Falsy),
525 "exists" => Ok(Self::Exists),
526 "type_is" => Ok(Self::TypeIs),
527 other => Err(crate::error::VictauriError::UnknownCondition {
528 condition: other.to_string(),
529 }),
530 }
531 }
532}
533
534impl std::fmt::Display for AssertionCondition {
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 let s = match self {
537 Self::Equals => "equals",
538 Self::NotEquals => "not_equals",
539 Self::Contains => "contains",
540 Self::GreaterThan => "greater_than",
541 Self::LessThan => "less_than",
542 Self::Truthy => "truthy",
543 Self::Falsy => "falsy",
544 Self::Exists => "exists",
545 Self::TypeIs => "type_is",
546 };
547 f.write_str(s)
548 }
549}
550
551#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
553pub struct SemanticAssertion {
554 pub label: String,
556 pub condition: AssertionCondition,
558 pub expected: serde_json::Value,
560}
561
562#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
564pub struct AssertionResult {
565 pub label: String,
567 pub passed: bool,
569 pub actual: serde_json::Value,
571 pub expected: serde_json::Value,
573 pub message: Option<String>,
575}
576
577fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
578 v.as_f64()
579 .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
580}
581
582fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
583 if a == b {
584 return true;
585 }
586 if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
587 (na - nb).abs() < f64::EPSILON
588 } else {
589 let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
590 let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
591 sa == sb
592 }
593}
594
595#[must_use]
610pub fn evaluate_assertion(
611 actual: serde_json::Value,
612 assertion: &SemanticAssertion,
613) -> AssertionResult {
614 let passed = match assertion.condition {
615 AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
616 AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
617 AssertionCondition::Contains => match (&actual, &assertion.expected) {
618 (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
619 (serde_json::Value::Array(arr), val) => arr.contains(val),
620 _ => false,
621 },
622 AssertionCondition::GreaterThan => {
623 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
624 (Some(a), Some(e)) => a > e,
625 _ => false,
626 }
627 }
628 AssertionCondition::LessThan => {
629 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
630 (Some(a), Some(e)) => a < e,
631 _ => false,
632 }
633 }
634 AssertionCondition::Truthy => match &actual {
635 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
636 serde_json::Value::String(s) => !s.is_empty(),
637 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
638 _ => true,
639 },
640 AssertionCondition::Falsy => match &actual {
641 serde_json::Value::Null | serde_json::Value::Bool(false) => true,
642 serde_json::Value::String(s) => s.is_empty(),
643 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
644 _ => false,
645 },
646 AssertionCondition::Exists => actual != serde_json::Value::Null,
647 AssertionCondition::TypeIs => {
648 let type_name = assertion.expected.as_str().unwrap_or("");
649 match type_name {
650 "string" => actual.is_string(),
651 "number" => actual.is_number(),
652 "boolean" => actual.is_boolean(),
653 "array" => actual.is_array(),
654 "object" => actual.is_object(),
655 "null" => actual.is_null(),
656 _ => false,
657 }
658 }
659 };
660
661 let message = if passed {
662 None
663 } else {
664 Some(format!(
665 "Assertion '{}' failed: expected {} {:?}, got {:?}",
666 assertion.label, assertion.condition, assertion.expected, actual
667 ))
668 };
669
670 AssertionResult {
671 label: assertion.label.clone(),
672 passed,
673 actual,
674 expected: assertion.expected.clone(),
675 message,
676 }
677}