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);
34 let passed = divergences.is_empty();
35 VerificationResult {
36 passed,
37 frontend_state,
38 backend_state,
39 divergences,
40 }
41}
42
43fn compare_values(
44 path: &str,
45 frontend: &serde_json::Value,
46 backend: &serde_json::Value,
47 divergences: &mut Vec<Divergence>,
48) {
49 if frontend == backend {
50 return;
51 }
52
53 match (frontend, backend) {
54 (serde_json::Value::Object(f_map), serde_json::Value::Object(b_map)) => {
55 for (key, f_val) in f_map {
56 let child_path = if path.is_empty() {
57 key.clone()
58 } else {
59 format!("{path}.{key}")
60 };
61 match b_map.get(key) {
62 Some(b_val) => compare_values(&child_path, f_val, b_val, divergences),
63 None => divergences.push(Divergence {
64 path: child_path,
65 frontend_value: f_val.clone(),
66 backend_value: serde_json::Value::Null,
67 severity: DivergenceSeverity::Warning,
68 }),
69 }
70 }
71 for key in b_map.keys() {
72 if !f_map.contains_key(key) {
73 let child_path = if path.is_empty() {
74 key.clone()
75 } else {
76 format!("{path}.{key}")
77 };
78 divergences.push(Divergence {
79 path: child_path,
80 frontend_value: serde_json::Value::Null,
81 backend_value: b_map[key].clone(),
82 severity: DivergenceSeverity::Warning,
83 });
84 }
85 }
86 }
87 (serde_json::Value::Array(f_arr), serde_json::Value::Array(b_arr)) => {
88 let max_len = f_arr.len().max(b_arr.len());
89 for i in 0..max_len {
90 let child_path = if path.is_empty() {
91 format!("[{i}]")
92 } else {
93 format!("{path}[{i}]")
94 };
95 match (f_arr.get(i), b_arr.get(i)) {
96 (Some(f_val), Some(b_val)) => {
97 compare_values(&child_path, f_val, b_val, divergences);
98 }
99 (Some(f_val), None) => divergences.push(Divergence {
100 path: child_path,
101 frontend_value: f_val.clone(),
102 backend_value: serde_json::Value::Null,
103 severity: DivergenceSeverity::Warning,
104 }),
105 (None, Some(b_val)) => divergences.push(Divergence {
106 path: child_path,
107 frontend_value: serde_json::Value::Null,
108 backend_value: b_val.clone(),
109 severity: DivergenceSeverity::Warning,
110 }),
111 (None, None) => {}
112 }
113 }
114 }
115 _ => {
116 let severity = classify_severity(frontend, backend);
117 divergences.push(Divergence {
118 path: if path.is_empty() {
119 "$".to_string()
120 } else {
121 path.to_string()
122 },
123 frontend_value: frontend.clone(),
124 backend_value: backend.clone(),
125 severity,
126 });
127 }
128 }
129}
130
131fn classify_severity(
132 frontend: &serde_json::Value,
133 backend: &serde_json::Value,
134) -> DivergenceSeverity {
135 match (frontend, backend) {
136 (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => DivergenceSeverity::Warning,
137 (serde_json::Value::Number(f), serde_json::Value::Number(b)) => {
138 match (f.as_f64(), b.as_f64()) {
139 (Some(fv), Some(bv)) if (fv - bv).abs() < FLOAT_EPSILON => DivergenceSeverity::Info,
140 _ => DivergenceSeverity::Error,
141 }
142 }
143 _ => DivergenceSeverity::Error,
144 }
145}
146
147#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
151pub struct GhostCommandReport {
152 pub ghost_commands: Vec<GhostCommand>,
154 pub total_frontend_commands: usize,
156 pub total_registry_commands: usize,
158}
159
160#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
162pub struct GhostCommand {
163 pub name: String,
165 pub source: GhostSource,
167 pub description: Option<String>,
169}
170
171#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
173#[non_exhaustive]
174pub enum GhostSource {
175 FrontendOnly,
177 RegistryOnly,
179}
180
181impl fmt::Display for GhostSource {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 let s = match self {
184 Self::FrontendOnly => "frontend-only",
185 Self::RegistryOnly => "registry-only",
186 };
187 f.write_str(s)
188 }
189}
190
191impl fmt::Display for GhostCommand {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(f, "{} ({})", self.name, self.source)
194 }
195}
196
197impl fmt::Display for GhostCommandReport {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 let n = self.ghost_commands.len();
221 write!(
222 f,
223 "{n} ghost command(s) ({} frontend, {} registry)",
224 self.total_frontend_commands, self.total_registry_commands
225 )
226 }
227}
228
229impl fmt::Display for IpcIntegrityReport {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 if self.healthy {
265 write!(
266 f,
267 "IPC healthy: {}/{} completed",
268 self.completed, self.total_calls
269 )
270 } else {
271 let stale = self.stale_calls.len();
272 write!(
273 f,
274 "IPC unhealthy: {stale} stale, {} errored of {} calls",
275 self.errored, self.total_calls
276 )
277 }
278 }
279}
280
281#[must_use]
297pub fn detect_ghost_commands(
298 frontend_commands: &[String],
299 registry: &CommandRegistry,
300) -> GhostCommandReport {
301 let registry_list = registry.list();
302 let registry_names: std::collections::HashSet<&str> =
303 registry_list.iter().map(|c| c.name.as_str()).collect();
304 let frontend_set: std::collections::HashSet<&str> = frontend_commands
305 .iter()
306 .map(std::string::String::as_str)
307 .collect();
308
309 let mut ghost_commands = Vec::new();
310
311 for name in &frontend_set {
312 if !registry_names.contains(name) {
313 ghost_commands.push(GhostCommand {
314 name: name.to_string(),
315 source: GhostSource::FrontendOnly,
316 description: Some(
317 "Command invoked from frontend but not registered in backend".to_string(),
318 ),
319 });
320 }
321 }
322
323 for cmd in ®istry_list {
324 if !frontend_set.contains(cmd.name.as_str()) {
325 ghost_commands.push(GhostCommand {
326 name: cmd.name.clone(),
327 source: GhostSource::RegistryOnly,
328 description: cmd.description.clone(),
329 });
330 }
331 }
332
333 ghost_commands.sort_by(|a, b| a.name.cmp(&b.name));
334
335 GhostCommandReport {
336 ghost_commands,
337 total_frontend_commands: frontend_set.len(),
338 total_registry_commands: registry_list.len(),
339 }
340}
341
342#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
346pub struct IpcIntegrityReport {
347 pub total_calls: usize,
349 pub completed: usize,
351 pub pending: usize,
353 pub errored: usize,
355 pub stale_calls: Vec<StaleCall>,
357 pub error_calls: Vec<ErrorCall>,
359 pub healthy: bool,
361}
362
363#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
365pub struct StaleCall {
366 pub id: String,
368 pub command: String,
370 pub timestamp: DateTime<Utc>,
372 pub age_ms: i64,
374 pub webview_label: String,
376}
377
378#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
380pub struct ErrorCall {
381 pub id: String,
383 pub command: String,
385 pub timestamp: DateTime<Utc>,
387 pub error: String,
389 pub webview_label: String,
391}
392
393#[must_use]
407pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
408 let now = Utc::now();
409 let calls = event_log.ipc_calls();
410 let total_calls = calls.len();
411 let mut completed = 0usize;
412 let mut pending = 0usize;
413 let mut errored = 0usize;
414 let mut stale_calls = Vec::new();
415 let mut error_calls = Vec::new();
416
417 for call in &calls {
418 match &call.result {
419 IpcResult::Ok(_) => completed += 1,
420 IpcResult::Pending => {
421 pending += 1;
422 let age_ms = (now - call.timestamp).num_milliseconds();
423 if age_ms >= stale_threshold_ms {
424 stale_calls.push(StaleCall {
425 id: call.id.clone(),
426 command: call.command.clone(),
427 timestamp: call.timestamp,
428 age_ms,
429 webview_label: call.webview_label.clone(),
430 });
431 }
432 }
433 IpcResult::Err(e) => {
434 errored += 1;
435 error_calls.push(ErrorCall {
436 id: call.id.clone(),
437 command: call.command.clone(),
438 timestamp: call.timestamp,
439 error: e.clone(),
440 webview_label: call.webview_label.clone(),
441 });
442 }
443 }
444 }
445
446 let healthy = stale_calls.is_empty() && errored == 0;
447
448 IpcIntegrityReport {
449 total_calls,
450 completed,
451 pending,
452 errored,
453 stale_calls,
454 error_calls,
455 healthy,
456 }
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
464#[serde(rename_all = "snake_case")]
465#[non_exhaustive]
466pub enum AssertionCondition {
467 Equals,
469 NotEquals,
471 Contains,
473 GreaterThan,
475 LessThan,
477 Truthy,
479 Falsy,
481 Exists,
483 TypeIs,
485}
486
487impl std::str::FromStr for AssertionCondition {
488 type Err = crate::error::VictauriError;
489
490 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
491 match s {
492 "equals" => Ok(Self::Equals),
493 "not_equals" => Ok(Self::NotEquals),
494 "contains" => Ok(Self::Contains),
495 "greater_than" => Ok(Self::GreaterThan),
496 "less_than" => Ok(Self::LessThan),
497 "truthy" => Ok(Self::Truthy),
498 "falsy" => Ok(Self::Falsy),
499 "exists" => Ok(Self::Exists),
500 "type_is" => Ok(Self::TypeIs),
501 other => Err(crate::error::VictauriError::UnknownCondition {
502 condition: other.to_string(),
503 }),
504 }
505 }
506}
507
508impl std::fmt::Display for AssertionCondition {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 let s = match self {
511 Self::Equals => "equals",
512 Self::NotEquals => "not_equals",
513 Self::Contains => "contains",
514 Self::GreaterThan => "greater_than",
515 Self::LessThan => "less_than",
516 Self::Truthy => "truthy",
517 Self::Falsy => "falsy",
518 Self::Exists => "exists",
519 Self::TypeIs => "type_is",
520 };
521 f.write_str(s)
522 }
523}
524
525#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
527pub struct SemanticAssertion {
528 pub label: String,
530 pub condition: AssertionCondition,
532 pub expected: serde_json::Value,
534}
535
536#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
538pub struct AssertionResult {
539 pub label: String,
541 pub passed: bool,
543 pub actual: serde_json::Value,
545 pub expected: serde_json::Value,
547 pub message: Option<String>,
549}
550
551#[must_use]
566pub fn evaluate_assertion(
567 actual: serde_json::Value,
568 assertion: &SemanticAssertion,
569) -> AssertionResult {
570 let passed = match assertion.condition {
571 AssertionCondition::Equals => actual == assertion.expected,
572 AssertionCondition::NotEquals => actual != assertion.expected,
573 AssertionCondition::Contains => match (&actual, &assertion.expected) {
574 (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
575 (serde_json::Value::Array(arr), val) => arr.contains(val),
576 _ => false,
577 },
578 AssertionCondition::GreaterThan => match (actual.as_f64(), assertion.expected.as_f64()) {
579 (Some(a), Some(e)) => a > e,
580 _ => false,
581 },
582 AssertionCondition::LessThan => match (actual.as_f64(), assertion.expected.as_f64()) {
583 (Some(a), Some(e)) => a < e,
584 _ => false,
585 },
586 AssertionCondition::Truthy => match &actual {
587 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
588 serde_json::Value::String(s) => !s.is_empty(),
589 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
590 _ => true,
591 },
592 AssertionCondition::Falsy => match &actual {
593 serde_json::Value::Null | serde_json::Value::Bool(false) => true,
594 serde_json::Value::String(s) => s.is_empty(),
595 serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
596 _ => false,
597 },
598 AssertionCondition::Exists => actual != serde_json::Value::Null,
599 AssertionCondition::TypeIs => {
600 let type_name = assertion.expected.as_str().unwrap_or("");
601 match type_name {
602 "string" => actual.is_string(),
603 "number" => actual.is_number(),
604 "boolean" => actual.is_boolean(),
605 "array" => actual.is_array(),
606 "object" => actual.is_object(),
607 "null" => actual.is_null(),
608 _ => false,
609 }
610 }
611 };
612
613 let message = if passed {
614 None
615 } else {
616 Some(format!(
617 "Assertion '{}' failed: expected {} {:?}, got {:?}",
618 assertion.label, assertion.condition, assertion.expected, actual
619 ))
620 };
621
622 AssertionResult {
623 label: assertion.label.clone(),
624 passed,
625 actual,
626 expected: assertion.expected.clone(),
627 message,
628 }
629}