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)]
183pub struct GhostCommandReport {
184 pub frontend_only: Vec<GhostCommand>,
186 pub registry_only: Vec<GhostCommand>,
188 pub total_frontend_commands: usize,
190 pub total_registry_commands: usize,
192}
193
194#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
196pub struct GhostCommand {
197 pub name: String,
199 pub source: GhostSource,
201 pub description: Option<String>,
203}
204
205#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub enum GhostSource {
209 FrontendOnly,
211 RegistryOnly,
213}
214
215impl fmt::Display for GhostSource {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 let s = match self {
218 Self::FrontendOnly => "frontend-only",
219 Self::RegistryOnly => "registry-only",
220 };
221 f.write_str(s)
222 }
223}
224
225impl fmt::Display for GhostCommand {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(f, "{} ({})", self.name, self.source)
228 }
229}
230
231impl fmt::Display for GhostCommandReport {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 let n = self.frontend_only.len();
256 write!(
257 f,
258 "{n} ghost command(s) ({} frontend, {} registry)",
259 self.total_frontend_commands, self.total_registry_commands
260 )
261 }
262}
263
264impl fmt::Display for IpcIntegrityReport {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 if self.healthy {
300 write!(
301 f,
302 "IPC healthy: {}/{} completed",
303 self.completed, self.total_calls
304 )
305 } else {
306 let stale = self.stale_calls.len();
307 write!(
308 f,
309 "IPC unhealthy: {stale} stale, {} errored of {} calls",
310 self.errored, self.total_calls
311 )
312 }
313 }
314}
315
316#[must_use]
332pub fn detect_ghost_commands(
333 frontend_commands: &[String],
334 registry: &CommandRegistry,
335) -> GhostCommandReport {
336 let registry_list = registry.list();
337 let registry_names: std::collections::HashSet<&str> =
338 registry_list.iter().map(|c| c.name.as_str()).collect();
339 let frontend_set: std::collections::HashSet<&str> = frontend_commands
340 .iter()
341 .map(std::string::String::as_str)
342 .collect();
343
344 let mut frontend_only = Vec::new();
345 let mut registry_only = Vec::new();
346
347 for name in &frontend_set {
348 if !registry_names.contains(name) {
349 frontend_only.push(GhostCommand {
350 name: name.to_string(),
351 source: GhostSource::FrontendOnly,
352 description: Some(
353 "Command invoked from frontend but not registered in backend".to_string(),
354 ),
355 });
356 }
357 }
358
359 for cmd in ®istry_list {
360 if !frontend_set.contains(cmd.name.as_str()) {
361 registry_only.push(GhostCommand {
362 name: cmd.name.clone(),
363 source: GhostSource::RegistryOnly,
364 description: cmd.description.clone(),
365 });
366 }
367 }
368
369 frontend_only.sort_by(|a, b| a.name.cmp(&b.name));
370 registry_only.sort_by(|a, b| a.name.cmp(&b.name));
371
372 GhostCommandReport {
373 frontend_only,
374 registry_only,
375 total_frontend_commands: frontend_set.len(),
376 total_registry_commands: registry_list.len(),
377 }
378}
379
380#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
384pub struct IpcIntegrityReport {
385 pub total_calls: usize,
387 pub completed: usize,
389 pub pending: usize,
391 pub errored: usize,
393 pub stale_calls: Vec<StaleCall>,
395 pub error_calls: Vec<ErrorCall>,
397 pub healthy: bool,
399}
400
401#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
403pub struct StaleCall {
404 pub id: String,
406 pub command: String,
408 pub timestamp: DateTime<Utc>,
410 pub age_ms: i64,
412 pub webview_label: String,
414}
415
416#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
418pub struct ErrorCall {
419 pub id: String,
421 pub command: String,
423 pub timestamp: DateTime<Utc>,
425 pub error: String,
427 pub webview_label: String,
429}
430
431#[must_use]
445pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
446 let now = Utc::now();
447 let calls = event_log.ipc_calls();
448 let total_calls = calls.len();
449 let mut completed = 0usize;
450 let mut pending = 0usize;
451 let mut errored = 0usize;
452 let mut stale_calls = Vec::new();
453 let mut error_calls = Vec::new();
454
455 for call in &calls {
456 match &call.result {
457 IpcResult::Ok(_) => completed += 1,
458 IpcResult::Pending => {
459 pending += 1;
460 let age_ms = (now - call.timestamp).num_milliseconds();
461 if age_ms >= stale_threshold_ms {
462 stale_calls.push(StaleCall {
463 id: call.id.clone(),
464 command: call.command.clone(),
465 timestamp: call.timestamp,
466 age_ms,
467 webview_label: call.webview_label.clone(),
468 });
469 }
470 }
471 IpcResult::Err(e) => {
472 errored += 1;
473 error_calls.push(ErrorCall {
474 id: call.id.clone(),
475 command: call.command.clone(),
476 timestamp: call.timestamp,
477 error: e.clone(),
478 webview_label: call.webview_label.clone(),
479 });
480 }
481 }
482 }
483
484 let healthy = stale_calls.is_empty() && errored == 0;
485
486 IpcIntegrityReport {
487 total_calls,
488 completed,
489 pending,
490 errored,
491 stale_calls,
492 error_calls,
493 healthy,
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
502#[serde(rename_all = "snake_case")]
503#[non_exhaustive]
504pub enum AssertionCondition {
505 Equals,
507 NotEquals,
509 Contains,
511 GreaterThan,
513 LessThan,
515 Truthy,
517 Falsy,
519 Exists,
521 TypeIs,
523}
524
525impl std::str::FromStr for AssertionCondition {
526 type Err = crate::error::VictauriError;
527
528 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
529 match s {
530 "equals" => Ok(Self::Equals),
531 "not_equals" => Ok(Self::NotEquals),
532 "contains" => Ok(Self::Contains),
533 "greater_than" => Ok(Self::GreaterThan),
534 "less_than" => Ok(Self::LessThan),
535 "truthy" => Ok(Self::Truthy),
536 "falsy" => Ok(Self::Falsy),
537 "exists" => Ok(Self::Exists),
538 "type_is" => Ok(Self::TypeIs),
539 other => Err(crate::error::VictauriError::UnknownCondition {
540 condition: other.to_string(),
541 }),
542 }
543 }
544}
545
546impl std::fmt::Display for AssertionCondition {
547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548 let s = match self {
549 Self::Equals => "equals",
550 Self::NotEquals => "not_equals",
551 Self::Contains => "contains",
552 Self::GreaterThan => "greater_than",
553 Self::LessThan => "less_than",
554 Self::Truthy => "truthy",
555 Self::Falsy => "falsy",
556 Self::Exists => "exists",
557 Self::TypeIs => "type_is",
558 };
559 f.write_str(s)
560 }
561}
562
563#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
565pub struct SemanticAssertion {
566 pub label: String,
568 pub condition: AssertionCondition,
570 pub expected: serde_json::Value,
572}
573
574#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
576pub struct AssertionResult {
577 pub label: String,
579 pub passed: bool,
581 pub actual: serde_json::Value,
583 pub expected: serde_json::Value,
585 pub message: Option<String>,
587}
588
589fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
590 v.as_f64()
591 .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
592}
593
594fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
595 if a == b {
596 return true;
597 }
598 if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
599 (na - nb).abs() < f64::EPSILON
600 } else {
601 let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
602 let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
603 sa == sb
604 }
605}
606
607#[must_use]
622pub fn evaluate_assertion(
623 actual: serde_json::Value,
624 assertion: &SemanticAssertion,
625) -> AssertionResult {
626 let passed = match assertion.condition {
627 AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
628 AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
629 AssertionCondition::Contains => match (&actual, &assertion.expected) {
630 (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
631 (serde_json::Value::Array(arr), val) => arr.contains(val),
632 _ => false,
633 },
634 AssertionCondition::GreaterThan => {
635 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
636 (Some(a), Some(e)) => a > e,
637 _ => false,
638 }
639 }
640 AssertionCondition::LessThan => {
641 match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
642 (Some(a), Some(e)) => a < e,
643 _ => false,
644 }
645 }
646 AssertionCondition::Truthy => match &actual {
647 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
648 serde_json::Value::String(s) => !s.is_empty(),
649 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
650 _ => true,
651 },
652 AssertionCondition::Falsy => match &actual {
653 serde_json::Value::Null | serde_json::Value::Bool(false) => true,
654 serde_json::Value::String(s) => s.is_empty(),
655 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
656 _ => false,
657 },
658 AssertionCondition::Exists => actual != serde_json::Value::Null,
659 AssertionCondition::TypeIs => {
660 let type_name = assertion.expected.as_str().unwrap_or("");
661 match type_name {
662 "string" => actual.is_string(),
663 "number" => actual.is_number(),
664 "boolean" => actual.is_boolean(),
665 "array" => actual.is_array(),
666 "object" => actual.is_object(),
667 "null" => actual.is_null(),
668 _ => false,
669 }
670 }
671 };
672
673 let message = if passed {
674 None
675 } else {
676 Some(format!(
677 "Assertion '{}' failed: expected {} {:?}, got {:?}",
678 assertion.label, assertion.condition, assertion.expected, actual
679 ))
680 };
681
682 AssertionResult {
683 label: assertion.label.clone(),
684 passed,
685 actual,
686 expected: assertion.expected.clone(),
687 message,
688 }
689}