Skip to main content

pi/
extension_inclusion.rs

1//! Final inclusion list generation for Pi extension candidates.
2//!
3//! Merges scoring tiers, candidate pool provenance, license verdicts,
4//! and validation evidence into an authoritative inclusion list with
5//! version pins. This output is the contract for acquisition and
6//! conformance work.
7
8use serde::{Deserialize, Serialize};
9use sha2::Digest as _;
10use std::borrow::Cow;
11use std::collections::BTreeMap;
12use std::collections::HashMap;
13
14// ────────────────────────────────────────────────────────────────────────────
15// Types
16// ────────────────────────────────────────────────────────────────────────────
17
18/// Version pin strategy.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum VersionPin {
22    /// npm package with exact version.
23    Npm {
24        package: String,
25        version: String,
26        registry_url: String,
27    },
28    /// Git repository with path (commit hash if available).
29    Git {
30        repo: String,
31        path: Option<String>,
32        commit: Option<String>,
33    },
34    /// Direct URL.
35    Url { url: String },
36    /// Checksum-only pin (no upstream reference available).
37    Checksum,
38}
39
40/// Extension category based on registration types.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum ExtensionCategory {
44    /// Registers a custom tool.
45    Tool,
46    /// Registers a slash command.
47    Command,
48    /// Registers a custom provider.
49    Provider,
50    /// Registers event hooks.
51    #[serde(alias = "event-hook")]
52    EventHook,
53    /// Registers UI components (message renderer).
54    #[serde(alias = "ui")]
55    UiComponent,
56    /// Registers flags or shortcuts.
57    #[serde(alias = "shortcut", alias = "flag")]
58    Configuration,
59    /// Multiple registration types.
60    Multi,
61    /// No specific registrations detected.
62    #[serde(alias = "basic", alias = "exec", alias = "session", alias = "unknown")]
63    General,
64}
65
66/// A single entry in the final inclusion list.
67///
68/// Supports both the v1 format (from `ext_inclusion_list` binary) and the
69/// v2 format (from `ext_inclusion_list` test generator).  Non-shared fields
70/// are optional with serde defaults.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct InclusionEntry {
73    pub id: String,
74    #[serde(default)]
75    pub name: Option<String>,
76    #[serde(default)]
77    pub tier: Option<String>,
78    #[serde(default)]
79    pub score: Option<f64>,
80    pub category: ExtensionCategory,
81    // v1 fields
82    #[serde(default)]
83    pub registrations: Vec<String>,
84    #[serde(default)]
85    pub version_pin: Option<VersionPin>,
86    #[serde(default)]
87    pub sha256: Option<String>,
88    #[serde(default)]
89    pub artifact_path: Option<String>,
90    #[serde(default)]
91    pub license: Option<String>,
92    #[serde(default)]
93    pub source_tier: Option<String>,
94    #[serde(default)]
95    pub rationale: Option<String>,
96    // v2 fields
97    #[serde(default)]
98    pub directory: Option<String>,
99    #[serde(default)]
100    pub provenance: Option<serde_json::Value>,
101    #[serde(default)]
102    pub capabilities: Option<Vec<String>>,
103    #[serde(default)]
104    pub risk_level: Option<String>,
105    #[serde(default)]
106    pub inclusion_rationale: Option<String>,
107}
108
109/// Exclusion note for high-scoring items not selected.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ExclusionNote {
112    pub id: String,
113    pub score: f64,
114    pub reason: String,
115}
116
117/// The final inclusion list document.
118///
119/// Supports both v1 format (binary output: task, stats, tier0, exclusions,
120/// `category_coverage`) and v2 format (test output: summary, `tier1_review`,
121/// coverage, `exclusion_notes`).
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct InclusionList {
124    pub schema: String,
125    pub generated_at: String,
126    // v1 fields
127    #[serde(default)]
128    pub task: Option<String>,
129    #[serde(default)]
130    pub stats: Option<InclusionStats>,
131    #[serde(default)]
132    pub tier0: Vec<InclusionEntry>,
133    #[serde(default)]
134    pub tier1: Vec<InclusionEntry>,
135    #[serde(default)]
136    pub tier2: Vec<InclusionEntry>,
137    #[serde(default)]
138    pub exclusions: Vec<ExclusionNote>,
139    #[serde(default)]
140    pub category_coverage: HashMap<String, usize>,
141    // v2 fields
142    #[serde(default)]
143    pub summary: Option<serde_json::Value>,
144    #[serde(default)]
145    pub tier1_review: Vec<InclusionEntry>,
146    #[serde(default)]
147    pub coverage: Option<serde_json::Value>,
148    #[serde(default)]
149    pub exclusion_notes: Vec<ExclusionNote>,
150}
151
152/// Aggregate stats.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct InclusionStats {
155    pub total_included: usize,
156    pub tier0_count: usize,
157    pub tier1_count: usize,
158    pub tier2_count: usize,
159    pub excluded_count: usize,
160    pub pinned_npm: usize,
161    pub pinned_git: usize,
162    pub pinned_url: usize,
163    pub pinned_checksum_only: usize,
164}
165
166// ────────────────────────────────────────────────────────────────────────────
167// Classification
168// ────────────────────────────────────────────────────────────────────────────
169
170/// Classify an extension by its registration types.
171#[must_use]
172pub fn classify_registrations(registrations: &[String]) -> ExtensionCategory {
173    let has_tool = registrations.iter().any(|r| r == "registerTool");
174    let has_cmd = registrations
175        .iter()
176        .any(|r| r == "registerCommand" || r == "registerSlashCommand");
177    let has_provider = registrations.iter().any(|r| r == "registerProvider");
178    let has_event = registrations
179        .iter()
180        .any(|r| r == "registerEvent" || r == "registerEventHook");
181    let has_ui = registrations.iter().any(|r| r == "registerMessageRenderer");
182    let has_configuration = registrations
183        .iter()
184        .any(|r| r == "registerFlag" || r == "registerShortcut");
185
186    let distinct = [
187        has_tool,
188        has_cmd,
189        has_provider,
190        has_event,
191        has_ui,
192        has_configuration,
193    ]
194    .iter()
195    .filter(|&&x| x)
196    .count();
197
198    if distinct > 1 {
199        return ExtensionCategory::Multi;
200    }
201
202    if has_tool {
203        ExtensionCategory::Tool
204    } else if has_cmd {
205        ExtensionCategory::Command
206    } else if has_provider {
207        ExtensionCategory::Provider
208    } else if has_event {
209        ExtensionCategory::EventHook
210    } else if has_ui {
211        ExtensionCategory::UiComponent
212    } else if has_configuration {
213        ExtensionCategory::Configuration
214    } else {
215        ExtensionCategory::General
216    }
217}
218
219/// Build inclusion rationale from tier, score, and registrations.
220#[must_use]
221pub fn build_rationale(
222    tier: &str,
223    score: f64,
224    category: &ExtensionCategory,
225    source_tier: &str,
226) -> String {
227    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
228    let score_u = score as u32;
229    let tier_reason: Cow<'_, str> = match tier {
230        "tier-0" => Cow::Borrowed("Official pi-mono baseline; must-pass conformance target"),
231        "tier-1" => Cow::Owned(format!("High score ({score_u}/100); passes all gates")),
232        "tier-2" => Cow::Owned(format!(
233            "Moderate score ({score_u}/100); stretch conformance target"
234        )),
235        _ => Cow::Borrowed("Excluded"),
236    };
237
238    let cat_reason = match category {
239        ExtensionCategory::Tool => "Covers tool registration path",
240        ExtensionCategory::Command => "Covers command/slash-command registration",
241        ExtensionCategory::Provider => "Covers custom provider registration",
242        ExtensionCategory::EventHook => "Covers event hook lifecycle",
243        ExtensionCategory::UiComponent => "Covers UI component rendering",
244        ExtensionCategory::Configuration => "Covers flag/shortcut configuration",
245        ExtensionCategory::Multi => "Multi-type: covers multiple registration paths",
246        ExtensionCategory::General => "General extension (export default)",
247    };
248
249    let source_reason = match source_tier {
250        "official-pi-mono" => "official",
251        "community" | "agents-mikeastock" => "community",
252        "npm-registry" | "npm-registry-pi" => "npm",
253        _ => source_tier,
254    };
255
256    format!("{tier_reason}. {cat_reason}. Source: {source_reason}.")
257}
258
259/// Recursively canonicalize a JSON value by sorting all object keys.
260///
261/// This guarantees stable serialization across platforms and parser insertion
262/// order differences, which is required for deterministic manifest hashing.
263#[must_use]
264pub fn canonicalize_json_value(value: &serde_json::Value) -> serde_json::Value {
265    match value {
266        serde_json::Value::Object(map) => {
267            let sorted = map
268                .iter()
269                .map(|(k, v)| (k.clone(), canonicalize_json_value(v)))
270                .collect::<BTreeMap<_, _>>();
271
272            let mut out = serde_json::Map::with_capacity(sorted.len());
273            for (k, v) in sorted {
274                out.insert(k, v);
275            }
276            serde_json::Value::Object(out)
277        }
278        serde_json::Value::Array(items) => {
279            serde_json::Value::Array(items.iter().map(canonicalize_json_value).collect())
280        }
281        _ => value.clone(),
282    }
283}
284
285/// Normalize inclusion-list JSON for stable comparisons and hashing.
286///
287/// The top-level `generated_at` field is intentionally removed so hashes only
288/// change when meaningful manifest content changes.
289#[must_use]
290pub fn normalize_manifest_value(value: &serde_json::Value) -> serde_json::Value {
291    let mut normalized = canonicalize_json_value(value);
292    if let Some(obj) = normalized.as_object_mut() {
293        obj.remove("generated_at");
294    }
295    normalized
296}
297
298/// Compute a stable SHA-256 hash for an inclusion-list JSON string.
299///
300/// Parsing + canonicalization ensures the hash is independent of object key
301/// ordering and line ending differences.
302pub fn normalized_manifest_hash(json: &str) -> Result<String, serde_json::Error> {
303    let value: serde_json::Value = serde_json::from_str(json)?;
304    normalized_manifest_hash_from_value(&value)
305}
306
307/// Compute a stable SHA-256 hash from a parsed inclusion-list JSON value.
308pub fn normalized_manifest_hash_from_value(
309    value: &serde_json::Value,
310) -> Result<String, serde_json::Error> {
311    let normalized = normalize_manifest_value(value);
312    let bytes = serde_json::to_vec(&normalized)?;
313    let mut hasher = sha2::Sha256::new();
314    hasher.update(&bytes);
315    Ok(format!("{:x}", hasher.finalize()))
316}
317
318// ────────────────────────────────────────────────────────────────────────────
319// Tests
320// ────────────────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn classify_single_tool() {
328        assert_eq!(
329            classify_registrations(&["registerTool".into()]),
330            ExtensionCategory::Tool
331        );
332    }
333
334    #[test]
335    fn classify_single_command() {
336        assert_eq!(
337            classify_registrations(&["registerCommand".into()]),
338            ExtensionCategory::Command
339        );
340    }
341
342    #[test]
343    fn classify_provider() {
344        assert_eq!(
345            classify_registrations(&["registerProvider".into()]),
346            ExtensionCategory::Provider
347        );
348    }
349
350    #[test]
351    fn classify_multi() {
352        assert_eq!(
353            classify_registrations(&["registerTool".into(), "registerCommand".into()]),
354            ExtensionCategory::Multi
355        );
356    }
357
358    #[test]
359    fn classify_empty() {
360        assert_eq!(classify_registrations(&[]), ExtensionCategory::General);
361    }
362
363    #[test]
364    fn classify_flag_is_configuration() {
365        assert_eq!(
366            classify_registrations(&["registerFlag".into()]),
367            ExtensionCategory::Configuration
368        );
369    }
370
371    #[test]
372    fn classify_event() {
373        assert_eq!(
374            classify_registrations(&["registerEventHook".into()]),
375            ExtensionCategory::EventHook
376        );
377    }
378
379    #[test]
380    fn classify_renderer() {
381        assert_eq!(
382            classify_registrations(&["registerMessageRenderer".into()]),
383            ExtensionCategory::UiComponent
384        );
385    }
386
387    #[test]
388    fn classify_unknown_then_known_prefers_known_category() {
389        assert_eq!(
390            classify_registrations(&["registerUnknown".into(), "registerProvider".into()]),
391            ExtensionCategory::Provider
392        );
393    }
394
395    #[test]
396    fn classify_configuration_plus_tool_is_multi() {
397        assert_eq!(
398            classify_registrations(&["registerFlag".into(), "registerTool".into()]),
399            ExtensionCategory::Multi
400        );
401    }
402
403    #[test]
404    fn rationale_tier0() {
405        let r = build_rationale("tier-0", 60.0, &ExtensionCategory::Tool, "official-pi-mono");
406        assert!(r.contains("Official pi-mono baseline"));
407        assert!(r.contains("tool registration"));
408        assert!(r.contains("official"));
409    }
410
411    #[test]
412    fn rationale_tier2() {
413        let r = build_rationale("tier-2", 52.0, &ExtensionCategory::Provider, "community");
414        assert!(r.contains("52/100"));
415        assert!(r.contains("custom provider"));
416    }
417
418    #[test]
419    fn rationale_tier1_includes_score_without_leak_pattern() {
420        let r = build_rationale("tier-1", 87.0, &ExtensionCategory::Tool, "community");
421        assert!(r.contains("87/100"));
422        assert!(r.contains("passes all gates"));
423    }
424
425    #[test]
426    fn inclusion_entry_serde_round_trip() {
427        let entry = InclusionEntry {
428            id: "test/ext".into(),
429            name: Some("Test Extension".into()),
430            tier: Some("tier-0".into()),
431            score: Some(60.0),
432            category: ExtensionCategory::Tool,
433            registrations: vec!["registerTool".into()],
434            version_pin: Some(VersionPin::Git {
435                repo: "https://github.com/test/ext".into(),
436                path: Some("extensions/test".into()),
437                commit: None,
438            }),
439            sha256: Some("abc123".into()),
440            artifact_path: Some("tests/ext_conformance/artifacts/test".into()),
441            license: Some("MIT".into()),
442            source_tier: Some("official-pi-mono".into()),
443            rationale: Some("Official baseline".into()),
444            directory: None,
445            provenance: None,
446            capabilities: None,
447            risk_level: None,
448            inclusion_rationale: None,
449        };
450        let json = serde_json::to_string(&entry).unwrap();
451        let back: InclusionEntry = serde_json::from_str(&json).unwrap();
452        assert_eq!(back.id, "test/ext");
453        assert_eq!(back.category, ExtensionCategory::Tool);
454    }
455
456    #[test]
457    fn npm_version_pin_serde() {
458        let pin = VersionPin::Npm {
459            package: "@oh-my-pi/test".into(),
460            version: "1.0.0".into(),
461            registry_url: "https://registry.npmjs.org".into(),
462        };
463        let json = serde_json::to_string(&pin).unwrap();
464        assert!(json.contains("npm"));
465        assert!(json.contains("1.0.0"));
466    }
467
468    #[test]
469    fn inclusion_list_serde() {
470        let list = InclusionList {
471            schema: "pi.ext.inclusion.v1".into(),
472            generated_at: "2026-01-01T00:00:00Z".into(),
473            task: Some("test".into()),
474            stats: Some(InclusionStats {
475                total_included: 0,
476                tier0_count: 0,
477                tier1_count: 0,
478                tier2_count: 0,
479                excluded_count: 0,
480                pinned_npm: 0,
481                pinned_git: 0,
482                pinned_url: 0,
483                pinned_checksum_only: 0,
484            }),
485            tier0: vec![],
486            tier1: vec![],
487            tier2: vec![],
488            exclusions: vec![],
489            category_coverage: HashMap::new(),
490            summary: None,
491            tier1_review: vec![],
492            coverage: None,
493            exclusion_notes: vec![],
494        };
495        let json = serde_json::to_string(&list).unwrap();
496        let back: InclusionList = serde_json::from_str(&json).unwrap();
497        assert_eq!(back.schema, "pi.ext.inclusion.v1");
498    }
499
500    #[test]
501    fn normalized_manifest_hash_ignores_generated_at_and_key_order() {
502        let first = serde_json::json!({
503            "schema": "pi.ext.inclusion_list.v1",
504            "generated_at": "2026-02-10T00:00:00Z",
505            "summary": {
506                "tier1_count": 2,
507                "tier2_count": 1
508            },
509            "tier1": [{"id": "a"}, {"id": "b"}]
510        });
511
512        let second = serde_json::json!({
513            "tier1": [{"id": "a"}, {"id": "b"}],
514            "summary": {
515                "tier2_count": 1,
516                "tier1_count": 2
517            },
518            "generated_at": "2030-01-01T12:34:56Z",
519            "schema": "pi.ext.inclusion_list.v1"
520        });
521
522        let first_hash = normalized_manifest_hash_from_value(&first).unwrap();
523        let second_hash = normalized_manifest_hash_from_value(&second).unwrap();
524        assert_eq!(first_hash, second_hash);
525    }
526
527    #[test]
528    fn normalized_manifest_hash_detects_content_changes() {
529        let baseline = serde_json::json!({
530            "schema": "pi.ext.inclusion_list.v1",
531            "generated_at": "2026-02-10T00:00:00Z",
532            "summary": { "tier1_count": 2 }
533        });
534        let changed = serde_json::json!({
535            "schema": "pi.ext.inclusion_list.v1",
536            "generated_at": "2026-02-10T00:00:00Z",
537            "summary": { "tier1_count": 3 }
538        });
539
540        let baseline_hash = normalized_manifest_hash_from_value(&baseline).unwrap();
541        let changed_hash = normalized_manifest_hash_from_value(&changed).unwrap();
542        assert_ne!(baseline_hash, changed_hash);
543    }
544
545    mod proptest_extension_inclusion {
546        use super::*;
547        use proptest::prelude::*;
548
549        /// Known registration type strings.
550        const REG_TYPES: &[&str] = &[
551            "registerTool",
552            "registerCommand",
553            "registerSlashCommand",
554            "registerProvider",
555            "registerEvent",
556            "registerEventHook",
557            "registerMessageRenderer",
558            "registerFlag",
559            "registerShortcut",
560        ];
561
562        proptest! {
563            /// `classify_registrations` never panics on arbitrary strings.
564            #[test]
565            fn classify_never_panics(
566                n in 0..10usize,
567                seed in prop::collection::vec("[a-zA-Z]{1,20}", 0..10)
568            ) {
569                let _ = classify_registrations(&seed[..n.min(seed.len())]);
570            }
571
572            /// Empty registrations always return General.
573            #[test]
574            fn empty_registrations_is_general(_dummy in 0..1u8) {
575                assert_eq!(classify_registrations(&[]), ExtensionCategory::General);
576            }
577
578            /// Single known registration type returns its specific category.
579            #[test]
580            fn single_registration_specific(idx in 0..REG_TYPES.len()) {
581                let regs = vec![REG_TYPES[idx].to_string()];
582                let cat = classify_registrations(&regs);
583                assert_ne!(cat, ExtensionCategory::Multi);
584                assert_ne!(cat, ExtensionCategory::General);
585            }
586
587            /// Two distinct registration categories return Multi.
588            #[test]
589            fn two_distinct_returns_multi(
590                idx_a in 0..1usize,   // tool
591                idx_b in 3..4usize    // provider
592            ) {
593                let regs = vec![
594                    REG_TYPES[idx_a].to_string(),
595                    REG_TYPES[idx_b].to_string(),
596                ];
597                assert_eq!(classify_registrations(&regs), ExtensionCategory::Multi);
598            }
599
600            /// Unknown registration strings return General.
601            #[test]
602            fn unknown_registrations_general(s in "[a-z]{5,15}") {
603                // Avoid accidentally matching known types
604                if !REG_TYPES.contains(&s.as_str()) {
605                    assert_eq!(
606                        classify_registrations(&[s]),
607                        ExtensionCategory::General
608                    );
609                }
610            }
611
612            /// Duplicate registrations don't change the result.
613            #[test]
614            fn duplicates_idempotent(idx in 0..REG_TYPES.len()) {
615                let single = vec![REG_TYPES[idx].to_string()];
616                let doubled = vec![REG_TYPES[idx].to_string(), REG_TYPES[idx].to_string()];
617                assert_eq!(
618                    classify_registrations(&single),
619                    classify_registrations(&doubled)
620                );
621            }
622
623            /// `ExtensionCategory` serde roundtrip.
624            #[test]
625            fn category_serde_roundtrip(idx in 0..8usize) {
626                let cats = [
627                    ExtensionCategory::Tool,
628                    ExtensionCategory::Command,
629                    ExtensionCategory::Provider,
630                    ExtensionCategory::EventHook,
631                    ExtensionCategory::UiComponent,
632                    ExtensionCategory::Configuration,
633                    ExtensionCategory::Multi,
634                    ExtensionCategory::General,
635                ];
636                let cat = &cats[idx];
637                let json = serde_json::to_string(cat).unwrap();
638                let back: ExtensionCategory = serde_json::from_str(&json).unwrap();
639                assert_eq!(*cat, back);
640            }
641
642            /// `build_rationale` never panics and produces non-empty output.
643            #[test]
644            fn rationale_never_panics(
645                tier_idx in 0..4usize,
646                score in 0.0f64..100.0,
647                cat_idx in 0..8usize,
648                source in "[a-z-]{1,20}"
649            ) {
650                let tiers = ["tier-0", "tier-1", "tier-2", "unknown"];
651                let cats = [
652                    ExtensionCategory::Tool,
653                    ExtensionCategory::Command,
654                    ExtensionCategory::Provider,
655                    ExtensionCategory::EventHook,
656                    ExtensionCategory::UiComponent,
657                    ExtensionCategory::Configuration,
658                    ExtensionCategory::Multi,
659                    ExtensionCategory::General,
660                ];
661                let result = build_rationale(tiers[tier_idx], score, &cats[cat_idx], &source);
662                assert!(!result.is_empty());
663                assert!(result.ends_with('.'));
664            }
665
666            /// `canonicalize_json_value` is idempotent.
667            #[test]
668            fn canonicalize_idempotent(
669                key1 in "[a-z]{1,5}",
670                key2 in "[a-z]{1,5}",
671                val1 in 0i64..100,
672                val2 in 0i64..100
673            ) {
674                let obj = serde_json::json!({ &key2: val2, &key1: val1 });
675                let once = canonicalize_json_value(&obj);
676                let twice = canonicalize_json_value(&once);
677                assert_eq!(once, twice);
678            }
679
680            /// `canonicalize_json_value` sorts object keys.
681            #[test]
682            fn canonicalize_sorts_keys(
683                key1 in "[a-z]{1,5}",
684                key2 in "[a-z]{1,5}"
685            ) {
686                let obj = serde_json::json!({ &key2: 1, &key1: 2 });
687                let canonical = canonicalize_json_value(&obj);
688                let keys: Vec<&String> = canonical.as_object().unwrap().keys().collect();
689                for w in keys.windows(2) {
690                    assert!(w[0] <= w[1], "keys not sorted: {keys:?}");
691                }
692            }
693
694            /// Primitives pass through `canonicalize_json_value` unchanged.
695            #[test]
696            fn canonicalize_preserves_primitives(n in -1000i64..1000) {
697                let val = serde_json::Value::from(n);
698                assert_eq!(canonicalize_json_value(&val), val);
699            }
700
701            /// `normalize_manifest_value` removes `generated_at`.
702            #[test]
703            fn normalize_removes_generated_at(ts in "[0-9]{4}-[0-9]{2}-[0-9]{2}") {
704                let obj = serde_json::json!({
705                    "schema": "test",
706                    "generated_at": ts,
707                    "data": 42
708                });
709                let norm = normalize_manifest_value(&obj);
710                assert!(norm.get("generated_at").is_none());
711                assert!(norm.get("data").is_some());
712            }
713
714            /// `normalized_manifest_hash` produces 64-char hex string.
715            #[test]
716            fn hash_is_64_hex(key in "[a-z]{1,10}", val in 0i64..1000) {
717                let json = serde_json::json!({ &key: val }).to_string();
718                let hash = normalized_manifest_hash(&json).unwrap();
719                assert_eq!(hash.len(), 64);
720                assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
721            }
722
723            /// Hash is deterministic — same input always produces same hash.
724            #[test]
725            fn hash_deterministic(key in "[a-z]{1,5}", val in 0i64..100) {
726                let json = serde_json::json!({ &key: val }).to_string();
727                let h1 = normalized_manifest_hash(&json).unwrap();
728                let h2 = normalized_manifest_hash(&json).unwrap();
729                assert_eq!(h1, h2);
730            }
731
732            /// Hash ignores key order (canonicalized).
733            #[test]
734            fn hash_ignores_key_order(
735                k1 in "[a-m]{1,3}",
736                k2 in "[n-z]{1,3}"
737            ) {
738                let a = format!(r#"{{"{k1}":1,"{k2}":2}}"#);
739                let b = format!(r#"{{"{k2}":2,"{k1}":1}}"#);
740                assert_eq!(
741                    normalized_manifest_hash(&a).unwrap(),
742                    normalized_manifest_hash(&b).unwrap()
743                );
744            }
745
746            /// Hash ignores `generated_at` field differences.
747            #[test]
748            fn hash_ignores_generated_at(ts1 in "[0-9]{10}", ts2 in "[0-9]{10}") {
749                let a = serde_json::json!({"generated_at": ts1, "x": 1});
750                let b = serde_json::json!({"generated_at": ts2, "x": 1});
751                assert_eq!(
752                    normalized_manifest_hash_from_value(&a).unwrap(),
753                    normalized_manifest_hash_from_value(&b).unwrap()
754                );
755            }
756
757            /// Invalid JSON returns Err from `normalized_manifest_hash`.
758            #[test]
759            fn hash_invalid_json_errs(s in "[a-z]{5,20}") {
760                assert!(normalized_manifest_hash(&s).is_err());
761            }
762        }
763    }
764}