1#![forbid(unsafe_code)]
7
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write as _;
11
12const FLOAT_EPSILON: f64 = 1e-10;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15enum DiffKind {
16 Root,
17 Registration,
18 Hostcall,
19 Event,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23struct DiffItem {
24 kind: DiffKind,
25 path: String,
26 message: String,
27}
28
29impl DiffItem {
30 fn new(kind: DiffKind, path: impl Into<String>, message: impl Into<String>) -> Self {
31 Self {
32 kind,
33 path: path.into(),
34 message: message.into(),
35 }
36 }
37}
38
39pub fn compare_conformance_output(expected: &Value, actual: &Value) -> Result<(), String> {
53 let mut diffs = Vec::new();
54 compare_conformance_output_inner(expected, actual, &mut diffs);
55 if diffs.is_empty() {
56 Ok(())
57 } else {
58 Err(render_diffs(&diffs))
59 }
60}
61
62fn compare_conformance_output_inner(expected: &Value, actual: &Value, diffs: &mut Vec<DiffItem>) {
63 compare_string_field(expected, actual, "extension_id", DiffKind::Root, diffs);
64 compare_string_field(expected, actual, "name", DiffKind::Root, diffs);
65 compare_string_field(expected, actual, "version", DiffKind::Root, diffs);
66
67 let expected_regs = expected.get("registrations");
69 let actual_regs = actual.get("registrations");
70 compare_registrations(expected_regs, actual_regs, diffs);
71
72 compare_hostcall_log(
74 expected.get("hostcall_log"),
75 actual.get("hostcall_log"),
76 diffs,
77 );
78
79 compare_optional_semantic_value(
81 expected.get("events"),
82 actual.get("events"),
83 "events",
84 DiffKind::Event,
85 diffs,
86 );
87}
88
89fn compare_string_field(
90 expected: &Value,
91 actual: &Value,
92 key: &str,
93 kind: DiffKind,
94 diffs: &mut Vec<DiffItem>,
95) {
96 let left = expected.get(key).and_then(Value::as_str);
97 let right = actual.get(key).and_then(Value::as_str);
98 if left != right {
99 diffs.push(DiffItem::new(
100 kind,
101 key,
102 format!("expected {left:?}, got {right:?}"),
103 ));
104 }
105}
106
107fn compare_registrations(
108 expected: Option<&Value>,
109 actual: Option<&Value>,
110 diffs: &mut Vec<DiffItem>,
111) {
112 let expected = expected.unwrap_or(&Value::Null);
113 let actual = actual.unwrap_or(&Value::Null);
114 let Some(expected_obj) = expected.as_object() else {
115 diffs.push(DiffItem::new(
116 DiffKind::Registration,
117 "registrations",
118 "expected an object",
119 ));
120 return;
121 };
122 let Some(actual_obj) = actual.as_object() else {
123 diffs.push(DiffItem::new(
124 DiffKind::Registration,
125 "registrations",
126 "actual is not an object",
127 ));
128 return;
129 };
130
131 compare_keyed_registration_list(
132 expected_obj.get("commands"),
133 actual_obj.get("commands"),
134 "name",
135 "registrations.commands",
136 diffs,
137 );
138 compare_keyed_registration_list(
139 expected_obj.get("tool_defs"),
140 actual_obj.get("tool_defs"),
141 "name",
142 "registrations.tool_defs",
143 diffs,
144 );
145 compare_keyed_registration_list(
146 expected_obj.get("flags"),
147 actual_obj.get("flags"),
148 "name",
149 "registrations.flags",
150 diffs,
151 );
152 compare_keyed_registration_list(
153 expected_obj.get("providers"),
154 actual_obj.get("providers"),
155 "name",
156 "registrations.providers",
157 diffs,
158 );
159 compare_keyed_registration_list(
160 expected_obj.get("shortcuts"),
161 actual_obj.get("shortcuts"),
162 "key_id",
163 "registrations.shortcuts",
164 diffs,
165 );
166 compare_keyed_registration_list(
167 expected_obj.get("models"),
168 actual_obj.get("models"),
169 "id",
170 "registrations.models",
171 diffs,
172 );
173
174 let expected_hooks = expected_obj.get("event_hooks");
176 let actual_hooks = actual_obj.get("event_hooks");
177 compare_string_set(
178 expected_hooks,
179 actual_hooks,
180 "registrations.event_hooks",
181 diffs,
182 );
183}
184
185fn compare_keyed_registration_list(
186 expected: Option<&Value>,
187 actual: Option<&Value>,
188 key_field: &str,
189 path: &str,
190 diffs: &mut Vec<DiffItem>,
191) {
192 let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Registration);
193 let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Registration);
194
195 let expected_map = index_by_string_key(&expected_items, key_field, path, diffs);
196 let actual_map = index_by_string_key(&actual_items, key_field, path, diffs);
197
198 let mut keys = BTreeSet::new();
199 keys.extend(expected_map.keys().cloned());
200 keys.extend(actual_map.keys().cloned());
201
202 for key in keys {
203 let expected_value = expected_map.get(&key);
204 let actual_value = actual_map.get(&key);
205 match (expected_value, actual_value) {
206 (Some(_), None) => diffs.push(DiffItem::new(
207 DiffKind::Registration,
208 format!("{path}[{key_field}={key}]"),
209 "missing in actual",
210 )),
211 (None, Some(_)) => diffs.push(DiffItem::new(
212 DiffKind::Registration,
213 format!("{path}[{key_field}={key}]"),
214 "extra in actual",
215 )),
216 (Some(left), Some(right)) => {
217 compare_semantic_value(
218 left,
219 right,
220 &format!("{path}[{key_field}={key}]"),
221 Some(key_field),
222 DiffKind::Registration,
223 diffs,
224 );
225 }
226 (None, None) => {}
227 }
228 }
229}
230
231fn compare_string_set(
232 expected: Option<&Value>,
233 actual: Option<&Value>,
234 path: &str,
235 diffs: &mut Vec<DiffItem>,
236) {
237 let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Registration);
238 let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Registration);
239
240 let expected_set = expected_items
241 .iter()
242 .filter_map(Value::as_str)
243 .map(str::to_string)
244 .collect::<BTreeSet<_>>();
245 let actual_set = actual_items
246 .iter()
247 .filter_map(Value::as_str)
248 .map(str::to_string)
249 .collect::<BTreeSet<_>>();
250
251 if expected_set == actual_set {
252 return;
253 }
254
255 let missing = expected_set
256 .difference(&actual_set)
257 .cloned()
258 .collect::<Vec<_>>();
259 let extra = actual_set
260 .difference(&expected_set)
261 .cloned()
262 .collect::<Vec<_>>();
263
264 if !missing.is_empty() {
265 diffs.push(DiffItem::new(
266 DiffKind::Registration,
267 path,
268 format!("missing: {}", missing.join(", ")),
269 ));
270 }
271 if !extra.is_empty() {
272 diffs.push(DiffItem::new(
273 DiffKind::Registration,
274 path,
275 format!("extra: {}", extra.join(", ")),
276 ));
277 }
278}
279
280fn compare_hostcall_log(
281 expected: Option<&Value>,
282 actual: Option<&Value>,
283 diffs: &mut Vec<DiffItem>,
284) {
285 let path = "hostcall_log";
286 let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Hostcall);
287 let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Hostcall);
288
289 if expected_items.len() != actual_items.len() {
290 diffs.push(DiffItem::new(
291 DiffKind::Hostcall,
292 path,
293 format!(
294 "length mismatch: expected {}, got {}",
295 expected_items.len(),
296 actual_items.len()
297 ),
298 ));
299 }
300
301 let count = expected_items.len().min(actual_items.len());
302 for idx in 0..count {
303 let left = &expected_items[idx];
304 let right = &actual_items[idx];
305 compare_semantic_value(
306 left,
307 right,
308 &format!("{path}[{idx}]"),
309 None,
310 DiffKind::Hostcall,
311 diffs,
312 );
313 }
314}
315
316fn compare_optional_semantic_value(
317 expected: Option<&Value>,
318 actual: Option<&Value>,
319 path: &str,
320 kind: DiffKind,
321 diffs: &mut Vec<DiffItem>,
322) {
323 if expected.is_none() && actual.is_none() {
324 return;
325 }
326 let left = expected.unwrap_or(&Value::Null);
327 let right = actual.unwrap_or(&Value::Null);
328 compare_semantic_value(left, right, path, None, kind, diffs);
329}
330
331fn value_as_array_or_empty(
332 value: Option<&Value>,
333 path: &str,
334 diffs: &mut Vec<DiffItem>,
335 kind: DiffKind,
336) -> Vec<Value> {
337 match value {
338 None | Some(Value::Null) => Vec::new(),
339 Some(Value::Array(items)) => items.clone(),
340 Some(other) => {
341 diffs.push(DiffItem::new(
342 kind,
343 path,
344 format!("expected array, got {}", json_type_name(other)),
345 ));
346 Vec::new()
347 }
348 }
349}
350
351fn index_by_string_key(
352 items: &[Value],
353 key_field: &str,
354 path: &str,
355 diffs: &mut Vec<DiffItem>,
356) -> BTreeMap<String, Value> {
357 let mut out = BTreeMap::new();
358 for (idx, item) in items.iter().enumerate() {
359 let key = item
360 .get(key_field)
361 .and_then(Value::as_str)
362 .map_or("", str::trim);
363 if key.is_empty() {
364 diffs.push(DiffItem::new(
365 DiffKind::Registration,
366 format!("{path}[{idx}]"),
367 format!("missing string key field {key_field:?}"),
368 ));
369 continue;
370 }
371 if out.contains_key(key) {
372 continue;
377 }
378 out.insert(key.to_string(), item.clone());
379 }
380 out
381}
382
383fn compare_semantic_value(
384 expected: &Value,
385 actual: &Value,
386 path: &str,
387 parent_key: Option<&str>,
388 kind: DiffKind,
389 diffs: &mut Vec<DiffItem>,
390) {
391 match (expected, actual) {
394 (Value::Null, Value::Null) => {}
395 (Value::Bool(left), Value::Bool(right)) => {
396 if left != right {
397 diffs.push(DiffItem::new(kind, path, format!("{left} != {right}")));
398 }
399 }
400 (Value::Number(left), Value::Number(right)) => {
401 if !numbers_equal(left, right) {
402 diffs.push(DiffItem::new(
403 kind,
404 path,
405 format!("expected {left}, got {right}"),
406 ));
407 }
408 }
409 (Value::String(left), Value::String(right)) => {
410 if left != right {
411 diffs.push(DiffItem::new(
412 kind,
413 path,
414 format!("expected {left:?}, got {right:?}"),
415 ));
416 }
417 }
418 (Value::Array(left), Value::Array(right)) => {
419 if array_order_insensitive(parent_key) {
420 compare_unordered_array(left, right, path, kind, diffs);
421 } else {
422 compare_ordered_array(left, right, path, kind, diffs);
423 }
424 }
425 (Value::Object(left), Value::Object(right)) => {
426 let mut keys = BTreeSet::new();
427 keys.extend(left.keys().cloned());
428 keys.extend(right.keys().cloned());
429
430 for key in keys {
431 let left_value = left.get(&key);
432 let right_value = right.get(&key);
433 if missing_equals_null_or_empty_array(left_value, right_value) {
434 continue;
435 }
436 let left_value = left_value.unwrap_or(&Value::Null);
437 let right_value = right_value.unwrap_or(&Value::Null);
438 compare_semantic_value(
439 left_value,
440 right_value,
441 &format!("{path}.{key}"),
442 Some(key.as_str()),
443 kind,
444 diffs,
445 );
446 }
447 }
448 _ => {
449 if missing_equals_null_or_empty_array(Some(expected), Some(actual)) {
450 return;
451 }
452 diffs.push(DiffItem::new(
453 kind,
454 path,
455 format!(
456 "type mismatch: expected {}, got {}",
457 json_type_name(expected),
458 json_type_name(actual)
459 ),
460 ));
461 }
462 }
463}
464
465fn compare_ordered_array(
466 expected: &[Value],
467 actual: &[Value],
468 path: &str,
469 kind: DiffKind,
470 diffs: &mut Vec<DiffItem>,
471) {
472 if expected.len() != actual.len() {
473 diffs.push(DiffItem::new(
474 kind,
475 path,
476 format!(
477 "length mismatch: expected {}, got {}",
478 expected.len(),
479 actual.len()
480 ),
481 ));
482 }
483 let count = expected.len().min(actual.len());
484 for idx in 0..count {
485 compare_semantic_value(
486 &expected[idx],
487 &actual[idx],
488 &format!("{path}[{idx}]"),
489 None,
490 kind,
491 diffs,
492 );
493 }
494}
495
496fn compare_unordered_array(
497 expected: &[Value],
498 actual: &[Value],
499 path: &str,
500 kind: DiffKind,
501 diffs: &mut Vec<DiffItem>,
502) {
503 let mut left = expected.to_vec();
504 let mut right = actual.to_vec();
505 left.sort_by_key(stable_value_key);
506 right.sort_by_key(stable_value_key);
507 compare_ordered_array(&left, &right, path, kind, diffs);
508}
509
510fn stable_value_key(value: &Value) -> String {
511 match value {
512 Value::Null => "null".to_string(),
513 Value::Bool(v) => format!("bool:{v}"),
514 Value::Number(v) => format!("num:{v}"),
515 Value::String(v) => format!("str:{v}"),
516 Value::Array(items) => {
517 let mut out = String::new();
518 out.push_str("arr:[");
519 for (idx, item) in items.iter().enumerate() {
520 if idx > 0 {
521 out.push(',');
522 }
523 out.push_str(&stable_value_key(item));
524 }
525 out.push(']');
526 out
527 }
528 Value::Object(map) => {
529 let mut keys = map.keys().cloned().collect::<Vec<_>>();
530 keys.sort();
531 let mut out = String::new();
532 out.push_str("obj:{");
533 for key in keys {
534 out.push_str(&key);
535 out.push('=');
536 if let Some(value) = map.get(&key) {
537 out.push_str(&stable_value_key(value));
538 }
539 out.push(';');
540 }
541 out.push('}');
542 out
543 }
544 }
545}
546
547fn array_order_insensitive(parent_key: Option<&str>) -> bool {
548 matches!(parent_key, Some("required" | "input" | "event_hooks"))
549}
550
551fn missing_equals_null_or_empty_array(left: Option<&Value>, right: Option<&Value>) -> bool {
552 match (left, right) {
553 (None | Some(Value::Null), None) | (None, Some(Value::Null)) => true,
554 (None, Some(Value::Array(items))) | (Some(Value::Array(items)), None) => items.is_empty(),
555 _ => false,
556 }
557}
558
559fn numbers_equal(left: &serde_json::Number, right: &serde_json::Number) -> bool {
560 if left == right {
561 return true;
562 }
563 let left = left.as_f64();
564 let right = right.as_f64();
565 match (left, right) {
566 (Some(left), Some(right)) => (left - right).abs() <= FLOAT_EPSILON + f64::EPSILON,
567 _ => false,
568 }
569}
570
571const fn json_type_name(value: &Value) -> &'static str {
572 match value {
573 Value::Null => "null",
574 Value::Bool(_) => "bool",
575 Value::Number(_) => "number",
576 Value::String(_) => "string",
577 Value::Array(_) => "array",
578 Value::Object(_) => "object",
579 }
580}
581
582fn render_diffs(diffs: &[DiffItem]) -> String {
583 let mut grouped: BTreeMap<DiffKind, Vec<&DiffItem>> = BTreeMap::new();
584 for diff in diffs {
585 grouped.entry(diff.kind).or_default().push(diff);
586 }
587
588 let mut out = String::new();
589 for (kind, items) in grouped {
590 let header = match kind {
591 DiffKind::Root => "ROOT",
592 DiffKind::Registration => "REGISTRATION",
593 DiffKind::Hostcall => "HOSTCALL",
594 DiffKind::Event => "EVENT",
595 };
596 let _ = writeln!(out, "== {header} DIFFS ==");
597 for item in items {
598 let _ = writeln!(out, "- {}: {}", item.path, item.message);
599 }
600 out.push('\n');
601 }
602 out
603}
604
605pub mod report {
610 use chrono::{SecondsFormat, Utc};
611 use serde::{Deserialize, Serialize};
612 use std::collections::BTreeMap;
613 use std::fmt::Write as _;
614
615 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616 #[serde(rename_all = "snake_case")]
617 pub enum ConformanceStatus {
618 Pass,
619 Fail,
620 Skip,
621 Error,
622 }
623
624 impl ConformanceStatus {
625 #[must_use]
626 pub const fn as_upper_str(self) -> &'static str {
627 match self {
628 Self::Pass => "PASS",
629 Self::Fail => "FAIL",
630 Self::Skip => "SKIP",
631 Self::Error => "ERROR",
632 }
633 }
634 }
635
636 #[derive(Debug, Clone, Serialize, Deserialize)]
637 #[serde(rename_all = "camelCase")]
638 pub struct ConformanceDiffEntry {
639 pub category: String,
640 pub path: String,
641 pub message: String,
642 }
643
644 #[derive(Debug, Clone, Serialize, Deserialize)]
645 #[serde(rename_all = "camelCase")]
646 pub struct ExtensionConformanceResult {
647 pub id: String,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub tier: Option<u32>,
650 pub status: ConformanceStatus,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub ts_time_ms: Option<u64>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub rust_time_ms: Option<u64>,
655 #[serde(default, skip_serializing_if = "Vec::is_empty")]
656 pub diffs: Vec<ConformanceDiffEntry>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub notes: Option<String>,
659 }
660
661 fn ratio(passed: u64, total: u64) -> f64 {
662 if total == 0 {
663 0.0
664 } else {
665 #[allow(clippy::cast_precision_loss)]
666 {
667 passed as f64 / total as f64
668 }
669 }
670 }
671
672 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
673 #[serde(rename_all = "camelCase")]
674 pub struct TierSummary {
675 pub total: u64,
676 pub passed: u64,
677 pub failed: u64,
678 pub skipped: u64,
679 pub errors: u64,
680 pub pass_rate: f64,
681 }
682
683 impl TierSummary {
684 fn from_results(results: &[ExtensionConformanceResult]) -> Self {
685 let total = results.len() as u64;
686 let passed = results
687 .iter()
688 .filter(|r| r.status == ConformanceStatus::Pass)
689 .count() as u64;
690 let failed = results
691 .iter()
692 .filter(|r| r.status == ConformanceStatus::Fail)
693 .count() as u64;
694 let skipped = results
695 .iter()
696 .filter(|r| r.status == ConformanceStatus::Skip)
697 .count() as u64;
698 let errors = results
699 .iter()
700 .filter(|r| r.status == ConformanceStatus::Error)
701 .count() as u64;
702
703 let pass_rate = ratio(passed, total);
704
705 Self {
706 total,
707 passed,
708 failed,
709 skipped,
710 errors,
711 pass_rate,
712 }
713 }
714 }
715
716 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
717 #[serde(rename_all = "camelCase")]
718 pub struct ConformanceSummary {
719 pub total: u64,
720 pub passed: u64,
721 pub failed: u64,
722 pub skipped: u64,
723 pub errors: u64,
724 pub pass_rate: f64,
725 pub by_tier: BTreeMap<String, TierSummary>,
726 }
727
728 #[derive(Debug, Clone, Serialize, Deserialize)]
729 #[serde(rename_all = "camelCase")]
730 pub struct ConformanceReport {
731 pub run_id: String,
732 pub timestamp: String,
733 pub summary: ConformanceSummary,
734 pub extensions: Vec<ExtensionConformanceResult>,
735 }
736
737 #[derive(Debug, Clone, Serialize, Deserialize)]
738 #[serde(rename_all = "camelCase")]
739 pub struct ExtensionRegression {
740 pub id: String,
741 pub previous: ConformanceStatus,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub current: Option<ConformanceStatus>,
744 }
745
746 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
747 #[serde(rename_all = "camelCase")]
748 pub struct ConformanceRegression {
749 pub compared_total: u64,
751 pub previous_passed: u64,
752 pub current_passed: u64,
753 pub previous_pass_rate: f64,
754 pub current_pass_rate: f64,
755 pub pass_rate_delta: f64,
756 #[serde(default, skip_serializing_if = "Vec::is_empty")]
757 pub regressed_extensions: Vec<ExtensionRegression>,
758 }
759
760 impl ConformanceRegression {
761 #[must_use]
762 pub fn has_regression(&self) -> bool {
763 const EPS: f64 = 1e-12;
764 self.pass_rate_delta < -EPS || !self.regressed_extensions.is_empty()
765 }
766 }
767
768 fn tier_key(tier: Option<u32>) -> String {
769 tier.map_or_else(|| "tier_unknown".to_string(), |tier| format!("tier{tier}"))
770 }
771
772 fn now_timestamp_string() -> String {
773 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
774 }
775
776 #[must_use]
781 pub fn generate_report(
782 run_id: impl Into<String>,
783 timestamp: Option<String>,
784 mut results: Vec<ExtensionConformanceResult>,
785 ) -> ConformanceReport {
786 results.sort_by(|left, right| {
787 let left_tier = left.tier.unwrap_or(u32::MAX);
788 let right_tier = right.tier.unwrap_or(u32::MAX);
789 (left_tier, &left.id).cmp(&(right_tier, &right.id))
790 });
791
792 let total = results.len() as u64;
793 let passed = results
794 .iter()
795 .filter(|r| r.status == ConformanceStatus::Pass)
796 .count() as u64;
797 let failed = results
798 .iter()
799 .filter(|r| r.status == ConformanceStatus::Fail)
800 .count() as u64;
801 let skipped = results
802 .iter()
803 .filter(|r| r.status == ConformanceStatus::Skip)
804 .count() as u64;
805 let errors = results
806 .iter()
807 .filter(|r| r.status == ConformanceStatus::Error)
808 .count() as u64;
809
810 let pass_rate = ratio(passed, total);
811
812 let mut by_tier: BTreeMap<String, Vec<ExtensionConformanceResult>> = BTreeMap::new();
813 for result in &results {
814 by_tier
815 .entry(tier_key(result.tier))
816 .or_default()
817 .push(result.clone());
818 }
819
820 let by_tier = by_tier
821 .into_iter()
822 .map(|(key, items)| (key, TierSummary::from_results(&items)))
823 .collect::<BTreeMap<_, _>>();
824
825 ConformanceReport {
826 run_id: run_id.into(),
827 timestamp: timestamp.unwrap_or_else(now_timestamp_string),
828 summary: ConformanceSummary {
829 total,
830 passed,
831 failed,
832 skipped,
833 errors,
834 pass_rate,
835 by_tier,
836 },
837 extensions: results,
838 }
839 }
840
841 #[must_use]
849 pub fn compute_regression(
850 previous: &ConformanceReport,
851 current: &ConformanceReport,
852 ) -> ConformanceRegression {
853 let compared_total = previous.extensions.len() as u64;
854 let previous_passed = previous
855 .extensions
856 .iter()
857 .filter(|r| r.status == ConformanceStatus::Pass)
858 .count() as u64;
859
860 let current_by_id = current
861 .extensions
862 .iter()
863 .map(|r| (r.id.as_str(), r.status))
864 .collect::<BTreeMap<_, _>>();
865
866 let mut current_passed = 0u64;
867 let mut regressed_extensions = Vec::new();
868 for result in &previous.extensions {
869 let current_status = current_by_id.get(result.id.as_str()).copied();
870 if matches!(current_status, Some(ConformanceStatus::Pass)) {
871 current_passed = current_passed.saturating_add(1);
872 }
873
874 if result.status == ConformanceStatus::Pass
875 && !matches!(current_status, Some(ConformanceStatus::Pass))
876 {
877 regressed_extensions.push(ExtensionRegression {
878 id: result.id.clone(),
879 previous: result.status,
880 current: current_status,
881 });
882 }
883 }
884
885 let previous_pass_rate = ratio(previous_passed, compared_total);
886 let current_pass_rate = ratio(current_passed, compared_total);
887 let pass_rate_delta = current_pass_rate - previous_pass_rate;
888
889 ConformanceRegression {
890 compared_total,
891 previous_passed,
892 current_passed,
893 previous_pass_rate,
894 current_pass_rate,
895 pass_rate_delta,
896 regressed_extensions,
897 }
898 }
899
900 impl ConformanceReport {
901 #[must_use]
903 pub fn render_markdown(&self) -> String {
904 let mut out = String::new();
905 let pass_rate_pct = self.summary.pass_rate * 100.0;
906 let _ = writeln!(out, "# Extension Conformance Report");
907 let _ = writeln!(out, "Generated: {}", self.timestamp);
908 let _ = writeln!(out, "Run ID: {}", self.run_id);
909 let _ = writeln!(out);
910 let _ = writeln!(
911 out,
912 "Pass Rate: {:.1}% ({}/{})",
913 pass_rate_pct, self.summary.passed, self.summary.total
914 );
915 let _ = writeln!(out);
916 let _ = writeln!(out, "## Summary");
917 let _ = writeln!(out, "- Total: {}", self.summary.total);
918 let _ = writeln!(out, "- Passed: {}", self.summary.passed);
919 let _ = writeln!(out, "- Failed: {}", self.summary.failed);
920 let _ = writeln!(out, "- Skipped: {}", self.summary.skipped);
921 let _ = writeln!(out, "- Errors: {}", self.summary.errors);
922 let _ = writeln!(out);
923
924 let _ = writeln!(out, "## By Tier");
925 for (tier, summary) in &self.summary.by_tier {
926 let tier_label = match tier.strip_prefix("tier") {
927 Some(num) if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) => {
928 format!("Tier {num}")
929 }
930 _ => tier.clone(),
931 };
932 let _ = writeln!(
933 out,
934 "### {tier_label}: {:.1}% ({}/{})",
935 summary.pass_rate * 100.0,
936 summary.passed,
937 summary.total
938 );
939 let _ = writeln!(out);
940 let _ = writeln!(out, "| Extension | Status | TS Time | Rust Time | Notes |");
941 let _ = writeln!(out, "|---|---|---:|---:|---|");
942 for result in self.extensions.iter().filter(|r| &tier_key(r.tier) == tier) {
943 let ts_time = result
944 .ts_time_ms
945 .map_or_else(String::new, |v| format!("{v}ms"));
946 let rust_time = result
947 .rust_time_ms
948 .map_or_else(String::new, |v| format!("{v}ms"));
949 let notes = result.notes.as_deref().unwrap_or("");
950 let _ = writeln!(
951 out,
952 "| {} | {} | {} | {} | {} |",
953 result.id,
954 result.status.as_upper_str(),
955 ts_time,
956 rust_time,
957 notes
958 );
959 }
960 let _ = writeln!(out);
961 }
962
963 let failures = self
964 .extensions
965 .iter()
966 .filter(|r| matches!(r.status, ConformanceStatus::Fail | ConformanceStatus::Error))
967 .collect::<Vec<_>>();
968
969 let _ = writeln!(out, "## Failures");
970 if failures.is_empty() {
971 let _ = writeln!(out, "(none)");
972 return out;
973 }
974
975 for failure in failures {
976 let tier = failure
977 .tier
978 .map_or_else(|| "unknown".to_string(), |v| v.to_string());
979 let _ = writeln!(out, "### {} (Tier {})", failure.id, tier);
980 if let Some(notes) = failure.notes.as_deref().filter(|v| !v.is_empty()) {
981 let _ = writeln!(out, "**Notes**: {notes}");
982 }
983 if failure.diffs.is_empty() {
984 let _ = writeln!(out, "- (no diff details)");
985 } else {
986 for diff in &failure.diffs {
987 let _ = writeln!(out, "- `{}`: {}", diff.path, diff.message);
988 }
989 }
990 let _ = writeln!(out);
991 }
992
993 out
994 }
995 }
996}
997
998pub mod snapshot {
1038 use serde::{Deserialize, Serialize};
1039 use sha2::{Digest, Sha256};
1040 use std::fmt::Write as _;
1041 use std::io;
1042 use std::path::{Path, PathBuf};
1043
1044 pub const ARTIFACT_ROOT: &str = "tests/ext_conformance/artifacts";
1048
1049 pub const TIER_SCOPED_DIRS: &[&str] = &[
1052 "community",
1053 "npm",
1054 "third-party",
1055 "agents-mikeastock",
1056 "templates",
1057 ];
1058
1059 pub const EXCLUDED_DIRS: &[&str] = &[
1061 "plugins-official",
1062 "plugins-community",
1063 "plugins-ariff",
1064 "agents-wshobson",
1065 "templates-davila7",
1066 ];
1067
1068 pub const FIXTURE_DIRS: &[&str] = &[
1070 "base_fixtures",
1071 "diff",
1072 "files",
1073 "negative-denied-caps",
1074 "reports",
1075 ];
1076
1077 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1081 #[serde(rename_all = "kebab-case")]
1082 pub enum SourceTier {
1083 OfficialPiMono,
1084 Community,
1085 NpmRegistry,
1086 ThirdPartyGithub,
1087 AgentsMikeastock,
1088 Templates,
1089 }
1090
1091 impl SourceTier {
1092 #[must_use]
1094 pub const fn directory_prefix(self) -> Option<&'static str> {
1095 match self {
1096 Self::OfficialPiMono => None,
1097 Self::Community => Some("community"),
1098 Self::NpmRegistry => Some("npm"),
1099 Self::ThirdPartyGithub => Some("third-party"),
1100 Self::AgentsMikeastock => Some("agents-mikeastock"),
1101 Self::Templates => Some("templates"),
1102 }
1103 }
1104
1105 #[must_use]
1107 pub fn from_directory(dir: &str) -> Self {
1108 if dir.starts_with("community/") {
1109 Self::Community
1110 } else if dir.starts_with("npm/") {
1111 Self::NpmRegistry
1112 } else if dir.starts_with("third-party/") {
1113 Self::ThirdPartyGithub
1114 } else if dir.starts_with("agents-mikeastock/") {
1115 Self::AgentsMikeastock
1116 } else if dir.starts_with("templates/") {
1117 Self::Templates
1118 } else {
1119 Self::OfficialPiMono
1120 }
1121 }
1122 }
1123
1124 #[derive(Debug, Clone, Serialize, Deserialize)]
1128 #[serde(rename_all = "snake_case", tag = "type")]
1129 pub enum ArtifactSource {
1130 Git {
1132 repo: String,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1134 path: Option<String>,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 commit: Option<String>,
1137 },
1138 Npm {
1140 package: String,
1141 version: String,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 url: Option<String>,
1144 },
1145 Url { url: String },
1147 }
1148
1149 #[derive(Debug, Clone, Serialize, Deserialize)]
1157 pub struct ArtifactSpec {
1158 pub id: String,
1160 pub directory: String,
1162 pub name: String,
1164 pub source_tier: SourceTier,
1166 pub license: String,
1168 pub source: ArtifactSource,
1170 }
1171
1172 pub fn validate_id(id: &str) -> Result<(), String> {
1181 if id.is_empty() {
1182 return Err("extension ID must not be empty".into());
1183 }
1184 if id != id.to_ascii_lowercase() {
1185 return Err(format!("extension ID must be lowercase: {id:?}"));
1186 }
1187 for ch in id.chars() {
1188 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' && ch != '/' {
1189 return Err(format!(
1190 "extension ID contains invalid character {ch:?}: {id:?}"
1191 ));
1192 }
1193 }
1194 if id.starts_with('-') || id.ends_with('-') {
1195 return Err(format!(
1196 "extension ID must not start or end with hyphen: {id:?}"
1197 ));
1198 }
1199 Ok(())
1200 }
1201
1202 pub fn validate_directory(dir: &str, tier: SourceTier) -> Result<(), String> {
1204 if dir.is_empty() {
1205 return Err("directory must not be empty".into());
1206 }
1207 match tier.directory_prefix() {
1208 Some(prefix) => {
1209 if !dir.starts_with(&format!("{prefix}/")) {
1210 return Err(format!(
1211 "directory {dir:?} must start with \"{prefix}/\" for tier {tier:?}"
1212 ));
1213 }
1214 }
1215 None => {
1216 for scoped in TIER_SCOPED_DIRS {
1217 if dir.starts_with(&format!("{scoped}/")) {
1218 return Err(format!(
1219 "official extension directory {dir:?} must not be under \
1220 tier-scoped dir \"{scoped}/\""
1221 ));
1222 }
1223 }
1224 }
1225 }
1226 Ok(())
1227 }
1228
1229 fn hex_lower(bytes: &[u8]) -> String {
1232 let mut output = String::with_capacity(bytes.len() * 2);
1233 for byte in bytes {
1234 let _ = write!(&mut output, "{byte:02x}");
1235 }
1236 output
1237 }
1238
1239 fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
1240 for entry in std::fs::read_dir(dir)? {
1241 let entry = entry?;
1242 let ft = entry.file_type()?;
1243 let path = entry.path();
1244 if ft.is_dir() {
1245 collect_files_recursive(&path, files)?;
1246 } else if ft.is_file() {
1247 files.push(path);
1248 }
1249 }
1250 Ok(())
1251 }
1252
1253 fn relative_posix(root: &Path, path: &Path) -> String {
1254 let rel = path.strip_prefix(root).unwrap_or(path);
1255 rel.components()
1256 .map(|c| c.as_os_str().to_string_lossy())
1257 .collect::<Vec<_>>()
1258 .join("/")
1259 }
1260
1261 pub fn digest_artifact_dir(dir: &Path) -> io::Result<String> {
1270 let mut files = Vec::new();
1271 collect_files_recursive(dir, &mut files)?;
1272 files.sort_by_key(|p| relative_posix(dir, p));
1273
1274 let mut hasher = Sha256::new();
1275 for path in &files {
1276 let rel = relative_posix(dir, path);
1277 hasher.update(b"file\0");
1278 hasher.update(rel.as_bytes());
1279 hasher.update(b"\0");
1280 let content: Vec<u8> = std::fs::read(path)?
1282 .into_iter()
1283 .filter(|&b| b != b'\r')
1284 .collect();
1285 hasher.update(&content);
1286 hasher.update(b"\0");
1287 }
1288 Ok(hex_lower(&hasher.finalize()))
1289 }
1290
1291 pub fn verify_integrity(dir: &Path, expected_sha256: &str) -> io::Result<Result<(), String>> {
1296 let actual = digest_artifact_dir(dir)?;
1297 if actual == expected_sha256 {
1298 Ok(Ok(()))
1299 } else {
1300 Ok(Err(format!(
1301 "checksum mismatch for {}: expected {expected_sha256}, got {actual}",
1302 dir.display()
1303 )))
1304 }
1305 }
1306
1307 #[must_use]
1311 pub fn validate_artifact_spec(spec: &ArtifactSpec) -> Vec<String> {
1312 let mut errors = Vec::new();
1313
1314 if let Err(e) = validate_id(&spec.id) {
1315 errors.push(e);
1316 }
1317 if let Err(e) = validate_directory(&spec.directory, spec.source_tier) {
1318 errors.push(e);
1319 }
1320 if spec.name.is_empty() {
1321 errors.push("name must not be empty".into());
1322 }
1323 if spec.license.is_empty() {
1324 errors.push("license must not be empty (use \"UNKNOWN\" if unknown)".into());
1325 }
1326
1327 match &spec.source {
1328 ArtifactSource::Git { repo, .. } => {
1329 if repo.is_empty() {
1330 errors.push("git source must have non-empty repo URL".into());
1331 }
1332 }
1333 ArtifactSource::Npm {
1334 package, version, ..
1335 } => {
1336 if package.is_empty() {
1337 errors.push("npm source must have non-empty package name".into());
1338 }
1339 if version.is_empty() {
1340 errors.push("npm source must have non-empty version".into());
1341 }
1342 }
1343 ArtifactSource::Url { url } => {
1344 if url.is_empty() {
1345 errors.push("url source must have non-empty URL".into());
1346 }
1347 }
1348 }
1349
1350 errors
1351 }
1352
1353 #[must_use]
1356 pub fn is_reserved_dir(name: &str) -> bool {
1357 EXCLUDED_DIRS.contains(&name)
1358 || FIXTURE_DIRS.contains(&name)
1359 || TIER_SCOPED_DIRS.contains(&name)
1360 }
1361}
1362
1363pub mod normalization {
1391 use regex::Regex;
1392 use serde::{Deserialize, Serialize};
1393 use serde_json::Value;
1394 use std::path::{Path, PathBuf};
1395 use std::sync::OnceLock;
1396
1397 pub const SCHEMA_VERSION: &str = "1.0.0";
1401
1402 pub const PLACEHOLDER_TIMESTAMP: &str = "<TIMESTAMP>";
1410 pub const PLACEHOLDER_HOST: &str = "<HOST>";
1411 pub const PLACEHOLDER_SESSION_ID: &str = "<SESSION_ID>";
1412 pub const PLACEHOLDER_RUN_ID: &str = "<RUN_ID>";
1413 pub const PLACEHOLDER_ARTIFACT_ID: &str = "<ARTIFACT_ID>";
1414 pub const PLACEHOLDER_TRACE_ID: &str = "<TRACE_ID>";
1415 pub const PLACEHOLDER_SPAN_ID: &str = "<SPAN_ID>";
1416 pub const PLACEHOLDER_UUID: &str = "<UUID>";
1417 pub const PLACEHOLDER_PI_MONO_ROOT: &str = "<PI_MONO_ROOT>";
1418 pub const PLACEHOLDER_PROJECT_ROOT: &str = "<PROJECT_ROOT>";
1419 pub const PLACEHOLDER_PORT: &str = "<PORT>";
1420 pub const PLACEHOLDER_PID: &str = "<PID>";
1421
1422 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1426 #[serde(rename_all = "snake_case")]
1427 pub enum FieldClassification {
1428 Semantic,
1433
1434 Transport,
1440
1441 Derived,
1445 }
1446
1447 #[derive(Debug, Clone, Serialize, Deserialize)]
1449 pub struct FieldRule {
1450 pub path_pattern: String,
1455
1456 pub classification: FieldClassification,
1458
1459 #[serde(default, skip_serializing_if = "Option::is_none")]
1463 pub placeholder: Option<String>,
1464 }
1465
1466 #[derive(Debug, Clone)]
1468 pub struct StringRewriteRule {
1469 pub name: &'static str,
1471 pub regex: &'static OnceLock<Regex>,
1473 pub replacement: &'static str,
1475 }
1476
1477 #[derive(Debug, Clone)]
1485 pub struct NormalizationContext {
1486 pub project_root: String,
1488 pub pi_mono_root: String,
1490 pub cwd: String,
1492 }
1493
1494 impl NormalizationContext {
1495 #[must_use]
1497 pub fn from_cwd(cwd: &Path) -> Self {
1498 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1499 .canonicalize()
1500 .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")))
1501 .display()
1502 .to_string();
1503 let pi_mono_root = PathBuf::from(&project_root)
1504 .join("legacy_pi_mono_code")
1505 .join("pi-mono")
1506 .canonicalize()
1507 .unwrap_or_else(|_| {
1508 PathBuf::from(&project_root)
1509 .join("legacy_pi_mono_code")
1510 .join("pi-mono")
1511 })
1512 .display()
1513 .to_string();
1514 let cwd = cwd
1515 .canonicalize()
1516 .unwrap_or_else(|_| cwd.to_path_buf())
1517 .display()
1518 .to_string();
1519 Self {
1520 project_root,
1521 pi_mono_root,
1522 cwd,
1523 }
1524 }
1525
1526 #[must_use]
1528 pub const fn new(project_root: String, pi_mono_root: String, cwd: String) -> Self {
1529 Self {
1530 project_root,
1531 pi_mono_root,
1532 cwd,
1533 }
1534 }
1535 }
1536
1537 static ANSI_REGEX: OnceLock<Regex> = OnceLock::new();
1540 static RUN_ID_REGEX: OnceLock<Regex> = OnceLock::new();
1541 static UUID_REGEX: OnceLock<Regex> = OnceLock::new();
1542 static OPENAI_BASE_REGEX: OnceLock<Regex> = OnceLock::new();
1543
1544 fn ansi_regex() -> &'static Regex {
1545 ANSI_REGEX.get_or_init(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex"))
1546 }
1547
1548 fn run_id_regex() -> &'static Regex {
1549 RUN_ID_REGEX.get_or_init(|| Regex::new(r"\brun-[0-9a-fA-F-]{36}\b").expect("run id regex"))
1550 }
1551
1552 fn uuid_regex() -> &'static Regex {
1553 UUID_REGEX.get_or_init(|| {
1554 Regex::new(
1555 r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
1556 )
1557 .expect("uuid regex")
1558 })
1559 }
1560
1561 fn openai_base_regex() -> &'static Regex {
1562 OPENAI_BASE_REGEX
1563 .get_or_init(|| Regex::new(r"http://127\.0\.0\.1:\d+/v1").expect("openai base regex"))
1564 }
1565
1566 const TIMESTAMP_KEYS: &[&str] = &[
1574 "timestamp",
1575 "started_at",
1576 "finished_at",
1577 "created_at",
1578 "createdAt",
1579 "ts",
1580 ];
1581
1582 const TRANSPORT_ID_KEYS: &[(&str, &str)] = &[
1584 ("session_id", PLACEHOLDER_SESSION_ID),
1585 ("sessionId", PLACEHOLDER_SESSION_ID),
1586 ("run_id", PLACEHOLDER_RUN_ID),
1587 ("runId", PLACEHOLDER_RUN_ID),
1588 ("artifact_id", PLACEHOLDER_ARTIFACT_ID),
1589 ("artifactId", PLACEHOLDER_ARTIFACT_ID),
1590 ("trace_id", PLACEHOLDER_TRACE_ID),
1591 ("traceId", PLACEHOLDER_TRACE_ID),
1592 ("span_id", PLACEHOLDER_SPAN_ID),
1593 ("spanId", PLACEHOLDER_SPAN_ID),
1594 ];
1595
1596 const FIXED_PLACEHOLDER_KEYS: &[(&str, &str)] = &[
1598 ("cwd", PLACEHOLDER_PI_MONO_ROOT),
1599 ("host", PLACEHOLDER_HOST),
1600 ];
1601
1602 const ZEROED_NUMBER_KEYS: &[&str] = &["pid"];
1604
1605 #[derive(Debug, Clone, Serialize, Deserialize)]
1612 pub struct NormalizationContract {
1613 pub schema_version: String,
1615 pub field_rules: Vec<FieldRule>,
1617 }
1618
1619 impl Default for NormalizationContract {
1620 fn default() -> Self {
1621 let mut field_rules = Vec::new();
1622
1623 for &key in TIMESTAMP_KEYS {
1625 field_rules.push(FieldRule {
1626 path_pattern: format!("*.{key}"),
1627 classification: FieldClassification::Transport,
1628 placeholder: Some(PLACEHOLDER_TIMESTAMP.to_string()),
1629 });
1630 }
1631
1632 for &(key, placeholder) in TRANSPORT_ID_KEYS {
1634 field_rules.push(FieldRule {
1635 path_pattern: format!("*.{key}"),
1636 classification: FieldClassification::Transport,
1637 placeholder: Some(placeholder.to_string()),
1638 });
1639 }
1640
1641 for &(key, placeholder) in FIXED_PLACEHOLDER_KEYS {
1643 field_rules.push(FieldRule {
1644 path_pattern: format!("*.{key}"),
1645 classification: FieldClassification::Transport,
1646 placeholder: Some(placeholder.to_string()),
1647 });
1648 }
1649
1650 for &key in ZEROED_NUMBER_KEYS {
1652 field_rules.push(FieldRule {
1653 path_pattern: format!("*.{key}"),
1654 classification: FieldClassification::Transport,
1655 placeholder: Some("0".to_string()),
1656 });
1657 }
1658
1659 for key in &[
1661 "schema",
1662 "level",
1663 "event",
1664 "message",
1665 "extension_id",
1666 "data",
1667 ] {
1668 field_rules.push(FieldRule {
1669 path_pattern: (*key).to_string(),
1670 classification: FieldClassification::Semantic,
1671 placeholder: None,
1672 });
1673 }
1674
1675 Self {
1676 schema_version: SCHEMA_VERSION.to_string(),
1677 field_rules,
1678 }
1679 }
1680 }
1681
1682 impl NormalizationContract {
1683 pub fn normalize(&self, value: &mut Value, ctx: &NormalizationContext) {
1688 normalize_value(value, None, ctx);
1689 }
1690
1691 #[must_use]
1693 pub fn normalize_and_canonicalize(
1694 &self,
1695 value: Value,
1696 ctx: &NormalizationContext,
1697 ) -> Value {
1698 let mut v = value;
1699 self.normalize(&mut v, ctx);
1700 canonicalize_json_keys(&v)
1701 }
1702 }
1703
1704 pub fn normalize_value(value: &mut Value, key: Option<&str>, ctx: &NormalizationContext) {
1717 match value {
1718 Value::Null | Value::Bool(_) => {}
1719 Value::String(s) => {
1720 if matches_any_key(key, TIMESTAMP_KEYS) {
1722 *s = PLACEHOLDER_TIMESTAMP.to_string();
1723 return;
1724 }
1725 if let Some(placeholder) = transport_id_placeholder(key) {
1727 *s = placeholder.to_string();
1728 return;
1729 }
1730 if let Some(placeholder) = fixed_placeholder(key) {
1732 *s = placeholder.to_string();
1733 return;
1734 }
1735 *s = normalize_string(s, ctx);
1737 }
1738 Value::Array(items) => {
1739 for item in items {
1740 normalize_value(item, None, ctx);
1741 }
1742 }
1743 Value::Object(map) => {
1744 for (k, item) in map.iter_mut() {
1745 normalize_value(item, Some(k.as_str()), ctx);
1746 }
1747 canonicalize_ui_method(map);
1750 }
1751 Value::Number(_) => {
1752 if matches_any_key(key, TIMESTAMP_KEYS) || matches_any_key(key, ZEROED_NUMBER_KEYS)
1753 {
1754 *value = Value::Number(0.into());
1755 }
1756 }
1757 }
1758 }
1759
1760 #[must_use]
1762 pub fn normalize_string(input: &str, ctx: &NormalizationContext) -> String {
1763 let without_ansi = ansi_regex().replace_all(input, "");
1765
1766 let mut out = without_ansi.to_string();
1768 out = replace_path_variants(&out, &ctx.cwd, PLACEHOLDER_PI_MONO_ROOT);
1769 out = replace_path_variants(&out, &ctx.pi_mono_root, PLACEHOLDER_PI_MONO_ROOT);
1770 out = replace_path_variants(&out, &ctx.project_root, PLACEHOLDER_PROJECT_ROOT);
1771
1772 out = run_id_regex()
1774 .replace_all(&out, PLACEHOLDER_RUN_ID)
1775 .into_owned();
1776
1777 out = openai_base_regex()
1779 .replace_all(&out, format!("http://127.0.0.1:{PLACEHOLDER_PORT}/v1"))
1780 .into_owned();
1781
1782 out = uuid_regex()
1784 .replace_all(&out, PLACEHOLDER_UUID)
1785 .into_owned();
1786
1787 out
1788 }
1789
1790 #[must_use]
1792 pub fn canonicalize_json_keys(value: &Value) -> Value {
1793 match value {
1794 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
1795 Value::Array(items) => Value::Array(items.iter().map(canonicalize_json_keys).collect()),
1796 Value::Object(map) => {
1797 let mut keys = map.keys().cloned().collect::<Vec<_>>();
1798 keys.sort();
1799 let mut out = serde_json::Map::new();
1800 for key in keys {
1801 if let Some(v) = map.get(&key) {
1802 out.insert(key, canonicalize_json_keys(v));
1803 }
1804 }
1805 Value::Object(out)
1806 }
1807 }
1808 }
1809
1810 fn replace_path_variants(input: &str, path: &str, placeholder: &str) -> String {
1812 if path.is_empty() {
1813 return input.to_string();
1814 }
1815 let mut out = input.replace(path, placeholder);
1816 let path_backslashes = path.replace('/', "\\");
1817 if path_backslashes != path {
1818 out = out.replace(&path_backslashes, placeholder);
1819 }
1820 out
1821 }
1822
1823 fn matches_any_key(key: Option<&str>, candidates: &[&str]) -> bool {
1826 key.is_some_and(|k| candidates.contains(&k))
1827 }
1828
1829 fn transport_id_placeholder(key: Option<&str>) -> Option<&'static str> {
1830 let k = key?;
1831 TRANSPORT_ID_KEYS
1832 .iter()
1833 .find(|(name, _)| *name == k)
1834 .map(|(_, placeholder)| *placeholder)
1835 }
1836
1837 fn fixed_placeholder(key: Option<&str>) -> Option<&'static str> {
1838 let k = key?;
1839 FIXED_PLACEHOLDER_KEYS
1840 .iter()
1841 .find(|(name, _)| *name == k)
1842 .map(|(_, placeholder)| *placeholder)
1843 }
1844
1845 fn canonicalize_ui_method(map: &mut serde_json::Map<String, Value>) {
1848 let is_ui_request = map
1849 .get("type")
1850 .and_then(Value::as_str)
1851 .is_some_and(|t| t == "extension_ui_request");
1852 if !is_ui_request {
1853 return;
1854 }
1855 if let Some(Value::String(method)) = map.get_mut("method") {
1856 let canonical = canonicalize_op_name(method);
1857 if canonical != method.as_str() {
1858 *method = canonical.to_string();
1859 }
1860 }
1861 }
1862
1863 pub const UI_OP_ALIASES: &[(&str, &str)] = &[
1872 ("setStatus", "status"),
1873 ("set_status", "status"),
1874 ("setLabel", "label"),
1875 ("set_label", "label"),
1876 ("setWidget", "widget"),
1877 ("set_widget", "widget"),
1878 ("setTitle", "title"),
1879 ("set_title", "title"),
1880 ];
1881
1882 #[must_use]
1884 pub fn canonicalize_op_name(op: &str) -> &str {
1885 UI_OP_ALIASES
1886 .iter()
1887 .find(|(alias, _)| *alias == op)
1888 .map_or(op, |(_, canonical)| canonical)
1889 }
1890
1891 #[must_use]
1901 pub fn is_path_key(key: &str) -> bool {
1902 key.ends_with("Path")
1903 || key.ends_with("Paths")
1904 || key.ends_with("path")
1905 || key.ends_with("paths")
1906 || key.ends_with("Dir")
1907 || key.ends_with("dir")
1908 || key == "cwd"
1909 }
1910
1911 #[must_use]
1924 pub fn path_suffix_match(actual: &str, expected: &str) -> bool {
1925 if actual == expected {
1926 return true;
1927 }
1928 if expected.starts_with('/') || expected.starts_with('\\') {
1930 return false;
1931 }
1932 let actual_norm = actual.replace('\\', "/");
1934 let expected_norm = expected.replace('\\', "/");
1935 actual_norm.ends_with(&format!("/{expected_norm}"))
1936 }
1937
1938 #[cfg(test)]
1941 mod tests {
1942 use super::*;
1943 use serde_json::json;
1944
1945 #[test]
1946 fn schema_version_is_set() {
1947 assert!(!SCHEMA_VERSION.is_empty());
1948 assert_eq!(
1949 SCHEMA_VERSION.split('.').count(),
1950 3,
1951 "semver format expected"
1952 );
1953 }
1954
1955 #[test]
1956 fn default_contract_has_field_rules() {
1957 let contract = NormalizationContract::default();
1958 assert!(
1959 !contract.field_rules.is_empty(),
1960 "default contract must have rules"
1961 );
1962 assert_eq!(contract.schema_version, SCHEMA_VERSION);
1963 }
1964
1965 #[test]
1966 fn field_classification_serde_roundtrip() {
1967 for class in [
1968 FieldClassification::Semantic,
1969 FieldClassification::Transport,
1970 FieldClassification::Derived,
1971 ] {
1972 let json = serde_json::to_string(&class).unwrap();
1973 let back: FieldClassification = serde_json::from_str(&json).unwrap();
1974 assert_eq!(class, back);
1975 }
1976 }
1977
1978 #[test]
1979 fn normalize_timestamp_string() {
1980 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1981 let mut val = json!({"ts": "2026-02-03T03:01:02.123Z"});
1982 normalize_value(&mut val, None, &ctx);
1983 assert_eq!(val["ts"], PLACEHOLDER_TIMESTAMP);
1984 }
1985
1986 #[test]
1987 fn normalize_timestamp_number() {
1988 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1989 let mut val = json!({"ts": 1_700_000_000_000_u64});
1990 normalize_value(&mut val, None, &ctx);
1991 assert_eq!(val["ts"], 0);
1992 }
1993
1994 #[test]
1995 fn normalize_transport_ids() {
1996 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1997 let mut val = json!({
1998 "session_id": "sess-abc",
1999 "run_id": "run-xyz",
2000 "artifact_id": "art-123",
2001 "trace_id": "tr-456",
2002 "span_id": "sp-789"
2003 });
2004 normalize_value(&mut val, None, &ctx);
2005 assert_eq!(val["session_id"], PLACEHOLDER_SESSION_ID);
2006 assert_eq!(val["run_id"], PLACEHOLDER_RUN_ID);
2007 assert_eq!(val["artifact_id"], PLACEHOLDER_ARTIFACT_ID);
2008 assert_eq!(val["trace_id"], PLACEHOLDER_TRACE_ID);
2009 assert_eq!(val["span_id"], PLACEHOLDER_SPAN_ID);
2010 }
2011
2012 #[test]
2013 fn normalize_camel_case_variants() {
2014 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2015 let mut val = json!({
2016 "sessionId": "sess-abc",
2017 "runId": "run-xyz",
2018 "artifactId": "art-123",
2019 "traceId": "tr-456",
2020 "spanId": "sp-789",
2021 "createdAt": "2026-01-01"
2022 });
2023 normalize_value(&mut val, None, &ctx);
2024 assert_eq!(val["sessionId"], PLACEHOLDER_SESSION_ID);
2025 assert_eq!(val["runId"], PLACEHOLDER_RUN_ID);
2026 assert_eq!(val["artifactId"], PLACEHOLDER_ARTIFACT_ID);
2027 assert_eq!(val["traceId"], PLACEHOLDER_TRACE_ID);
2028 assert_eq!(val["spanId"], PLACEHOLDER_SPAN_ID);
2029 assert_eq!(val["createdAt"], PLACEHOLDER_TIMESTAMP);
2030 }
2031
2032 #[test]
2033 fn normalize_fixed_keys() {
2034 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2035 let mut val = json!({"cwd": "/some/path", "host": "myhost.local"});
2036 normalize_value(&mut val, None, &ctx);
2037 assert_eq!(val["cwd"], PLACEHOLDER_PI_MONO_ROOT);
2038 assert_eq!(val["host"], PLACEHOLDER_HOST);
2039 }
2040
2041 #[test]
2042 fn normalize_pid() {
2043 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2044 let mut val = json!({"source": {"pid": 42}});
2045 normalize_value(&mut val, None, &ctx);
2046 assert_eq!(val["source"]["pid"], 0);
2047 }
2048
2049 #[test]
2050 fn normalize_string_strips_ansi() {
2051 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2052 let input = "\x1b[31mERROR\x1b[0m: something failed";
2053 let out = normalize_string(input, &ctx);
2054 assert_eq!(out, "ERROR: something failed");
2055 }
2056
2057 #[test]
2058 fn normalize_string_rewrites_uuids() {
2059 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2060 let input = "id=123e4567-e89b-12d3-a456-426614174000";
2061 let out = normalize_string(input, &ctx);
2062 assert!(out.contains(PLACEHOLDER_UUID), "got: {out}");
2063 }
2064
2065 #[test]
2066 fn normalize_string_rewrites_run_ids() {
2067 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2068 let input = "run-123e4567-e89b-12d3-a456-426614174000";
2069 let out = normalize_string(input, &ctx);
2070 assert!(out.contains(PLACEHOLDER_RUN_ID), "got: {out}");
2071 }
2072
2073 #[test]
2074 fn normalize_string_rewrites_ports() {
2075 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2076 let input = "http://127.0.0.1:4887/v1/chat";
2077 let out = normalize_string(input, &ctx);
2078 assert!(
2079 out.contains(&format!("http://127.0.0.1:{PLACEHOLDER_PORT}/v1")),
2080 "got: {out}"
2081 );
2082 }
2083
2084 #[test]
2085 fn normalize_string_rewrites_paths() {
2086 let ctx = NormalizationContext::new(
2087 "/repo/pi".to_string(),
2088 "/repo/pi/legacy_pi_mono_code/pi-mono".to_string(),
2089 "/tmp/work".to_string(),
2090 );
2091 let input = "opened /tmp/work/file.txt and /repo/pi/src/main.rs";
2092 let out = normalize_string(input, &ctx);
2093 assert!(
2094 out.contains(&format!("{PLACEHOLDER_PI_MONO_ROOT}/file.txt")),
2095 "got: {out}"
2096 );
2097 assert!(
2098 out.contains(&format!("{PLACEHOLDER_PROJECT_ROOT}/src/main.rs")),
2099 "got: {out}"
2100 );
2101 }
2102
2103 #[test]
2104 fn canonicalize_json_keys_sorts_recursively() {
2105 let input = json!({"z": 1, "a": {"c": 3, "b": 2}});
2106 let out = canonicalize_json_keys(&input);
2107 let serialized = serde_json::to_string(&out).unwrap();
2108 assert_eq!(serialized, r#"{"a":{"b":2,"c":3},"z":1}"#);
2109 }
2110
2111 #[test]
2112 fn contract_normalize_and_canonicalize() {
2113 let contract = NormalizationContract::default();
2114 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2115 let input = json!({
2116 "z_field": "hello",
2117 "ts": "2026-01-01",
2118 "a_field": 42,
2119 "session_id": "sess-x"
2120 });
2121 let out = contract.normalize_and_canonicalize(input, &ctx);
2122 assert_eq!(out["ts"], PLACEHOLDER_TIMESTAMP);
2123 assert_eq!(out["session_id"], PLACEHOLDER_SESSION_ID);
2124 let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
2126 let mut sorted = keys.clone();
2127 sorted.sort();
2128 assert_eq!(keys, sorted);
2129 }
2130
2131 #[test]
2132 fn canonicalize_op_name_resolves_aliases() {
2133 assert_eq!(canonicalize_op_name("setStatus"), "status");
2134 assert_eq!(canonicalize_op_name("set_status"), "status");
2135 assert_eq!(canonicalize_op_name("setLabel"), "label");
2136 assert_eq!(canonicalize_op_name("set_label"), "label");
2137 assert_eq!(canonicalize_op_name("setWidget"), "widget");
2138 assert_eq!(canonicalize_op_name("set_widget"), "widget");
2139 assert_eq!(canonicalize_op_name("setTitle"), "title");
2140 assert_eq!(canonicalize_op_name("set_title"), "title");
2141 assert_eq!(canonicalize_op_name("status"), "status");
2143 assert_eq!(canonicalize_op_name("notify"), "notify");
2144 assert_eq!(canonicalize_op_name("unknown_op"), "unknown_op");
2145 }
2146
2147 #[test]
2148 fn normalize_canonicalizes_ui_method() {
2149 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2150 let mut input = json!({
2151 "type": "extension_ui_request",
2152 "id": "req-1",
2153 "method": "setStatus",
2154 "statusKey": "demo",
2155 "statusText": "Ready"
2156 });
2157 normalize_value(&mut input, None, &ctx);
2158 assert_eq!(
2159 input["method"], "status",
2160 "setStatus should be canonicalized to status"
2161 );
2162 }
2163
2164 #[test]
2165 fn normalize_skips_non_ui_request_method() {
2166 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2167 let mut input = json!({
2168 "type": "http_request",
2169 "method": "setStatus"
2170 });
2171 normalize_value(&mut input, None, &ctx);
2172 assert_eq!(
2173 input["method"], "setStatus",
2174 "non-ui-request method should NOT be canonicalized"
2175 );
2176 }
2177
2178 #[test]
2179 fn normalize_and_canonicalize_handles_ui_aliases() {
2180 let contract = NormalizationContract::default();
2181 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2182 let event_camel = json!({
2184 "type": "extension_ui_request",
2185 "method": "setStatus",
2186 "statusKey": "k"
2187 });
2188 let event_snake = json!({
2189 "type": "extension_ui_request",
2190 "method": "set_status",
2191 "statusKey": "k"
2192 });
2193 let a = contract.normalize_and_canonicalize(event_camel, &ctx);
2194 let b = contract.normalize_and_canonicalize(event_snake, &ctx);
2195 assert_eq!(
2196 a, b,
2197 "setStatus and set_status should normalize identically"
2198 );
2199 assert_eq!(a["method"], "status");
2200 }
2201
2202 #[test]
2203 fn contract_serializes_to_json() {
2204 let contract = NormalizationContract::default();
2205 let json = serde_json::to_string_pretty(&contract).unwrap();
2206 assert!(json.contains("schema_version"));
2207 assert!(json.contains("field_rules"));
2208 let back: NormalizationContract = serde_json::from_str(&json).unwrap();
2210 assert_eq!(back.schema_version, SCHEMA_VERSION);
2211 assert_eq!(back.field_rules.len(), contract.field_rules.len());
2212 }
2213
2214 #[test]
2215 fn default_contract_covers_all_transport_keys() {
2216 let contract = NormalizationContract::default();
2217 let transport_rules: Vec<_> = contract
2218 .field_rules
2219 .iter()
2220 .filter(|r| r.classification == FieldClassification::Transport)
2221 .collect();
2222 assert!(
2224 transport_rules.len() >= 19,
2225 "expected >= 19 transport rules, got {}",
2226 transport_rules.len()
2227 );
2228 }
2229
2230 #[test]
2231 fn default_contract_has_semantic_rules() {
2232 let contract = NormalizationContract::default();
2233 assert!(
2234 contract
2235 .field_rules
2236 .iter()
2237 .any(|r| r.classification == FieldClassification::Semantic),
2238 "contract should document semantic fields"
2239 );
2240 }
2241
2242 #[test]
2245 fn is_path_key_matches_common_suffixes() {
2246 assert!(is_path_key("promptPaths"));
2247 assert!(is_path_key("skillPaths"));
2248 assert!(is_path_key("themePaths"));
2249 assert!(is_path_key("filePath"));
2250 assert!(is_path_key("cwd"));
2251 assert!(is_path_key("workingDir"));
2252 assert!(!is_path_key("method"));
2253 assert!(!is_path_key("statusKey"));
2254 assert!(!is_path_key("name"));
2255 }
2256
2257 #[test]
2258 fn path_suffix_match_exact() {
2259 assert!(path_suffix_match("SKILL.md", "SKILL.md"));
2260 assert!(path_suffix_match("/a/b/c.txt", "/a/b/c.txt"));
2261 }
2262
2263 #[test]
2264 fn path_suffix_match_relative_in_absolute() {
2265 assert!(path_suffix_match(
2266 "/data/projects/pi/tests/ext_conformance/artifacts/dynamic-resources/SKILL.md",
2267 "SKILL.md"
2268 ));
2269 assert!(path_suffix_match(
2270 "/data/projects/pi/tests/ext_conformance/artifacts/dynamic-resources/dynamic.md",
2271 "dynamic.md"
2272 ));
2273 }
2274
2275 #[test]
2276 fn path_suffix_match_multi_component_relative() {
2277 assert!(path_suffix_match(
2278 "/data/projects/ext/sub/dir/file.ts",
2279 "dir/file.ts"
2280 ));
2281 assert!(!path_suffix_match(
2282 "/data/projects/ext/sub/dir/file.ts",
2283 "other/file.ts"
2284 ));
2285 }
2286
2287 #[test]
2288 fn path_suffix_match_rejects_when_expected_is_absolute() {
2289 assert!(!path_suffix_match("/a/b/c.txt", "/x/y/c.txt"));
2291 }
2292
2293 #[test]
2294 fn path_suffix_match_handles_backslashes() {
2295 assert!(path_suffix_match(
2296 "C:\\Users\\dev\\project\\SKILL.md",
2297 "SKILL.md"
2298 ));
2299 }
2300
2301 #[test]
2304 fn normalize_deeply_nested_mixed_fields() {
2305 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2306 let mut val = json!({
2307 "outer": {
2308 "inner": {
2309 "session_id": "sess-deep",
2310 "semantic_data": "keep me",
2311 "ts": "2026-01-01T00:00:00Z",
2312 "nested_array": [
2313 { "pid": 99, "name": "tool-a" },
2314 { "host": "deep-host", "value": 42 }
2315 ]
2316 }
2317 }
2318 });
2319 normalize_value(&mut val, None, &ctx);
2320 assert_eq!(val["outer"]["inner"]["session_id"], PLACEHOLDER_SESSION_ID);
2321 assert_eq!(val["outer"]["inner"]["semantic_data"], "keep me");
2322 assert_eq!(val["outer"]["inner"]["ts"], PLACEHOLDER_TIMESTAMP);
2323 assert_eq!(val["outer"]["inner"]["nested_array"][0]["pid"], 0);
2324 assert_eq!(val["outer"]["inner"]["nested_array"][0]["name"], "tool-a");
2325 assert_eq!(
2326 val["outer"]["inner"]["nested_array"][1]["host"],
2327 PLACEHOLDER_HOST
2328 );
2329 assert_eq!(val["outer"]["inner"]["nested_array"][1]["value"], 42);
2330 }
2331
2332 #[test]
2333 fn normalize_array_of_events() {
2334 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2335 let mut val = json!([
2336 { "ts": "2026-01-01", "session_id": "s1", "event": "start" },
2337 { "ts": "2026-01-02", "session_id": "s2", "event": "end" }
2338 ]);
2339 normalize_value(&mut val, None, &ctx);
2340 assert_eq!(val[0]["ts"], PLACEHOLDER_TIMESTAMP);
2341 assert_eq!(val[0]["session_id"], PLACEHOLDER_SESSION_ID);
2342 assert_eq!(val[0]["event"], "start");
2343 assert_eq!(val[1]["ts"], PLACEHOLDER_TIMESTAMP);
2344 assert_eq!(val[1]["session_id"], PLACEHOLDER_SESSION_ID);
2345 assert_eq!(val[1]["event"], "end");
2346 }
2347
2348 #[test]
2349 fn normalize_empty_structures_unchanged() {
2350 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2351 let mut empty_obj = json!({});
2352 normalize_value(&mut empty_obj, None, &ctx);
2353 assert_eq!(empty_obj, json!({}));
2354
2355 let mut empty_arr = json!([]);
2356 normalize_value(&mut empty_arr, None, &ctx);
2357 assert_eq!(empty_arr, json!([]));
2358
2359 let mut null_val = Value::Null;
2360 normalize_value(&mut null_val, None, &ctx);
2361 assert!(null_val.is_null());
2362
2363 let mut bool_val = json!(true);
2364 normalize_value(&mut bool_val, None, &ctx);
2365 assert_eq!(bool_val, true);
2366 }
2367
2368 #[test]
2369 fn normalize_string_combined_patterns() {
2370 let ctx = NormalizationContext::new(
2371 "/repo/pi".to_string(),
2372 "/repo/pi/legacy".to_string(),
2373 "/tmp/work".to_string(),
2374 );
2375 let input = "\x1b[31mrun-123e4567-e89b-12d3-a456-426614174000\x1b[0m at /tmp/work/test.rs with id=deadbeef-dead-beef-dead-beefdeadbeef http://127.0.0.1:9999/v1/api";
2376 let out = normalize_string(input, &ctx);
2377 assert!(!out.contains("\x1b["), "ANSI should be stripped");
2378 assert!(out.contains(PLACEHOLDER_RUN_ID), "run-ID: {out}");
2379 assert!(out.contains(PLACEHOLDER_PI_MONO_ROOT), "path: {out}");
2380 assert!(out.contains(PLACEHOLDER_UUID), "UUID: {out}");
2381 assert!(out.contains(PLACEHOLDER_PORT), "port: {out}");
2382 }
2383
2384 #[test]
2385 fn normalize_path_canonicalization_overlapping_roots() {
2386 let ctx = NormalizationContext::new(
2388 "/repo".to_string(),
2389 "/repo/legacy/pi-mono".to_string(),
2390 "/repo/legacy/pi-mono/test-dir".to_string(),
2391 );
2392 let input = "file at /repo/legacy/pi-mono/test-dir/output.txt";
2394 let out = normalize_string(input, &ctx);
2395 assert!(
2396 out.contains(PLACEHOLDER_PI_MONO_ROOT),
2397 "cwd inside pi_mono: {out}"
2398 );
2399 assert!(
2400 !out.contains("/repo/legacy/pi-mono/test-dir"),
2401 "original cwd should be gone: {out}"
2402 );
2403 assert!(
2405 !out.contains("test-dir"),
2406 "cwd subdirectory remnant should be normalized away: {out}"
2407 );
2408 }
2409
2410 #[test]
2411 fn normalize_idempotent() {
2412 let ctx = NormalizationContext::new(
2413 "/repo".to_string(),
2414 "/repo/legacy".to_string(),
2415 "/tmp/work".to_string(),
2416 );
2417 let contract = NormalizationContract::default();
2418 let input = json!({
2419 "ts": "2026-01-01",
2420 "session_id": "sess-x",
2421 "host": "myhost",
2422 "pid": 42,
2423 "message": "\x1b[31m/tmp/work/file.txt\x1b[0m"
2424 });
2425 let first = contract.normalize_and_canonicalize(input, &ctx);
2426 let second = contract.normalize_and_canonicalize(first.clone(), &ctx);
2427 assert_eq!(first, second, "normalization must be idempotent");
2428 }
2429
2430 #[test]
2431 fn normalize_preserves_all_semantic_fields() {
2432 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2433 let mut val = json!({
2434 "schema": "pi.ext.log.v1",
2435 "level": "info",
2436 "event": "tool_call.start",
2437 "extension_id": "ext.demo",
2438 "data": { "key": "value", "nested": [1, 2, 3] }
2439 });
2440 let original = val.clone();
2441 normalize_value(&mut val, None, &ctx);
2442 assert_eq!(val["schema"], original["schema"]);
2443 assert_eq!(val["level"], original["level"]);
2444 assert_eq!(val["event"], original["event"]);
2445 assert_eq!(val["extension_id"], original["extension_id"]);
2446 assert_eq!(val["data"]["key"], "value");
2447 assert_eq!(val["data"]["nested"], json!([1, 2, 3]));
2448 }
2449
2450 #[test]
2451 fn normalize_all_timestamp_key_variants() {
2452 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2453 for key in &[
2454 "timestamp",
2455 "started_at",
2456 "finished_at",
2457 "created_at",
2458 "createdAt",
2459 "ts",
2460 ] {
2461 let mut val =
2462 serde_json::from_str(&format!(r#"{{"{key}": "2026-01-01T00:00:00Z"}}"#))
2463 .unwrap();
2464 normalize_value(&mut val, None, &ctx);
2465 assert_eq!(
2466 val[key], PLACEHOLDER_TIMESTAMP,
2467 "key {key} should be normalized"
2468 );
2469 }
2470 }
2471
2472 #[test]
2473 fn normalize_numeric_timestamp_keys_zeroed() {
2474 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2475 for key in &["timestamp", "started_at", "finished_at", "ts"] {
2476 let mut val = serde_json::from_str(&format!(r#"{{"{key}": 1700000000}}"#)).unwrap();
2477 normalize_value(&mut val, None, &ctx);
2478 assert_eq!(val[key], 0, "numeric {key} should be zeroed");
2479 }
2480 }
2481
2482 #[test]
2483 fn canonicalize_json_keys_preserves_array_order() {
2484 let input = json!({"items": [3, 1, 2], "z": "last", "a": "first"});
2485 let out = canonicalize_json_keys(&input);
2486 let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
2488 assert_eq!(keys, &["a", "items", "z"]);
2489 assert_eq!(out["items"], json!([3, 1, 2]));
2490 }
2491
2492 #[test]
2493 fn canonicalize_json_keys_nested_arrays_of_objects() {
2494 let input = json!({
2495 "b": [
2496 {"z": 1, "a": 2},
2497 {"y": 3, "b": 4}
2498 ],
2499 "a": "first"
2500 });
2501 let out = canonicalize_json_keys(&input);
2502 let serialized = serde_json::to_string(&out).unwrap();
2503 assert_eq!(
2506 serialized,
2507 r#"{"a":"first","b":[{"a":2,"z":1},{"b":4,"y":3}]}"#
2508 );
2509 }
2510
2511 #[test]
2512 fn canonicalize_json_keys_scalar_values_unchanged() {
2513 assert_eq!(canonicalize_json_keys(&json!(42)), json!(42));
2514 assert_eq!(canonicalize_json_keys(&json!("hello")), json!("hello"));
2515 assert_eq!(canonicalize_json_keys(&json!(true)), json!(true));
2516 assert_eq!(canonicalize_json_keys(&json!(null)), json!(null));
2517 }
2518
2519 #[test]
2520 fn normalize_string_no_match_returns_unchanged() {
2521 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2522 let input = "plain text with no special patterns";
2523 let out = normalize_string(input, &ctx);
2524 assert_eq!(out, input);
2525 }
2526
2527 #[test]
2528 fn normalize_string_multiple_uuids() {
2529 let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2530 let input = "ids: 11111111-2222-3333-4444-555555555555 and 66666666-7777-8888-9999-aaaaaaaaaaaa";
2531 let out = normalize_string(input, &ctx);
2532 let count = out.matches(PLACEHOLDER_UUID).count();
2533 assert_eq!(count, 2, "both UUIDs should be replaced: {out}");
2534 }
2535
2536 #[test]
2537 fn is_path_key_additional_patterns() {
2538 assert!(is_path_key("outputPath"));
2539 assert!(is_path_key("inputDir"));
2540 assert!(is_path_key("rootdir"));
2541 assert!(is_path_key("filePaths"));
2542 assert!(!is_path_key("method"));
2543 assert!(!is_path_key("status"));
2544 assert!(!is_path_key(""));
2545 }
2546
2547 #[test]
2548 fn path_suffix_match_empty_strings() {
2549 assert!(path_suffix_match("", ""));
2550 assert!(!path_suffix_match("file.txt", ""));
2551 assert!(!path_suffix_match("", "file.txt"));
2552 }
2553
2554 #[test]
2555 fn path_suffix_match_partial_filename_no_match() {
2556 assert!(!path_suffix_match("/path/to/SKILL.md", "LL.md"));
2558 }
2559
2560 #[test]
2561 fn replace_path_variants_empty_path_noop() {
2562 let result = super::replace_path_variants("some input text", "", "PLACEHOLDER");
2563 assert_eq!(result, "some input text");
2564 }
2565
2566 #[test]
2567 fn replace_path_variants_backslash_form() {
2568 let result =
2569 super::replace_path_variants("C:\\repo\\pi\\file.rs", "/repo/pi", "<ROOT>");
2570 assert!(
2575 result.contains("<ROOT>"),
2576 "backslash variant should match: {result}"
2577 );
2578 }
2579
2580 #[test]
2581 fn context_new_explicit_paths() {
2582 let ctx =
2583 NormalizationContext::new("/a".to_string(), "/b".to_string(), "/c".to_string());
2584 assert_eq!(ctx.project_root, "/a");
2585 assert_eq!(ctx.pi_mono_root, "/b");
2586 assert_eq!(ctx.cwd, "/c");
2587 }
2588
2589 #[test]
2590 fn canonicalize_ui_method_non_ui_type_untouched() {
2591 let mut map = serde_json::Map::new();
2592 map.insert("type".into(), json!("rpc_request"));
2593 map.insert("method".into(), json!("setStatus"));
2594 super::canonicalize_ui_method(&mut map);
2595 assert_eq!(map["method"], "setStatus");
2596 }
2597
2598 #[test]
2599 fn canonicalize_ui_method_missing_type_untouched() {
2600 let mut map = serde_json::Map::new();
2601 map.insert("method".into(), json!("setStatus"));
2602 super::canonicalize_ui_method(&mut map);
2603 assert_eq!(map["method"], "setStatus");
2604 }
2605
2606 #[test]
2607 fn canonicalize_ui_method_unknown_method_untouched() {
2608 let mut map = serde_json::Map::new();
2609 map.insert("type".into(), json!("extension_ui_request"));
2610 map.insert("method".into(), json!("customOp"));
2611 super::canonicalize_ui_method(&mut map);
2612 assert_eq!(map["method"], "customOp");
2613 }
2614
2615 #[test]
2616 fn canonicalize_ui_method_all_aliases() {
2617 for &(alias, canonical) in UI_OP_ALIASES {
2618 let mut map = serde_json::Map::new();
2619 map.insert("type".into(), json!("extension_ui_request"));
2620 map.insert("method".into(), json!(alias));
2621 super::canonicalize_ui_method(&mut map);
2622 assert_eq!(
2623 map["method"].as_str().unwrap(),
2624 canonical,
2625 "alias {alias} should canonicalize to {canonical}"
2626 );
2627 }
2628 }
2629
2630 #[test]
2631 fn matches_any_key_none_returns_false() {
2632 assert!(!super::matches_any_key(None, &["ts", "pid"]));
2633 }
2634
2635 #[test]
2636 fn transport_id_placeholder_known_keys() {
2637 assert_eq!(
2638 super::transport_id_placeholder(Some("session_id")),
2639 Some(PLACEHOLDER_SESSION_ID)
2640 );
2641 assert_eq!(
2642 super::transport_id_placeholder(Some("sessionId")),
2643 Some(PLACEHOLDER_SESSION_ID)
2644 );
2645 assert_eq!(
2646 super::transport_id_placeholder(Some("run_id")),
2647 Some(PLACEHOLDER_RUN_ID)
2648 );
2649 assert_eq!(super::transport_id_placeholder(Some("unknown")), None);
2650 assert_eq!(super::transport_id_placeholder(None), None);
2651 }
2652
2653 #[test]
2654 fn fixed_placeholder_known_keys() {
2655 assert_eq!(
2656 super::fixed_placeholder(Some("cwd")),
2657 Some(PLACEHOLDER_PI_MONO_ROOT)
2658 );
2659 assert_eq!(
2660 super::fixed_placeholder(Some("host")),
2661 Some(PLACEHOLDER_HOST)
2662 );
2663 assert_eq!(super::fixed_placeholder(Some("other")), None);
2664 assert_eq!(super::fixed_placeholder(None), None);
2665 }
2666 }
2667}
2668
2669#[cfg(test)]
2670mod tests {
2671 use super::compare_conformance_output;
2672 use super::report::compute_regression;
2673 use super::report::generate_report;
2674 use super::report::{ConformanceDiffEntry, ConformanceStatus, ExtensionConformanceResult};
2675 use super::snapshot::{
2676 self, ArtifactSource, ArtifactSpec, SourceTier, validate_artifact_spec, validate_directory,
2677 validate_id,
2678 };
2679 use proptest::prelude::*;
2680 use proptest::string::string_regex;
2681 use serde_json::{Map, Value, json};
2682 use std::collections::BTreeSet;
2683
2684 #[test]
2685 fn ignores_registration_ordering_by_key() {
2686 let expected = json!({
2687 "extension_id": "ext",
2688 "name": "Ext",
2689 "version": "1.0.0",
2690 "registrations": {
2691 "commands": [
2692 { "name": "b", "description": "B" },
2693 { "name": "a", "description": "A" }
2694 ],
2695 "shortcuts": [
2696 { "key_id": "ctrl+a", "description": "A" }
2697 ],
2698 "flags": [],
2699 "providers": [],
2700 "tool_defs": [],
2701 "models": [],
2702 "event_hooks": ["on_message", "on_tool"]
2703 },
2704 "hostcall_log": []
2705 });
2706
2707 let actual = json!({
2708 "extension_id": "ext",
2709 "name": "Ext",
2710 "version": "1.0.0",
2711 "registrations": {
2712 "commands": [
2713 { "name": "a", "description": "A" },
2714 { "name": "b", "description": "B" }
2715 ],
2716 "shortcuts": [
2717 { "description": "A", "key_id": "ctrl+a" }
2718 ],
2719 "flags": [],
2720 "providers": [],
2721 "tool_defs": [],
2722 "models": [],
2723 "event_hooks": ["on_tool", "on_message"]
2724 },
2725 "hostcall_log": []
2726 });
2727
2728 compare_conformance_output(&expected, &actual).unwrap();
2729 }
2730
2731 #[test]
2732 fn hostcall_log_is_order_sensitive() {
2733 let expected = json!({
2734 "extension_id": "ext",
2735 "name": "Ext",
2736 "version": "1.0.0",
2737 "registrations": {
2738 "commands": [],
2739 "shortcuts": [],
2740 "flags": [],
2741 "providers": [],
2742 "tool_defs": [],
2743 "models": [],
2744 "event_hooks": []
2745 },
2746 "hostcall_log": [
2747 { "op": "get_state", "result": { "a": 1 } },
2748 { "op": "set_name", "payload": { "name": "x" } }
2749 ]
2750 });
2751
2752 let actual = json!({
2753 "extension_id": "ext",
2754 "name": "Ext",
2755 "version": "1.0.0",
2756 "registrations": {
2757 "commands": [],
2758 "shortcuts": [],
2759 "flags": [],
2760 "providers": [],
2761 "tool_defs": [],
2762 "models": [],
2763 "event_hooks": []
2764 },
2765 "hostcall_log": [
2766 { "op": "set_name", "payload": { "name": "x" } },
2767 { "op": "get_state", "result": { "a": 1 } }
2768 ]
2769 });
2770
2771 let err = compare_conformance_output(&expected, &actual).unwrap_err();
2772 assert!(err.contains("HOSTCALL"), "missing HOSTCALL header: {err}");
2773 assert!(
2774 err.contains("hostcall_log[0].op"),
2775 "expected path to mention index 0 op: {err}"
2776 );
2777 }
2778
2779 #[test]
2780 fn treats_missing_as_null_and_empty_array_equivalent() {
2781 let expected = json!({
2782 "extension_id": "ext",
2783 "name": "Ext",
2784 "version": "1.0.0",
2785 "registrations": {
2786 "commands": [
2787 { "name": "a", "description": null }
2788 ],
2789 "shortcuts": [],
2790 "flags": [],
2791 "providers": [],
2792 "tool_defs": [],
2793 "models": [],
2794 "event_hooks": []
2795 },
2796 "hostcall_log": []
2797 });
2798
2799 let actual = json!({
2800 "extension_id": "ext",
2801 "name": "Ext",
2802 "version": "1.0.0",
2803 "registrations": {
2804 "commands": [
2805 { "name": "a" }
2806 ],
2807 "shortcuts": [],
2808 "flags": [],
2809 "providers": [],
2810 "tool_defs": [],
2811 "models": [],
2812 "event_hooks": []
2813 }
2814 });
2815
2816 compare_conformance_output(&expected, &actual).unwrap();
2817 }
2818
2819 #[test]
2820 fn compares_numbers_with_tolerance() {
2821 let expected = json!({
2822 "extension_id": "ext",
2823 "name": "Ext",
2824 "version": "1.0.0",
2825 "registrations": {
2826 "commands": [],
2827 "shortcuts": [],
2828 "flags": [],
2829 "providers": [],
2830 "tool_defs": [
2831 { "name": "t", "parameters": { "precision": 0.1 } }
2832 ],
2833 "models": [],
2834 "event_hooks": []
2835 },
2836 "hostcall_log": []
2837 });
2838
2839 let actual = json!({
2840 "extension_id": "ext",
2841 "name": "Ext",
2842 "version": "1.0.0",
2843 "registrations": {
2844 "commands": [],
2845 "shortcuts": [],
2846 "flags": [],
2847 "providers": [],
2848 "tool_defs": [
2849 { "name": "t", "parameters": { "precision": 0.100_000_000_000_01 } }
2850 ],
2851 "models": [],
2852 "event_hooks": []
2853 },
2854 "hostcall_log": []
2855 });
2856
2857 compare_conformance_output(&expected, &actual).unwrap();
2858 }
2859
2860 #[test]
2861 fn required_array_order_does_not_matter() {
2862 let expected = json!({
2863 "extension_id": "ext",
2864 "name": "Ext",
2865 "version": "1.0.0",
2866 "registrations": {
2867 "commands": [],
2868 "shortcuts": [],
2869 "flags": [],
2870 "providers": [],
2871 "tool_defs": [
2872 { "name": "t", "parameters": { "required": ["b", "a"] } }
2873 ],
2874 "models": [],
2875 "event_hooks": []
2876 },
2877 "hostcall_log": []
2878 });
2879
2880 let actual = json!({
2881 "extension_id": "ext",
2882 "name": "Ext",
2883 "version": "1.0.0",
2884 "registrations": {
2885 "commands": [],
2886 "shortcuts": [],
2887 "flags": [],
2888 "providers": [],
2889 "tool_defs": [
2890 { "name": "t", "parameters": { "required": ["a", "b"] } }
2891 ],
2892 "models": [],
2893 "event_hooks": []
2894 },
2895 "hostcall_log": []
2896 });
2897
2898 compare_conformance_output(&expected, &actual).unwrap();
2899 }
2900
2901 #[test]
2902 fn conformance_report_summarizes_and_renders_markdown() {
2903 let results = vec![
2904 ExtensionConformanceResult {
2905 id: "hello".to_string(),
2906 tier: Some(1),
2907 status: ConformanceStatus::Pass,
2908 ts_time_ms: Some(42),
2909 rust_time_ms: Some(38),
2910 diffs: Vec::new(),
2911 notes: None,
2912 },
2913 ExtensionConformanceResult {
2914 id: "event-bus".to_string(),
2915 tier: Some(2),
2916 status: ConformanceStatus::Fail,
2917 ts_time_ms: Some(55),
2918 rust_time_ms: Some(60),
2919 diffs: vec![ConformanceDiffEntry {
2920 category: "registration.event_hooks".to_string(),
2921 path: "registrations.event_hooks".to_string(),
2922 message: "extra hook in Rust".to_string(),
2923 }],
2924 notes: Some("registration mismatch".to_string()),
2925 },
2926 ExtensionConformanceResult {
2927 id: "ui-heavy".to_string(),
2928 tier: Some(6),
2929 status: ConformanceStatus::Skip,
2930 ts_time_ms: None,
2931 rust_time_ms: None,
2932 diffs: Vec::new(),
2933 notes: Some("ignored in CI".to_string()),
2934 },
2935 ];
2936
2937 let report = generate_report(
2938 "run-test",
2939 Some("2026-02-05T00:00:00Z".to_string()),
2940 results,
2941 );
2942
2943 assert_eq!(report.summary.total, 3);
2944 assert_eq!(report.summary.passed, 1);
2945 assert_eq!(report.summary.failed, 1);
2946 assert_eq!(report.summary.skipped, 1);
2947 assert_eq!(report.summary.errors, 0);
2948 assert!(report.summary.by_tier.contains_key("tier1"));
2949 assert!(report.summary.by_tier.contains_key("tier2"));
2950 assert!(report.summary.by_tier.contains_key("tier6"));
2951
2952 let md = report.render_markdown();
2953 assert!(md.contains("# Extension Conformance Report"));
2954 assert!(md.contains("Run ID: run-test"));
2955 assert!(md.contains("| hello | PASS | 42ms | 38ms |"));
2956 assert!(md.contains("## Failures"));
2957 assert!(md.contains("### event-bus (Tier 2)"));
2958 }
2959
2960 #[test]
2961 fn conformance_regression_ignores_new_extensions_for_pass_rate() {
2962 let previous = generate_report(
2963 "run-prev",
2964 Some("2026-02-05T00:00:00Z".to_string()),
2965 vec![
2966 ExtensionConformanceResult {
2967 id: "a".to_string(),
2968 tier: Some(1),
2969 status: ConformanceStatus::Pass,
2970 ts_time_ms: None,
2971 rust_time_ms: None,
2972 diffs: Vec::new(),
2973 notes: None,
2974 },
2975 ExtensionConformanceResult {
2976 id: "b".to_string(),
2977 tier: Some(1),
2978 status: ConformanceStatus::Fail,
2979 ts_time_ms: None,
2980 rust_time_ms: None,
2981 diffs: Vec::new(),
2982 notes: None,
2983 },
2984 ],
2985 );
2986
2987 let current = generate_report(
2988 "run-cur",
2989 Some("2026-02-06T00:00:00Z".to_string()),
2990 vec![
2991 ExtensionConformanceResult {
2992 id: "a".to_string(),
2993 tier: Some(1),
2994 status: ConformanceStatus::Pass,
2995 ts_time_ms: None,
2996 rust_time_ms: None,
2997 diffs: Vec::new(),
2998 notes: None,
2999 },
3000 ExtensionConformanceResult {
3001 id: "b".to_string(),
3002 tier: Some(1),
3003 status: ConformanceStatus::Fail,
3004 ts_time_ms: None,
3005 rust_time_ms: None,
3006 diffs: Vec::new(),
3007 notes: None,
3008 },
3009 ExtensionConformanceResult {
3011 id: "c".to_string(),
3012 tier: Some(1),
3013 status: ConformanceStatus::Fail,
3014 ts_time_ms: None,
3015 rust_time_ms: None,
3016 diffs: Vec::new(),
3017 notes: None,
3018 },
3019 ],
3020 );
3021
3022 let regression = compute_regression(&previous, ¤t);
3023 assert!(!regression.has_regression());
3024 assert_eq!(regression.compared_total, 2);
3025 assert_eq!(regression.previous_passed, 1);
3026 assert_eq!(regression.current_passed, 1);
3027 }
3028
3029 #[test]
3030 fn conformance_regression_flags_pass_to_fail() {
3031 let previous = generate_report(
3032 "run-prev",
3033 Some("2026-02-05T00:00:00Z".to_string()),
3034 vec![ExtensionConformanceResult {
3035 id: "a".to_string(),
3036 tier: Some(1),
3037 status: ConformanceStatus::Pass,
3038 ts_time_ms: None,
3039 rust_time_ms: None,
3040 diffs: Vec::new(),
3041 notes: None,
3042 }],
3043 );
3044
3045 let current = generate_report(
3046 "run-cur",
3047 Some("2026-02-06T00:00:00Z".to_string()),
3048 vec![ExtensionConformanceResult {
3049 id: "a".to_string(),
3050 tier: Some(1),
3051 status: ConformanceStatus::Fail,
3052 ts_time_ms: None,
3053 rust_time_ms: None,
3054 diffs: vec![ConformanceDiffEntry {
3055 category: "root".to_string(),
3056 path: "x".to_string(),
3057 message: "changed".to_string(),
3058 }],
3059 notes: None,
3060 }],
3061 );
3062
3063 let regression = compute_regression(&previous, ¤t);
3064 assert!(regression.has_regression());
3065 assert_eq!(regression.regressed_extensions.len(), 1);
3066 assert_eq!(regression.regressed_extensions[0].id, "a");
3067 assert_eq!(
3068 regression.regressed_extensions[0].current,
3069 Some(ConformanceStatus::Fail)
3070 );
3071 }
3072
3073 #[test]
3078 fn snapshot_validate_id_accepts_valid_ids() {
3079 assert!(validate_id("hello").is_ok());
3080 assert!(validate_id("auto-commit-on-exit").is_ok());
3081 assert!(validate_id("my-ext-2").is_ok());
3082 assert!(validate_id("agents-mikeastock/extensions").is_ok());
3083 }
3084
3085 #[test]
3086 fn snapshot_validate_id_rejects_invalid_ids() {
3087 assert!(validate_id("").is_err());
3088 assert!(validate_id("Hello").is_err());
3089 assert!(validate_id("my_ext").is_err());
3090 assert!(validate_id("-leading").is_err());
3091 assert!(validate_id("trailing-").is_err());
3092 assert!(validate_id("has space").is_err());
3093 }
3094
3095 #[test]
3096 fn snapshot_validate_directory_official_tier() {
3097 assert!(validate_directory("hello", SourceTier::OfficialPiMono).is_ok());
3098 assert!(validate_directory("community/x", SourceTier::OfficialPiMono).is_err());
3099 assert!(validate_directory("npm/x", SourceTier::OfficialPiMono).is_err());
3100 }
3101
3102 #[test]
3103 fn snapshot_validate_directory_scoped_tiers() {
3104 assert!(validate_directory("community/my-ext", SourceTier::Community).is_ok());
3105 assert!(validate_directory("my-ext", SourceTier::Community).is_err());
3106
3107 assert!(validate_directory("npm/some-pkg", SourceTier::NpmRegistry).is_ok());
3108 assert!(validate_directory("some-pkg", SourceTier::NpmRegistry).is_err());
3109
3110 assert!(validate_directory("third-party/repo", SourceTier::ThirdPartyGithub).is_ok());
3111 assert!(validate_directory("repo", SourceTier::ThirdPartyGithub).is_err());
3112
3113 assert!(validate_directory("templates/my-tpl", SourceTier::Templates).is_ok());
3114 }
3115
3116 #[test]
3117 fn snapshot_validate_directory_empty_rejected() {
3118 assert!(validate_directory("", SourceTier::OfficialPiMono).is_err());
3119 }
3120
3121 #[test]
3122 fn snapshot_source_tier_from_directory() {
3123 assert_eq!(
3124 SourceTier::from_directory("hello"),
3125 SourceTier::OfficialPiMono
3126 );
3127 assert_eq!(
3128 SourceTier::from_directory("community/foo"),
3129 SourceTier::Community
3130 );
3131 assert_eq!(
3132 SourceTier::from_directory("npm/bar"),
3133 SourceTier::NpmRegistry
3134 );
3135 assert_eq!(
3136 SourceTier::from_directory("third-party/baz"),
3137 SourceTier::ThirdPartyGithub
3138 );
3139 assert_eq!(
3140 SourceTier::from_directory("agents-mikeastock/ext"),
3141 SourceTier::AgentsMikeastock
3142 );
3143 assert_eq!(
3144 SourceTier::from_directory("templates/tpl"),
3145 SourceTier::Templates
3146 );
3147 }
3148
3149 #[test]
3150 fn snapshot_validate_spec_valid() {
3151 let spec = ArtifactSpec {
3152 id: "my-ext".into(),
3153 directory: "community/my-ext".into(),
3154 name: "My Extension".into(),
3155 source_tier: SourceTier::Community,
3156 license: "MIT".into(),
3157 source: ArtifactSource::Git {
3158 repo: "https://github.com/user/repo".into(),
3159 path: Some("extensions/my-ext.ts".into()),
3160 commit: None,
3161 },
3162 };
3163 assert!(validate_artifact_spec(&spec).is_empty());
3164 }
3165
3166 #[test]
3167 fn snapshot_validate_spec_collects_multiple_errors() {
3168 let spec = ArtifactSpec {
3169 id: String::new(),
3170 directory: "my-ext".into(),
3171 name: String::new(),
3172 source_tier: SourceTier::Community,
3173 license: String::new(),
3174 source: ArtifactSource::Git {
3175 repo: String::new(),
3176 path: None,
3177 commit: None,
3178 },
3179 };
3180 let errors = validate_artifact_spec(&spec);
3181 assert!(errors.len() >= 4, "expected at least 4 errors: {errors:?}");
3182 }
3183
3184 #[test]
3185 fn snapshot_validate_spec_npm_source() {
3186 let spec = ArtifactSpec {
3187 id: "npm-ext".into(),
3188 directory: "npm/npm-ext".into(),
3189 name: "NPM Extension".into(),
3190 source_tier: SourceTier::NpmRegistry,
3191 license: "UNKNOWN".into(),
3192 source: ArtifactSource::Npm {
3193 package: "npm-ext".into(),
3194 version: "1.0.0".into(),
3195 url: None,
3196 },
3197 };
3198 assert!(validate_artifact_spec(&spec).is_empty());
3199
3200 let bad = ArtifactSpec {
3202 source: ArtifactSource::Npm {
3203 package: String::new(),
3204 version: "1.0.0".into(),
3205 url: None,
3206 },
3207 ..spec
3208 };
3209 assert!(!validate_artifact_spec(&bad).is_empty());
3210 }
3211
3212 #[test]
3213 fn snapshot_validate_spec_url_source() {
3214 let spec = ArtifactSpec {
3215 id: "url-ext".into(),
3216 directory: "third-party/url-ext".into(),
3217 name: "URL Extension".into(),
3218 source_tier: SourceTier::ThirdPartyGithub,
3219 license: "Apache-2.0".into(),
3220 source: ArtifactSource::Url {
3221 url: "https://example.com/ext.ts".into(),
3222 },
3223 };
3224 assert!(validate_artifact_spec(&spec).is_empty());
3225 }
3226
3227 #[test]
3228 fn snapshot_digest_artifact_dir_deterministic() {
3229 let tmp = tempfile::tempdir().unwrap();
3230 std::fs::write(tmp.path().join("hello.ts"), b"console.log('hi');").unwrap();
3231 std::fs::write(tmp.path().join("index.ts"), b"export default function() {}").unwrap();
3232
3233 let d1 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3234 let d2 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3235 assert_eq!(d1, d2, "digest must be deterministic");
3236 assert_eq!(d1.len(), 64, "SHA-256 hex must be 64 chars");
3237 }
3238
3239 #[test]
3240 fn snapshot_digest_artifact_dir_changes_with_content() {
3241 let tmp = tempfile::tempdir().unwrap();
3242 std::fs::write(tmp.path().join("a.ts"), b"version1").unwrap();
3243 let d1 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3244
3245 std::fs::write(tmp.path().join("a.ts"), b"version2").unwrap();
3246 let d2 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3247
3248 assert_ne!(d1, d2, "different content must produce different digest");
3249 }
3250
3251 #[test]
3252 fn snapshot_verify_integrity_pass() {
3253 let tmp = tempfile::tempdir().unwrap();
3254 std::fs::write(tmp.path().join("test.ts"), b"hello").unwrap();
3255 let digest = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3256
3257 let result = snapshot::verify_integrity(tmp.path(), &digest).unwrap();
3258 assert!(result.is_ok());
3259 }
3260
3261 #[test]
3262 fn snapshot_verify_integrity_fail() {
3263 let tmp = tempfile::tempdir().unwrap();
3264 std::fs::write(tmp.path().join("test.ts"), b"hello").unwrap();
3265
3266 let result = snapshot::verify_integrity(
3267 tmp.path(),
3268 "0000000000000000000000000000000000000000000000000000000000000000",
3269 )
3270 .unwrap();
3271 assert!(result.is_err());
3272 assert!(result.unwrap_err().contains("checksum mismatch"));
3273 }
3274
3275 #[test]
3276 fn snapshot_is_reserved_dir() {
3277 assert!(snapshot::is_reserved_dir("base_fixtures"));
3278 assert!(snapshot::is_reserved_dir("community"));
3279 assert!(snapshot::is_reserved_dir("plugins-official"));
3280 assert!(!snapshot::is_reserved_dir("hello"));
3281 assert!(!snapshot::is_reserved_dir("my-ext"));
3282 }
3283
3284 #[test]
3285 fn snapshot_source_tier_roundtrip_serde() {
3286 let tier = SourceTier::ThirdPartyGithub;
3287 let json = serde_json::to_string(&tier).unwrap();
3288 assert_eq!(json, "\"third-party-github\"");
3289 let parsed: SourceTier = serde_json::from_str(&json).unwrap();
3290 assert_eq!(parsed, tier);
3291 }
3292
3293 #[test]
3294 fn snapshot_artifact_spec_serde_roundtrip() {
3295 let spec = ArtifactSpec {
3296 id: "test-ext".into(),
3297 directory: "community/test-ext".into(),
3298 name: "Test".into(),
3299 source_tier: SourceTier::Community,
3300 license: "MIT".into(),
3301 source: ArtifactSource::Git {
3302 repo: "https://github.com/user/repo".into(),
3303 path: None,
3304 commit: Some("abc123".into()),
3305 },
3306 };
3307 let json = serde_json::to_string_pretty(&spec).unwrap();
3308 let parsed: ArtifactSpec = serde_json::from_str(&json).unwrap();
3309 assert_eq!(parsed.id, "test-ext");
3310 assert_eq!(parsed.source_tier, SourceTier::Community);
3311 }
3312
3313 #[allow(clippy::needless_pass_by_value)]
3318 fn base_output(
3319 registrations: serde_json::Value,
3320 hostcall_log: serde_json::Value,
3321 ) -> serde_json::Value {
3322 json!({
3323 "extension_id": "ext",
3324 "name": "Ext",
3325 "version": "1.0.0",
3326 "registrations": registrations,
3327 "hostcall_log": hostcall_log
3328 })
3329 }
3330
3331 fn empty_registrations() -> serde_json::Value {
3332 json!({
3333 "commands": [],
3334 "shortcuts": [],
3335 "flags": [],
3336 "providers": [],
3337 "tool_defs": [],
3338 "models": [],
3339 "event_hooks": []
3340 })
3341 }
3342
3343 #[test]
3344 fn compare_detects_root_extension_id_mismatch() {
3345 let expected = base_output(empty_registrations(), json!([]));
3346 let mut actual = expected.clone();
3347 actual["extension_id"] = json!("other");
3348 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3349 assert!(err.contains("ROOT"), "should report ROOT diff: {err}");
3350 assert!(err.contains("extension_id"), "should mention field: {err}");
3351 }
3352
3353 #[test]
3354 fn compare_detects_root_name_mismatch() {
3355 let expected = base_output(empty_registrations(), json!([]));
3356 let mut actual = expected.clone();
3357 actual["name"] = json!("Different");
3358 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3359 assert!(err.contains("ROOT"), "diff kind: {err}");
3360 }
3361
3362 #[test]
3363 fn compare_detects_root_version_mismatch() {
3364 let expected = base_output(empty_registrations(), json!([]));
3365 let mut actual = expected.clone();
3366 actual["version"] = json!("2.0.0");
3367 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3368 assert!(err.contains("version"), "diff: {err}");
3369 }
3370
3371 #[test]
3372 fn compare_detects_extra_registration_item() {
3373 let expected = base_output(
3374 json!({
3375 "commands": [{"name": "a", "description": "A"}],
3376 "shortcuts": [], "flags": [], "providers": [],
3377 "tool_defs": [], "models": [], "event_hooks": []
3378 }),
3379 json!([]),
3380 );
3381 let actual = base_output(
3382 json!({
3383 "commands": [
3384 {"name": "a", "description": "A"},
3385 {"name": "b", "description": "B"}
3386 ],
3387 "shortcuts": [], "flags": [], "providers": [],
3388 "tool_defs": [], "models": [], "event_hooks": []
3389 }),
3390 json!([]),
3391 );
3392 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3393 assert!(err.contains("extra"), "should report extra: {err}");
3394 assert!(err.contains("name=b"), "should name the extra item: {err}");
3395 }
3396
3397 #[test]
3398 fn compare_detects_missing_registration_item() {
3399 let expected = base_output(
3400 json!({
3401 "commands": [
3402 {"name": "a", "description": "A"},
3403 {"name": "b", "description": "B"}
3404 ],
3405 "shortcuts": [], "flags": [], "providers": [],
3406 "tool_defs": [], "models": [], "event_hooks": []
3407 }),
3408 json!([]),
3409 );
3410 let actual = base_output(
3411 json!({
3412 "commands": [{"name": "a", "description": "A"}],
3413 "shortcuts": [], "flags": [], "providers": [],
3414 "tool_defs": [], "models": [], "event_hooks": []
3415 }),
3416 json!([]),
3417 );
3418 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3419 assert!(err.contains("missing"), "should report missing: {err}");
3420 }
3421
3422 #[test]
3423 fn compare_detects_string_value_mismatch_in_registration() {
3424 let expected = base_output(
3425 json!({
3426 "commands": [{"name": "a", "description": "original"}],
3427 "shortcuts": [], "flags": [], "providers": [],
3428 "tool_defs": [], "models": [], "event_hooks": []
3429 }),
3430 json!([]),
3431 );
3432 let actual = base_output(
3433 json!({
3434 "commands": [{"name": "a", "description": "changed"}],
3435 "shortcuts": [], "flags": [], "providers": [],
3436 "tool_defs": [], "models": [], "event_hooks": []
3437 }),
3438 json!([]),
3439 );
3440 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3441 assert!(err.contains("description"), "should identify field: {err}");
3442 }
3443
3444 #[test]
3445 fn compare_type_mismatch_produces_clear_diff() {
3446 let expected = base_output(
3447 json!({
3448 "commands": [{"name": "a", "value": "string"}],
3449 "shortcuts": [], "flags": [], "providers": [],
3450 "tool_defs": [], "models": [], "event_hooks": []
3451 }),
3452 json!([]),
3453 );
3454 let actual = base_output(
3455 json!({
3456 "commands": [{"name": "a", "value": 42}],
3457 "shortcuts": [], "flags": [], "providers": [],
3458 "tool_defs": [], "models": [], "event_hooks": []
3459 }),
3460 json!([]),
3461 );
3462 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3463 assert!(
3464 err.contains("type mismatch"),
3465 "should report type mismatch: {err}"
3466 );
3467 }
3468
3469 #[test]
3470 fn compare_event_hooks_order_insensitive() {
3471 let expected = base_output(
3472 json!({
3473 "commands": [], "shortcuts": [], "flags": [],
3474 "providers": [], "tool_defs": [], "models": [],
3475 "event_hooks": ["on_tool", "on_message", "on_session"]
3476 }),
3477 json!([]),
3478 );
3479 let actual = base_output(
3480 json!({
3481 "commands": [], "shortcuts": [], "flags": [],
3482 "providers": [], "tool_defs": [], "models": [],
3483 "event_hooks": ["on_session", "on_tool", "on_message"]
3484 }),
3485 json!([]),
3486 );
3487 compare_conformance_output(&expected, &actual).unwrap();
3488 }
3489
3490 #[test]
3491 fn compare_event_hooks_detects_mismatch() {
3492 let expected = base_output(
3493 json!({
3494 "commands": [], "shortcuts": [], "flags": [],
3495 "providers": [], "tool_defs": [], "models": [],
3496 "event_hooks": ["on_tool", "on_message"]
3497 }),
3498 json!([]),
3499 );
3500 let actual = base_output(
3501 json!({
3502 "commands": [], "shortcuts": [], "flags": [],
3503 "providers": [], "tool_defs": [], "models": [],
3504 "event_hooks": ["on_tool", "on_session"]
3505 }),
3506 json!([]),
3507 );
3508 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3509 assert!(
3510 err.contains("on_message") || err.contains("on_session"),
3511 "should report hook difference: {err}"
3512 );
3513 }
3514
3515 #[test]
3516 fn compare_empty_outputs_equal() {
3517 let a = base_output(empty_registrations(), json!([]));
3518 compare_conformance_output(&a, &a).unwrap();
3519 }
3520
3521 #[test]
3522 fn compare_null_registration_reports_error() {
3523 let expected = json!({
3526 "extension_id": "ext",
3527 "name": "Ext",
3528 "version": "1.0.0",
3529 "registrations": null,
3530 "hostcall_log": []
3531 });
3532 let actual = json!({
3533 "extension_id": "ext",
3534 "name": "Ext",
3535 "version": "1.0.0",
3536 "registrations": null,
3537 "hostcall_log": []
3538 });
3539 let result = compare_conformance_output(&expected, &actual);
3543 assert!(
3544 result.is_err(),
3545 "null registrations should produce diff (expected object)"
3546 );
3547 }
3548
3549 #[test]
3550 fn compare_both_missing_registrations_reports_expected_object() {
3551 let expected = json!({
3555 "extension_id": "ext",
3556 "name": "Ext",
3557 "version": "1.0.0",
3558 "hostcall_log": []
3559 });
3560 let actual = expected.clone();
3561 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3562 assert!(
3563 err.contains("expected an object"),
3564 "missing registrations should be flagged: {err}"
3565 );
3566 }
3567
3568 #[test]
3569 fn compare_hostcall_log_length_mismatch() {
3570 let expected = base_output(
3571 empty_registrations(),
3572 json!([
3573 {"op": "get_state", "result": {}},
3574 {"op": "set_name", "payload": {"name": "x"}}
3575 ]),
3576 );
3577 let actual = base_output(
3578 empty_registrations(),
3579 json!([{"op": "get_state", "result": {}}]),
3580 );
3581 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3582 assert!(
3583 err.contains("length mismatch"),
3584 "should report length: {err}"
3585 );
3586 }
3587
3588 #[test]
3589 fn compare_float_within_epsilon_equal() {
3590 let expected = base_output(
3591 json!({
3592 "commands": [], "shortcuts": [], "flags": [],
3593 "providers": [], "models": [], "event_hooks": [],
3594 "tool_defs": [{"name": "t", "score": 0.1}]
3595 }),
3596 json!([]),
3597 );
3598 let actual = base_output(
3599 json!({
3600 "commands": [], "shortcuts": [], "flags": [],
3601 "providers": [], "models": [], "event_hooks": [],
3602 "tool_defs": [{"name": "t", "score": 0.100_000_000_000_001}]
3603 }),
3604 json!([]),
3605 );
3606 compare_conformance_output(&expected, &actual).unwrap();
3607 }
3608
3609 #[test]
3610 fn compare_float_beyond_epsilon_differs() {
3611 let expected = base_output(
3612 json!({
3613 "commands": [], "shortcuts": [], "flags": [],
3614 "providers": [], "models": [], "event_hooks": [],
3615 "tool_defs": [{"name": "t", "score": 0.1}]
3616 }),
3617 json!([]),
3618 );
3619 let actual = base_output(
3620 json!({
3621 "commands": [], "shortcuts": [], "flags": [],
3622 "providers": [], "models": [], "event_hooks": [],
3623 "tool_defs": [{"name": "t", "score": 0.2}]
3624 }),
3625 json!([]),
3626 );
3627 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3628 assert!(err.contains("score"), "should mention score: {err}");
3629 }
3630
3631 #[test]
3632 fn compare_integer_exact_match() {
3633 let expected = base_output(
3634 json!({
3635 "commands": [], "shortcuts": [], "flags": [],
3636 "providers": [], "models": [], "event_hooks": [],
3637 "tool_defs": [{"name": "t", "count": 42}]
3638 }),
3639 json!([]),
3640 );
3641 let actual = base_output(
3642 json!({
3643 "commands": [], "shortcuts": [], "flags": [],
3644 "providers": [], "models": [], "event_hooks": [],
3645 "tool_defs": [{"name": "t", "count": 42}]
3646 }),
3647 json!([]),
3648 );
3649 compare_conformance_output(&expected, &actual).unwrap();
3650 }
3651
3652 #[test]
3653 fn compare_with_events_section() {
3654 let expected = json!({
3655 "extension_id": "ext",
3656 "name": "Ext",
3657 "version": "1.0.0",
3658 "registrations": {
3659 "commands": [], "shortcuts": [], "flags": [],
3660 "providers": [], "tool_defs": [], "models": [],
3661 "event_hooks": []
3662 },
3663 "hostcall_log": [],
3664 "events": { "count": 3, "types": ["start", "end"] }
3665 });
3666 let actual = json!({
3667 "extension_id": "ext",
3668 "name": "Ext",
3669 "version": "1.0.0",
3670 "registrations": {
3671 "commands": [], "shortcuts": [], "flags": [],
3672 "providers": [], "tool_defs": [], "models": [],
3673 "event_hooks": []
3674 },
3675 "hostcall_log": [],
3676 "events": { "count": 3, "types": ["start", "end"] }
3677 });
3678 compare_conformance_output(&expected, &actual).unwrap();
3679 }
3680
3681 #[test]
3682 fn compare_events_mismatch_detected() {
3683 let expected = json!({
3684 "extension_id": "ext",
3685 "name": "Ext",
3686 "version": "1.0.0",
3687 "registrations": {
3688 "commands": [], "shortcuts": [], "flags": [],
3689 "providers": [], "tool_defs": [], "models": [],
3690 "event_hooks": []
3691 },
3692 "hostcall_log": [],
3693 "events": { "count": 3 }
3694 });
3695 let actual = json!({
3696 "extension_id": "ext",
3697 "name": "Ext",
3698 "version": "1.0.0",
3699 "registrations": {
3700 "commands": [], "shortcuts": [], "flags": [],
3701 "providers": [], "tool_defs": [], "models": [],
3702 "event_hooks": []
3703 },
3704 "hostcall_log": [],
3705 "events": { "count": 5 }
3706 });
3707 let err = compare_conformance_output(&expected, &actual).unwrap_err();
3708 assert!(err.contains("EVENT"), "should be EVENT diff: {err}");
3709 }
3710
3711 #[test]
3712 fn compare_missing_events_both_sides_ok() {
3713 let a = base_output(empty_registrations(), json!([]));
3714 compare_conformance_output(&a, &a).unwrap();
3716 }
3717
3718 #[test]
3719 fn compare_shortcuts_keyed_by_key_id() {
3720 let expected = base_output(
3721 json!({
3722 "commands": [], "flags": [], "providers": [],
3723 "tool_defs": [], "models": [], "event_hooks": [],
3724 "shortcuts": [
3725 {"key_id": "ctrl+b", "label": "Bold"},
3726 {"key_id": "ctrl+a", "label": "All"}
3727 ]
3728 }),
3729 json!([]),
3730 );
3731 let actual = base_output(
3732 json!({
3733 "commands": [], "flags": [], "providers": [],
3734 "tool_defs": [], "models": [], "event_hooks": [],
3735 "shortcuts": [
3736 {"key_id": "ctrl+a", "label": "All"},
3737 {"key_id": "ctrl+b", "label": "Bold"}
3738 ]
3739 }),
3740 json!([]),
3741 );
3742 compare_conformance_output(&expected, &actual).unwrap();
3744 }
3745
3746 #[test]
3747 fn compare_models_keyed_by_id() {
3748 let expected = base_output(
3749 json!({
3750 "commands": [], "shortcuts": [], "flags": [],
3751 "providers": [], "tool_defs": [], "event_hooks": [],
3752 "models": [
3753 {"id": "m2", "name": "Model 2"},
3754 {"id": "m1", "name": "Model 1"}
3755 ]
3756 }),
3757 json!([]),
3758 );
3759 let actual = base_output(
3760 json!({
3761 "commands": [], "shortcuts": [], "flags": [],
3762 "providers": [], "tool_defs": [], "event_hooks": [],
3763 "models": [
3764 {"id": "m1", "name": "Model 1"},
3765 {"id": "m2", "name": "Model 2"}
3766 ]
3767 }),
3768 json!([]),
3769 );
3770 compare_conformance_output(&expected, &actual).unwrap();
3771 }
3772
3773 #[test]
3774 fn compare_models_with_duplicate_ids_is_reflexive() {
3775 let sample = json!({
3778 "extension_id": "a",
3779 "name": "_",
3780 "version": "0.0.0",
3781 "registrations": {
3782 "commands": [],
3783 "event_hooks": [],
3784 "flags": [],
3785 "models": [
3786 {"id": "_", "name": "model-_"},
3787 {"id": "_", "name": "model-_"}
3788 ],
3789 "providers": [],
3790 "shortcuts": [],
3791 "tool_defs": []
3792 },
3793 "hostcall_log": []
3794 });
3795 compare_conformance_output(&sample, &sample).unwrap();
3796 }
3797
3798 #[test]
3799 fn report_empty_results() {
3800 let report = generate_report(
3801 "run-empty",
3802 Some("2026-02-07T00:00:00Z".to_string()),
3803 vec![],
3804 );
3805 assert_eq!(report.summary.total, 0);
3806 assert_eq!(report.summary.passed, 0);
3807 assert!(report.summary.pass_rate.abs() < f64::EPSILON);
3808 assert!(report.summary.by_tier.is_empty());
3809 }
3810
3811 #[test]
3812 fn report_all_pass() {
3813 let results = vec![
3814 ExtensionConformanceResult {
3815 id: "a".into(),
3816 tier: Some(1),
3817 status: ConformanceStatus::Pass,
3818 ts_time_ms: Some(10),
3819 rust_time_ms: Some(8),
3820 diffs: vec![],
3821 notes: None,
3822 },
3823 ExtensionConformanceResult {
3824 id: "b".into(),
3825 tier: Some(1),
3826 status: ConformanceStatus::Pass,
3827 ts_time_ms: Some(20),
3828 rust_time_ms: Some(15),
3829 diffs: vec![],
3830 notes: None,
3831 },
3832 ];
3833 let report = generate_report(
3834 "run-pass",
3835 Some("2026-02-07T00:00:00Z".to_string()),
3836 results,
3837 );
3838 assert_eq!(report.summary.total, 2);
3839 assert_eq!(report.summary.passed, 2);
3840 assert!((report.summary.pass_rate - 1.0).abs() < f64::EPSILON);
3841 }
3842
3843 #[test]
3844 fn regression_no_overlap_flags_missing_extension() {
3845 let previous = generate_report(
3848 "prev",
3849 Some("2026-02-05T00:00:00Z".to_string()),
3850 vec![ExtensionConformanceResult {
3851 id: "old-ext".into(),
3852 tier: Some(1),
3853 status: ConformanceStatus::Pass,
3854 ts_time_ms: None,
3855 rust_time_ms: None,
3856 diffs: vec![],
3857 notes: None,
3858 }],
3859 );
3860 let current = generate_report(
3861 "cur",
3862 Some("2026-02-06T00:00:00Z".to_string()),
3863 vec![ExtensionConformanceResult {
3864 id: "new-ext".into(),
3865 tier: Some(1),
3866 status: ConformanceStatus::Fail,
3867 ts_time_ms: None,
3868 rust_time_ms: None,
3869 diffs: vec![],
3870 notes: None,
3871 }],
3872 );
3873 let regression = compute_regression(&previous, ¤t);
3874 assert_eq!(regression.compared_total, 1);
3876 assert!(regression.has_regression());
3878 assert_eq!(regression.regressed_extensions.len(), 1);
3879 assert_eq!(regression.regressed_extensions[0].id, "old-ext");
3880 assert_eq!(regression.regressed_extensions[0].current, None);
3881 }
3882
3883 #[test]
3884 fn regression_all_passing_to_passing() {
3885 let results = vec![ExtensionConformanceResult {
3886 id: "a".into(),
3887 tier: Some(1),
3888 status: ConformanceStatus::Pass,
3889 ts_time_ms: None,
3890 rust_time_ms: None,
3891 diffs: vec![],
3892 notes: None,
3893 }];
3894 let previous = generate_report(
3895 "prev",
3896 Some("2026-02-05T00:00:00Z".to_string()),
3897 results.clone(),
3898 );
3899 let current = generate_report("cur", Some("2026-02-06T00:00:00Z".to_string()), results);
3900 let regression = compute_regression(&previous, ¤t);
3901 assert!(!regression.has_regression());
3902 assert_eq!(regression.compared_total, 1);
3903 assert!((regression.pass_rate_delta).abs() < f64::EPSILON);
3904 }
3905
3906 #[test]
3907 fn conformance_status_as_upper_str() {
3908 assert_eq!(ConformanceStatus::Pass.as_upper_str(), "PASS");
3909 assert_eq!(ConformanceStatus::Fail.as_upper_str(), "FAIL");
3910 assert_eq!(ConformanceStatus::Skip.as_upper_str(), "SKIP");
3911 assert_eq!(ConformanceStatus::Error.as_upper_str(), "ERROR");
3912 }
3913
3914 fn ident_strategy() -> impl Strategy<Value = String> {
3915 string_regex("[a-z0-9_-]{1,16}").expect("valid identifier regex")
3916 }
3917
3918 fn semver_strategy() -> impl Strategy<Value = String> {
3919 (0u8..10, 0u8..20, 0u8..20)
3920 .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
3921 }
3922
3923 fn bounded_json(max_depth: u32) -> BoxedStrategy<Value> {
3924 let leaf = prop_oneof![
3925 Just(Value::Null),
3926 any::<bool>().prop_map(Value::Bool),
3927 any::<i64>().prop_map(|n| Value::Number(n.into())),
3928 string_regex("[A-Za-z0-9 _.-]{0,32}")
3929 .expect("valid scalar string regex")
3930 .prop_map(Value::String),
3931 ];
3932
3933 if max_depth == 0 {
3934 return leaf.boxed();
3935 }
3936
3937 let array_strategy =
3938 prop::collection::vec(bounded_json(max_depth - 1), 0..4).prop_map(Value::Array);
3939 let object_strategy = prop::collection::btree_map(
3940 string_regex("[a-z]{1,8}").expect("valid object key regex"),
3941 bounded_json(max_depth - 1),
3942 0..4,
3943 )
3944 .prop_map(|map| Value::Object(map.into_iter().collect::<Map<String, Value>>()));
3945
3946 prop_oneof![leaf, array_strategy, object_strategy].boxed()
3947 }
3948
3949 fn named_entry_strategy() -> BoxedStrategy<Value> {
3950 (
3951 ident_strategy(),
3952 string_regex("[A-Za-z0-9 _.-]{0,24}").expect("valid description regex"),
3953 )
3954 .prop_map(|(name, description)| json!({ "name": name, "description": description }))
3955 .boxed()
3956 }
3957
3958 fn shortcut_entry_strategy() -> BoxedStrategy<Value> {
3959 (
3960 ident_strategy(),
3961 string_regex("[A-Za-z0-9 _.-]{0,24}").expect("valid shortcut description regex"),
3962 )
3963 .prop_map(
3964 |(key_id, description)| json!({ "key_id": key_id, "description": description }),
3965 )
3966 .boxed()
3967 }
3968
3969 fn model_entry_strategy() -> BoxedStrategy<Value> {
3970 ident_strategy()
3971 .prop_map(|id| json!({ "id": id, "name": format!("model-{id}") }))
3972 .boxed()
3973 }
3974
3975 fn tool_def_entry_strategy() -> BoxedStrategy<Value> {
3976 (
3977 ident_strategy(),
3978 prop::collection::vec(ident_strategy(), 0..6),
3979 bounded_json(1),
3980 )
3981 .prop_map(|(name, required, input)| {
3982 json!({
3983 "name": name,
3984 "parameters": {
3985 "type": "object",
3986 "required": required,
3987 "input": [input]
3988 }
3989 })
3990 })
3991 .boxed()
3992 }
3993
3994 fn hostcall_entry_strategy() -> BoxedStrategy<Value> {
3995 (ident_strategy(), bounded_json(2))
3996 .prop_map(|(op, payload)| json!({ "op": op, "payload": payload }))
3997 .boxed()
3998 }
3999
4000 fn dedup_by_key(items: Vec<Value>, key_field: &str) -> Vec<Value> {
4004 let mut seen = BTreeSet::new();
4005 items
4006 .into_iter()
4007 .filter(|item| {
4008 item.get(key_field)
4009 .and_then(Value::as_str)
4010 .is_none_or(|k| seen.insert(k.to_string()))
4011 })
4012 .collect()
4013 }
4014
4015 fn conformance_output_strategy() -> impl Strategy<Value = Value> {
4016 (
4017 ident_strategy(),
4018 ident_strategy(),
4019 semver_strategy(),
4020 prop::collection::vec(named_entry_strategy(), 0..6),
4021 prop::collection::vec(shortcut_entry_strategy(), 0..6),
4022 prop::collection::vec(named_entry_strategy(), 0..6),
4023 prop::collection::vec(named_entry_strategy(), 0..6),
4024 prop::collection::vec(tool_def_entry_strategy(), 0..6),
4025 prop::collection::vec(model_entry_strategy(), 0..6),
4026 prop::collection::vec(ident_strategy(), 0..6),
4027 prop::collection::vec(hostcall_entry_strategy(), 0..8),
4028 prop::option::of(bounded_json(3)),
4029 )
4030 .prop_map(
4031 |(
4032 extension_id,
4033 name,
4034 version,
4035 commands,
4036 shortcuts,
4037 flags,
4038 providers,
4039 tool_defs,
4040 models,
4041 event_hooks,
4042 hostcall_log,
4043 events,
4044 )| {
4045 let mut out = json!({
4046 "extension_id": extension_id,
4047 "name": name,
4048 "version": version,
4049 "registrations": {
4050 "commands": dedup_by_key(commands, "name"),
4051 "shortcuts": dedup_by_key(shortcuts, "key_id"),
4052 "flags": dedup_by_key(flags, "name"),
4053 "providers": dedup_by_key(providers, "name"),
4054 "tool_defs": dedup_by_key(tool_defs, "name"),
4055 "models": dedup_by_key(models, "id"),
4056 "event_hooks": event_hooks
4057 },
4058 "hostcall_log": hostcall_log
4059 });
4060 if let Some(events) = events {
4061 out.as_object_mut()
4062 .expect("root object")
4063 .insert("events".to_string(), events);
4064 }
4065 out
4066 },
4067 )
4068 }
4069
4070 fn minimal_output_with_events(events: &Value) -> Value {
4071 json!({
4072 "extension_id": "ext",
4073 "name": "Ext",
4074 "version": "1.0.0",
4075 "registrations": {
4076 "commands": [],
4077 "shortcuts": [],
4078 "flags": [],
4079 "providers": [],
4080 "tool_defs": [],
4081 "models": [],
4082 "event_hooks": []
4083 },
4084 "hostcall_log": [],
4085 "events": events
4086 })
4087 }
4088
4089 fn output_with_type_probe(value: &Value) -> Value {
4090 json!({
4091 "extension_id": "ext",
4092 "name": "Ext",
4093 "version": "1.0.0",
4094 "registrations": {
4095 "commands": [],
4096 "shortcuts": [],
4097 "flags": [],
4098 "providers": [],
4099 "tool_defs": [{ "name": "probe", "parameters": { "value": value } }],
4100 "models": [],
4101 "event_hooks": []
4102 },
4103 "hostcall_log": []
4104 })
4105 }
4106
4107 fn deeply_nested_object(depth: usize, leaf: Value) -> Value {
4108 let mut current = leaf;
4109 for idx in 0..depth {
4110 let mut map = Map::new();
4111 map.insert(format!("k{idx}"), current);
4112 current = Value::Object(map);
4113 }
4114 current
4115 }
4116
4117 fn primitive_value_strategy() -> impl Strategy<Value = Value> {
4118 prop_oneof![
4119 Just(Value::Null),
4120 any::<bool>().prop_map(Value::Bool),
4121 any::<i64>().prop_map(|n| Value::Number(n.into())),
4122 string_regex("[A-Za-z0-9 _.-]{0,20}")
4123 .expect("valid primitive string regex")
4124 .prop_map(Value::String),
4125 prop::collection::vec(any::<u8>(), 0..4).prop_map(|bytes| {
4126 Value::Array(
4127 bytes
4128 .into_iter()
4129 .map(|b| Value::Number(u64::from(b).into()))
4130 .collect(),
4131 )
4132 }),
4133 prop::collection::btree_map(
4134 string_regex("[a-z]{1,4}").expect("valid primitive object key regex"),
4135 any::<u8>(),
4136 0..3
4137 )
4138 .prop_map(|entries| {
4139 let mut map = Map::new();
4140 for (key, value) in entries {
4141 map.insert(key, Value::Number(u64::from(value).into()));
4142 }
4143 Value::Object(map)
4144 }),
4145 ]
4146 }
4147
4148 proptest! {
4149 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
4150
4151 #[test]
4152 fn proptest_compare_conformance_output_reflexive(
4153 sample in conformance_output_strategy()
4154 ) {
4155 prop_assert!(
4156 compare_conformance_output(&sample, &sample).is_ok(),
4157 "comparator should be reflexive on valid conformance shape"
4158 );
4159 }
4160
4161 #[test]
4162 fn proptest_compare_conformance_output_symmetry(
4163 expected in conformance_output_strategy(),
4164 actual in conformance_output_strategy()
4165 ) {
4166 let left = compare_conformance_output(&expected, &actual).is_ok();
4167 let right = compare_conformance_output(&actual, &expected).is_ok();
4168 prop_assert_eq!(left, right);
4169 }
4170
4171 #[test]
4172 fn proptest_compare_deep_nesting_depth_200_no_panic(
4173 leaf in bounded_json(1)
4174 ) {
4175 let nested = deeply_nested_object(200, leaf);
4176 let expected = minimal_output_with_events(&nested);
4177 let actual = minimal_output_with_events(&nested);
4178 prop_assert!(compare_conformance_output(&expected, &actual).is_ok());
4179 }
4180
4181 #[test]
4182 fn proptest_compare_large_required_arrays_order_insensitive(
4183 required in prop::collection::btree_set(ident_strategy(), 0..256)
4184 ) {
4185 let required_vec = required.into_iter().collect::<Vec<_>>();
4186 let mut reversed = required_vec.clone();
4187 reversed.reverse();
4188
4189 let expected = json!({
4190 "extension_id": "ext",
4191 "name": "Ext",
4192 "version": "1.0.0",
4193 "registrations": {
4194 "commands": [],
4195 "shortcuts": [],
4196 "flags": [],
4197 "providers": [],
4198 "tool_defs": [{ "name": "t", "parameters": { "required": required_vec } }],
4199 "models": [],
4200 "event_hooks": []
4201 },
4202 "hostcall_log": []
4203 });
4204 let actual = json!({
4205 "extension_id": "ext",
4206 "name": "Ext",
4207 "version": "1.0.0",
4208 "registrations": {
4209 "commands": [],
4210 "shortcuts": [],
4211 "flags": [],
4212 "providers": [],
4213 "tool_defs": [{ "name": "t", "parameters": { "required": reversed } }],
4214 "models": [],
4215 "event_hooks": []
4216 },
4217 "hostcall_log": []
4218 });
4219
4220 prop_assert!(compare_conformance_output(&expected, &actual).is_ok());
4221 }
4222
4223 #[test]
4224 fn proptest_type_confusion_reports_diff(
4225 left in primitive_value_strategy(),
4226 right in primitive_value_strategy()
4227 ) {
4228 prop_assume!(super::json_type_name(&left) != super::json_type_name(&right));
4229 let expected = output_with_type_probe(&left);
4230 let actual = output_with_type_probe(&right);
4231 prop_assert!(compare_conformance_output(&expected, &actual).is_err());
4232 }
4233 }
4234}