1use crate::event::{EventLog, IpcResult};
2use crate::registry::CommandRegistry;
3use crate::types::{Divergence, DivergenceSeverity, VerificationResult};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7pub fn verify_state(
20 frontend_state: serde_json::Value,
21 backend_state: serde_json::Value,
22) -> VerificationResult {
23 let mut divergences = Vec::new();
24 compare_values("", &frontend_state, &backend_state, &mut divergences);
25 let passed = divergences.is_empty();
26 VerificationResult {
27 passed,
28 frontend_state,
29 backend_state,
30 divergences,
31 }
32}
33
34fn compare_values(
35 path: &str,
36 frontend: &serde_json::Value,
37 backend: &serde_json::Value,
38 divergences: &mut Vec<Divergence>,
39) {
40 if frontend == backend {
41 return;
42 }
43
44 match (frontend, backend) {
45 (serde_json::Value::Object(f_map), serde_json::Value::Object(b_map)) => {
46 for (key, f_val) in f_map {
47 let child_path = if path.is_empty() {
48 key.clone()
49 } else {
50 format!("{path}.{key}")
51 };
52 match b_map.get(key) {
53 Some(b_val) => compare_values(&child_path, f_val, b_val, divergences),
54 None => divergences.push(Divergence {
55 path: child_path,
56 frontend_value: f_val.clone(),
57 backend_value: serde_json::Value::Null,
58 severity: DivergenceSeverity::Warning,
59 }),
60 }
61 }
62 for key in b_map.keys() {
63 if !f_map.contains_key(key) {
64 let child_path = if path.is_empty() {
65 key.clone()
66 } else {
67 format!("{path}.{key}")
68 };
69 divergences.push(Divergence {
70 path: child_path,
71 frontend_value: serde_json::Value::Null,
72 backend_value: b_map[key].clone(),
73 severity: DivergenceSeverity::Warning,
74 });
75 }
76 }
77 }
78 (serde_json::Value::Array(f_arr), serde_json::Value::Array(b_arr)) => {
79 let max_len = f_arr.len().max(b_arr.len());
80 for i in 0..max_len {
81 let child_path = if path.is_empty() {
82 format!("[{i}]")
83 } else {
84 format!("{path}[{i}]")
85 };
86 match (f_arr.get(i), b_arr.get(i)) {
87 (Some(f_val), Some(b_val)) => {
88 compare_values(&child_path, f_val, b_val, divergences);
89 }
90 (Some(f_val), None) => divergences.push(Divergence {
91 path: child_path,
92 frontend_value: f_val.clone(),
93 backend_value: serde_json::Value::Null,
94 severity: DivergenceSeverity::Warning,
95 }),
96 (None, Some(b_val)) => divergences.push(Divergence {
97 path: child_path,
98 frontend_value: serde_json::Value::Null,
99 backend_value: b_val.clone(),
100 severity: DivergenceSeverity::Warning,
101 }),
102 (None, None) => {}
103 }
104 }
105 }
106 _ => {
107 let severity = classify_severity(frontend, backend);
108 divergences.push(Divergence {
109 path: if path.is_empty() {
110 "$".to_string()
111 } else {
112 path.to_string()
113 },
114 frontend_value: frontend.clone(),
115 backend_value: backend.clone(),
116 severity,
117 });
118 }
119 }
120}
121
122fn classify_severity(
123 frontend: &serde_json::Value,
124 backend: &serde_json::Value,
125) -> DivergenceSeverity {
126 match (frontend, backend) {
127 (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => DivergenceSeverity::Warning,
128 (serde_json::Value::Number(f), serde_json::Value::Number(b)) => {
129 match (f.as_f64(), b.as_f64()) {
130 (Some(fv), Some(bv)) if (fv - bv).abs() < f64::EPSILON => DivergenceSeverity::Info,
131 _ => DivergenceSeverity::Error,
132 }
133 }
134 _ => DivergenceSeverity::Error,
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct GhostCommandReport {
143 pub ghost_commands: Vec<GhostCommand>,
145 pub total_frontend_commands: usize,
147 pub total_registry_commands: usize,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct GhostCommand {
154 pub name: String,
156 pub source: GhostSource,
158 pub description: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164#[non_exhaustive]
165pub enum GhostSource {
166 FrontendOnly,
168 RegistryOnly,
170}
171
172pub fn detect_ghost_commands(
174 frontend_commands: &[String],
175 registry: &CommandRegistry,
176) -> GhostCommandReport {
177 let registry_list = registry.list();
178 let registry_names: std::collections::HashSet<&str> =
179 registry_list.iter().map(|c| c.name.as_str()).collect();
180 let frontend_set: std::collections::HashSet<&str> =
181 frontend_commands.iter().map(|s| s.as_str()).collect();
182
183 let mut ghost_commands = Vec::new();
184
185 for name in &frontend_set {
186 if !registry_names.contains(name) {
187 ghost_commands.push(GhostCommand {
188 name: name.to_string(),
189 source: GhostSource::FrontendOnly,
190 description: Some(
191 "Command invoked from frontend but not registered in backend".to_string(),
192 ),
193 });
194 }
195 }
196
197 for cmd in ®istry_list {
198 if !frontend_set.contains(cmd.name.as_str()) {
199 ghost_commands.push(GhostCommand {
200 name: cmd.name.clone(),
201 source: GhostSource::RegistryOnly,
202 description: cmd.description.clone(),
203 });
204 }
205 }
206
207 ghost_commands.sort_by(|a, b| a.name.cmp(&b.name));
208
209 GhostCommandReport {
210 ghost_commands,
211 total_frontend_commands: frontend_commands.len(),
212 total_registry_commands: registry_list.len(),
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct IpcIntegrityReport {
221 pub total_calls: usize,
223 pub completed: usize,
225 pub pending: usize,
227 pub errored: usize,
229 pub stale_calls: Vec<StaleCall>,
231 pub error_calls: Vec<ErrorCall>,
233 pub healthy: bool,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct StaleCall {
240 pub id: String,
242 pub command: String,
244 pub timestamp: DateTime<Utc>,
246 pub age_ms: i64,
248 pub webview_label: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ErrorCall {
255 pub id: String,
257 pub command: String,
259 pub timestamp: DateTime<Utc>,
261 pub error: String,
263 pub webview_label: String,
265}
266
267pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
269 let now = Utc::now();
270 let calls = event_log.ipc_calls();
271 let total_calls = calls.len();
272 let mut completed = 0usize;
273 let mut pending = 0usize;
274 let mut errored = 0usize;
275 let mut stale_calls = Vec::new();
276 let mut error_calls = Vec::new();
277
278 for call in &calls {
279 match &call.result {
280 IpcResult::Ok(_) => completed += 1,
281 IpcResult::Pending => {
282 pending += 1;
283 let age_ms = (now - call.timestamp).num_milliseconds();
284 if age_ms >= stale_threshold_ms {
285 stale_calls.push(StaleCall {
286 id: call.id.clone(),
287 command: call.command.clone(),
288 timestamp: call.timestamp,
289 age_ms,
290 webview_label: call.webview_label.clone(),
291 });
292 }
293 }
294 IpcResult::Err(e) => {
295 errored += 1;
296 error_calls.push(ErrorCall {
297 id: call.id.clone(),
298 command: call.command.clone(),
299 timestamp: call.timestamp,
300 error: e.clone(),
301 webview_label: call.webview_label.clone(),
302 });
303 }
304 }
305 }
306
307 let healthy = stale_calls.is_empty() && errored == 0;
308
309 IpcIntegrityReport {
310 total_calls,
311 completed,
312 pending,
313 errored,
314 stale_calls,
315 error_calls,
316 healthy,
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SemanticAssertion {
325 pub label: String,
327 pub condition: String,
329 pub expected: serde_json::Value,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AssertionResult {
336 pub label: String,
338 pub passed: bool,
340 pub actual: serde_json::Value,
342 pub expected: serde_json::Value,
344 pub message: Option<String>,
346}
347
348pub fn evaluate_assertion(
363 actual: serde_json::Value,
364 assertion: &SemanticAssertion,
365) -> AssertionResult {
366 let passed = match assertion.condition.as_str() {
367 "equals" => actual == assertion.expected,
368 "not_equals" => actual != assertion.expected,
369 "contains" => match (&actual, &assertion.expected) {
370 (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
371 (serde_json::Value::Array(arr), val) => arr.contains(val),
372 _ => false,
373 },
374 "greater_than" => match (actual.as_f64(), assertion.expected.as_f64()) {
375 (Some(a), Some(e)) => a > e,
376 _ => false,
377 },
378 "less_than" => match (actual.as_f64(), assertion.expected.as_f64()) {
379 (Some(a), Some(e)) => a < e,
380 _ => false,
381 },
382 "truthy" => {
383 matches!(
384 &actual,
385 serde_json::Value::Bool(true)
386 | serde_json::Value::Number(_)
387 | serde_json::Value::String(_)
388 | serde_json::Value::Array(_)
389 | serde_json::Value::Object(_)
390 ) && actual != serde_json::Value::String(String::new())
391 }
392 "falsy" => {
393 matches!(
394 &actual,
395 serde_json::Value::Null | serde_json::Value::Bool(false)
396 ) || actual == serde_json::Value::String(String::new())
397 || actual == serde_json::json!(0)
398 }
399 "exists" => actual != serde_json::Value::Null,
400 "type_is" => {
401 let type_name = assertion.expected.as_str().unwrap_or("");
402 match type_name {
403 "string" => actual.is_string(),
404 "number" => actual.is_number(),
405 "boolean" => actual.is_boolean(),
406 "array" => actual.is_array(),
407 "object" => actual.is_object(),
408 "null" => actual.is_null(),
409 _ => false,
410 }
411 }
412 unknown => {
413 return AssertionResult {
414 label: assertion.label.clone(),
415 passed: false,
416 actual,
417 expected: assertion.expected.clone(),
418 message: Some(format!(
419 "Unknown assertion condition '{}' in '{}'",
420 unknown, assertion.label
421 )),
422 };
423 }
424 };
425
426 let message = if !passed {
427 Some(format!(
428 "Assertion '{}' failed: expected {} {:?}, got {:?}",
429 assertion.label, assertion.condition, assertion.expected, actual
430 ))
431 } else {
432 None
433 };
434
435 AssertionResult {
436 label: assertion.label.clone(),
437 passed,
438 actual,
439 expected: assertion.expected.clone(),
440 message,
441 }
442}