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