Skip to main content

eggress_testkit/
strict_manifest.rs

1//! Strict manifest validation for the pproxy 2.7.9 behavioral compatibility manifest.
2//!
3//! Validates `docs/parity/pproxy_2_7_9_strict_manifest.toml`.
4
5use std::collections::HashSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde::Deserialize;
10use thiserror::Error;
11
12// ---------------------------------------------------------------------------
13// Constants
14// ---------------------------------------------------------------------------
15
16pub const ALLOWED_CATEGORIES: &[&str] = &[
17    "python_namespace",
18    "cli_option",
19    "protocol",
20    "cipher",
21    "composition",
22    "process",
23    "failure",
24];
25
26pub const ALLOWED_STATUSES: &[&str] = &[
27    "gap",
28    "drop_in",
29    "structural",
30    "known_upstream_defect",
31    "platform_constraint",
32    "not_applicable",
33    "intentional_non_parity",
34];
35
36pub const ALLOWED_COMPARATORS: &[&str] = &[
37    "async_callable_signature",
38    "module_existence",
39    "constant_value",
40    "enum_membership",
41    "method_signature",
42    "property_existence",
43    "class_hierarchy",
44    "cli_flag_parse",
45    "cli_flag_rejection",
46    "protocol_wire",
47    "cipher_roundtrip",
48    "cipher_kat",
49    "process_lifecycle",
50    "failure_class",
51    "composition_validity",
52    "composition_rejection",
53];
54
55pub const ALLOWED_OWNERS: &[&str] = &["track-a", "track-b", "track-c"];
56
57pub const ALLOWED_MILESTONES: &[&str] = &["A", "B", "C", "D", "E", "F"];
58
59pub const ALLOWED_EVIDENCE_LEVELS: &[&str] = &[
60    "paired_oracle",
61    "bidirectional_interop",
62    "oracle_only_baseline",
63    "candidate_only",
64    "structural_only",
65    "none",
66];
67
68pub const ALLOWED_IMPLEMENTATION_STATES: &[&str] =
69    &["functional", "structural", "partial", "absent"];
70
71pub const ALLOWED_CERTIFICATION_SCOPES: &[&str] =
72    &["structural", "behavioral", "interop", "process", "platform"];
73
74/// Milestone order for "current milestone" checking.
75const MILESTONE_ORDER: &[&str] = &["A", "B", "C", "D", "E", "F"];
76
77/// Current release milestone — records at or below this milestone
78/// with non-terminal status are flagged. Set to "C" for the A–C
79/// corrective closure pass.
80const CURRENT_MILESTONE: &str = "C";
81
82/// Terminal statuses that do not represent unresolved progress.
83const TERMINAL_STATUSES: &[&str] = &[
84    "drop_in",
85    "not_applicable",
86    "known_upstream_defect",
87    "platform_constraint",
88    "intentional_non_parity",
89    "structural",
90];
91
92// ---------------------------------------------------------------------------
93// Data model
94// ---------------------------------------------------------------------------
95
96/// Top-level metadata section of the strict manifest.
97#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
98pub struct StrictManifestMeta {
99    pub manifest_version: String,
100    pub pproxy_version: String,
101    pub schema: String,
102    #[serde(default)]
103    pub policy_ref: String,
104    #[serde(default)]
105    pub oracle_ref: String,
106    #[serde(default)]
107    pub closure_through: String,
108}
109
110/// A single record entry in the strict manifest.
111#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
112pub struct StrictRecord {
113    pub id: String,
114    pub category: String,
115    #[serde(default)]
116    pub kind: String,
117    #[serde(default)]
118    pub module: String,
119    #[serde(default)]
120    pub name: String,
121    #[serde(default)]
122    pub oracle_probe: String,
123    #[serde(default)]
124    pub candidate_probe: String,
125    #[serde(default)]
126    pub comparator: String,
127    pub status: String,
128    #[serde(default)]
129    pub owner: String,
130    #[serde(default)]
131    pub milestone: String,
132    #[serde(default)]
133    pub platforms: Vec<String>,
134    #[serde(default)]
135    pub python_versions: Vec<String>,
136    #[serde(default)]
137    pub depends_on: Vec<String>,
138    #[serde(default)]
139    pub test_refs: Vec<String>,
140    #[serde(default)]
141    pub evidence_refs: Vec<String>,
142    #[serde(default)]
143    pub notes: String,
144    #[serde(default)]
145    pub evidence_level: String,
146    #[serde(default)]
147    pub implementation_state: String,
148    #[serde(default = "default_certification_scope")]
149    pub certification_scope: String,
150    #[serde(default = "default_closure_required")]
151    pub closure_required: bool,
152    #[serde(default)]
153    pub behavior_record: Option<String>,
154    #[serde(default)]
155    pub inventory_only: bool,
156}
157
158/// The complete strict manifest structure.
159#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
160pub struct StrictManifest {
161    pub meta: StrictManifestMeta,
162    pub record: Vec<StrictRecord>,
163}
164
165// ---------------------------------------------------------------------------
166// Errors
167// ---------------------------------------------------------------------------
168
169/// A single validation error with rule number and context.
170#[derive(Debug, Clone, Error, PartialEq, Eq)]
171pub enum StrictValidationError {
172    #[error("TOML parse error: {message}")]
173    TomlParse { message: String },
174
175    #[error("file I/O error: {message}")]
176    Io { message: String },
177
178    // Rule 1: Unknown enum values
179    #[error("unknown category \"{value}\" (valid: {allowed:?})")]
180    UnknownCategory { value: String, allowed: Vec<String> },
181
182    #[error("unknown status \"{value}\" (valid: {allowed:?})")]
183    UnknownStatus { value: String, allowed: Vec<String> },
184
185    #[error("unknown comparator \"{value}\" (valid: {allowed:?})")]
186    UnknownComparator { value: String, allowed: Vec<String> },
187
188    #[error("unknown owner \"{value}\" (valid: {allowed:?})")]
189    UnknownOwner { value: String, allowed: Vec<String> },
190
191    #[error("unknown milestone \"{value}\" (valid: {allowed:?})")]
192    UnknownMilestone { value: String, allowed: Vec<String> },
193
194    // Rule 2: Duplicate IDs
195    #[error("duplicate record id: \"{id}\"")]
196    DuplicateId { id: String },
197
198    // Rule 3: Empty ID
199    #[error("record has empty id")]
200    EmptyId,
201
202    // Rule 4: drop_in without evidence or tests
203    #[error("drop_in record \"{id}\" has empty evidence_refs and test_refs")]
204    DropInWithoutEvidence { id: String },
205
206    // Rule 5: drop_in without oracle_probe
207    #[error("drop_in record \"{id}\" has empty oracle_probe")]
208    DropInWithoutOracleProbe { id: String },
209
210    // Rule 6: Unresolved progress state at current milestone
211    #[error(
212        "record \"{id}\" has non-terminal status \"{status}\" at milestone \"{milestone}\" \
213         (at or below current milestone {current})"
214    )]
215    UnresolvedProgress {
216        id: String,
217        status: String,
218        milestone: String,
219        current: String,
220    },
221
222    // Rule 7: drop_in requires paired_oracle or bidirectional_interop evidence
223    #[error(
224        "drop_in record \"{id}\" has evidence_level \"{evidence_level}\" \
225         (must be paired_oracle or bidirectional_interop)"
226    )]
227    DropInRequiresStrongEvidence { id: String, evidence_level: String },
228
229    // Rule 8: structural-only comparator + drop_in requires non-structural evidence
230    #[error(
231        "drop_in record \"{id}\" with comparator \"{comparator}\" has \
232         evidence_level \"{evidence_level}\" (must not be structural_only or none)"
233    )]
234    StructuralComparatorDropInRequiresEvidence {
235        id: String,
236        comparator: String,
237        evidence_level: String,
238    },
239
240    // Rule 9: structural_only evidence cannot coexist with drop_in
241    #[error(
242        "record \"{id}\" has evidence_level \"structural_only\" but status \"drop_in\" \
243         (structural_only evidence is incompatible with drop_in)"
244    )]
245    StructuralOnlyIncompatibleWithDropIn { id: String },
246
247    // Rule 10a: drop_in + structural comparator for behavioral certification_scope
248    #[error(
249        "record \"{id}\" has status \"drop_in\" with structural comparator \"{comparator}\" \
250         but certification_scope is \"behavioral\" (structural evidence cannot certify behavior)"
251    )]
252    StructuralComparatorBehavioralScopeMismatch { id: String, comparator: String },
253
254    // Rule 10b: closure_required with empty evidence_refs
255    #[error(
256        "record \"{id}\" has closure_required = true but empty evidence_refs \
257         (closure-required records must have evidence)"
258    )]
259    ClosureRequiredWithoutEvidence { id: String },
260
261    // Rule 10c: missing behavior_record for public structural record
262    #[error(
263        "record \"{id}\" has certification_scope = \"structural\" but missing behavior_record \
264         (structural records must reference a behavior record)"
265    )]
266    StructuralMissingBehaviorRecord { id: String },
267
268    // New enum validation errors
269    #[error("unknown certification_scope \"{value}\" (valid: {allowed:?})")]
270    UnknownCertificationScope { value: String, allowed: Vec<String> },
271    #[error("unknown evidence_level \"{value}\" (valid: {allowed:?})")]
272    UnknownEvidenceLevel { value: String, allowed: Vec<String> },
273
274    #[error("unknown implementation_state \"{value}\" (valid: {allowed:?})")]
275    UnknownImplementationState { value: String, allowed: Vec<String> },
276}
277
278/// A collection of validation errors.
279#[derive(Debug, Clone, Error, PartialEq, Eq)]
280#[error("{errors:#?}")]
281pub struct StrictValidationErrors {
282    pub errors: Vec<StrictValidationError>,
283}
284
285impl StrictValidationErrors {
286    pub fn new() -> Self {
287        Self { errors: Vec::new() }
288    }
289
290    pub fn push(&mut self, err: StrictValidationError) {
291        self.errors.push(err);
292    }
293
294    pub fn is_empty(&self) -> bool {
295        self.errors.is_empty()
296    }
297
298    pub fn len(&self) -> usize {
299        self.errors.len()
300    }
301}
302
303impl Default for StrictValidationErrors {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Helpers
311// ---------------------------------------------------------------------------
312
313fn default_certification_scope() -> String {
314    "behavioral".to_string()
315}
316
317fn default_closure_required() -> bool {
318    true
319}
320
321fn milestone_index(milestone: &str) -> Option<usize> {
322    MILESTONE_ORDER.iter().position(|&m| m == milestone)
323}
324
325/// Locate the strict manifest file relative to CARGO_MANIFEST_DIR.
326pub fn find_strict_manifest_path() -> Option<PathBuf> {
327    if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
328        let candidate = PathBuf::from(&manifest_dir)
329            .join("../../docs/parity/pproxy_2_7_9_strict_manifest.toml");
330        if candidate.exists() {
331            return Some(candidate);
332        }
333    }
334
335    let cwd = std::env::current_dir().ok()?;
336    let mut dir = cwd.as_path();
337    loop {
338        let candidate = dir.join("docs/parity/pproxy_2_7_9_strict_manifest.toml");
339        if candidate.exists() {
340            return Some(candidate);
341        }
342        dir = dir.parent()?;
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Oracle Provenance Verification
348// ---------------------------------------------------------------------------
349
350/// Oracle hashes as loaded from `compat/pproxy-2.7.9/hashes.toml`.
351#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
352pub struct OracleHashes {
353    pub package: OracleHashPackage,
354}
355
356#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
357pub struct OracleHashPackage {
358    pub version: String,
359    pub source: String,
360    pub sha256_sdist: Option<String>,
361    pub sha256_wheel: Option<String>,
362}
363
364/// Oracle provenance as loaded from `compat/pproxy-2.7.9/provenance.toml`.
365#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
366pub struct OracleProvenance {
367    pub oracle: OracleProvenanceInner,
368}
369
370#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
371pub struct OracleProvenanceInner {
372    pub package: String,
373    pub version: String,
374    pub source: String,
375    pub license: String,
376    pub retrieval_date: String,
377}
378
379/// Errors from oracle provenance verification.
380#[derive(Debug, Clone, Error, PartialEq, Eq)]
381pub enum OracleError {
382    #[error("file I/O error: {message}")]
383    Io { message: String },
384
385    #[error("TOML parse error: {message}")]
386    TomlParse { message: String },
387
388    #[error("expected pproxy version \"{expected}\" but found \"{found}\"")]
389    VersionMismatch { expected: String, found: String },
390
391    #[error("expected pproxy package \"{expected}\" but found \"{found}\"")]
392    PackageMismatch { expected: String, found: String },
393
394    #[error("wheel hash mismatch: expected \"{expected}\" but computed \"{found}\"")]
395    WheelHashMismatch { expected: String, found: String },
396
397    #[error("no wheel hash recorded in hashes.toml")]
398    MissingWheelHash,
399
400    #[error("hash computation failed: {message}")]
401    HashComputationFailed { message: String },
402}
403
404/// Load and validate oracle hashes from the canonical path.
405pub fn load_oracle_hashes(manifest_dir: &Path) -> Result<OracleHashes, OracleError> {
406    let path = manifest_dir.join("compat/pproxy-2.7.9/hashes.toml");
407    let content = fs::read_to_string(&path).map_err(|e| OracleError::Io {
408        message: format!("failed to read {}: {}", path.display(), e),
409    })?;
410    let hashes: OracleHashes = toml::from_str(&content).map_err(|e| OracleError::TomlParse {
411        message: e.to_string(),
412    })?;
413    Ok(hashes)
414}
415
416/// Load and validate oracle provenance from the canonical path.
417pub fn load_oracle_provenance(manifest_dir: &Path) -> Result<OracleProvenance, OracleError> {
418    let path = manifest_dir.join("compat/pproxy-2.7.9/provenance.toml");
419    let content = fs::read_to_string(&path).map_err(|e| OracleError::Io {
420        message: format!("failed to read {}: {}", path.display(), e),
421    })?;
422    let prov: OracleProvenance = toml::from_str(&content).map_err(|e| OracleError::TomlParse {
423        message: e.to_string(),
424    })?;
425    Ok(prov)
426}
427
428/// Verify that the strict manifest's pproxy_version matches the oracle provenance.
429pub fn verify_manifest_oracle_version(
430    manifest: &StrictManifest,
431    provenance: &OracleProvenance,
432) -> Result<(), OracleError> {
433    if manifest.meta.pproxy_version != provenance.oracle.version {
434        return Err(OracleError::VersionMismatch {
435            expected: provenance.oracle.version.clone(),
436            found: manifest.meta.pproxy_version.clone(),
437        });
438    }
439    if provenance.oracle.package != "pproxy" {
440        return Err(OracleError::PackageMismatch {
441            expected: "pproxy".to_string(),
442            found: provenance.oracle.package.clone(),
443        });
444    }
445    Ok(())
446}
447
448/// Verify a wheel file matches the expected hash.
449///
450/// Note: This function requires the `sha2` crate to be available at runtime.
451/// If SHA256 computation is not available, it returns an error.
452pub fn verify_wheel_hash(_wheel_path: &Path, _expected_hash: &str) -> Result<(), OracleError> {
453    // SHA256 verification requires the sha2 crate. Since eggress-testkit
454    // doesn't depend on sha2, this function is a placeholder that documents
455    // the verification interface. Actual hash verification is performed by
456    // the oracle runner at runtime.
457    Err(OracleError::HashComputationFailed {
458        message: "sha2 feature required for hash verification; use oracle runner at runtime"
459            .to_string(),
460    })
461}
462
463// ---------------------------------------------------------------------------
464// Validation
465// ---------------------------------------------------------------------------
466
467/// Validate a parsed strict manifest.
468///
469/// Returns `Ok(())` when all invariants hold, or `Err(StrictValidationErrors)`
470/// listing every violation found.
471pub fn validate_strict_manifest(manifest: &StrictManifest) -> Result<(), StrictValidationErrors> {
472    let mut errs = StrictValidationErrors::new();
473
474    // Rule 2: Duplicate IDs
475    let mut seen_ids = HashSet::new();
476    for rec in &manifest.record {
477        if !seen_ids.insert(rec.id.clone()) {
478            errs.push(StrictValidationError::DuplicateId { id: rec.id.clone() });
479        }
480    }
481
482    // Per-record validations
483    for rec in &manifest.record {
484        // Rule 3: Empty ID
485        if rec.id.is_empty() {
486            errs.push(StrictValidationError::EmptyId);
487            continue;
488        }
489
490        // Rule 1: Valid category
491        if !rec.category.is_empty() && !ALLOWED_CATEGORIES.contains(&rec.category.as_str()) {
492            errs.push(StrictValidationError::UnknownCategory {
493                value: rec.category.clone(),
494                allowed: ALLOWED_CATEGORIES.iter().map(|s| s.to_string()).collect(),
495            });
496        }
497
498        // Rule 1: Valid status
499        if !rec.status.is_empty() && !ALLOWED_STATUSES.contains(&rec.status.as_str()) {
500            errs.push(StrictValidationError::UnknownStatus {
501                value: rec.status.clone(),
502                allowed: ALLOWED_STATUSES.iter().map(|s| s.to_string()).collect(),
503            });
504        }
505
506        // Rule 1: Valid comparator
507        if !rec.comparator.is_empty() && !ALLOWED_COMPARATORS.contains(&rec.comparator.as_str()) {
508            errs.push(StrictValidationError::UnknownComparator {
509                value: rec.comparator.clone(),
510                allowed: ALLOWED_COMPARATORS.iter().map(|s| s.to_string()).collect(),
511            });
512        }
513
514        // Rule 1: Valid owner
515        if !rec.owner.is_empty() && !ALLOWED_OWNERS.contains(&rec.owner.as_str()) {
516            errs.push(StrictValidationError::UnknownOwner {
517                value: rec.owner.clone(),
518                allowed: ALLOWED_OWNERS.iter().map(|s| s.to_string()).collect(),
519            });
520        }
521
522        // Rule 1: Valid milestone
523        if !rec.milestone.is_empty() && !ALLOWED_MILESTONES.contains(&rec.milestone.as_str()) {
524            errs.push(StrictValidationError::UnknownMilestone {
525                value: rec.milestone.clone(),
526                allowed: ALLOWED_MILESTONES.iter().map(|s| s.to_string()).collect(),
527            });
528        }
529
530        // Rule 1: Valid evidence_level
531        if !rec.evidence_level.is_empty()
532            && !ALLOWED_EVIDENCE_LEVELS.contains(&rec.evidence_level.as_str())
533        {
534            errs.push(StrictValidationError::UnknownEvidenceLevel {
535                value: rec.evidence_level.clone(),
536                allowed: ALLOWED_EVIDENCE_LEVELS
537                    .iter()
538                    .map(|s| s.to_string())
539                    .collect(),
540            });
541        }
542
543        // Rule 1: Valid implementation_state
544        if !rec.implementation_state.is_empty()
545            && !ALLOWED_IMPLEMENTATION_STATES.contains(&rec.implementation_state.as_str())
546        {
547            errs.push(StrictValidationError::UnknownImplementationState {
548                value: rec.implementation_state.clone(),
549                allowed: ALLOWED_IMPLEMENTATION_STATES
550                    .iter()
551                    .map(|s| s.to_string())
552                    .collect(),
553            });
554        }
555
556        // Rule 4: drop_in requires evidence_refs or test_refs
557        if rec.status == "drop_in" && rec.evidence_refs.is_empty() && rec.test_refs.is_empty() {
558            errs.push(StrictValidationError::DropInWithoutEvidence { id: rec.id.clone() });
559        }
560
561        // Rule 5: drop_in requires oracle_probe
562        if rec.status == "drop_in" && rec.oracle_probe.is_empty() {
563            errs.push(StrictValidationError::DropInWithoutOracleProbe { id: rec.id.clone() });
564        }
565
566        // Rule 6: Unresolved progress at current milestone
567        if let Some(rec_idx) = milestone_index(&rec.milestone) {
568            if let Some(cur_idx) = milestone_index(CURRENT_MILESTONE) {
569                if rec_idx <= cur_idx && !TERMINAL_STATUSES.contains(&rec.status.as_str()) {
570                    errs.push(StrictValidationError::UnresolvedProgress {
571                        id: rec.id.clone(),
572                        status: rec.status.clone(),
573                        milestone: rec.milestone.clone(),
574                        current: CURRENT_MILESTONE.to_string(),
575                    });
576                }
577            }
578        }
579
580        // Rule 7: drop_in requires paired_oracle or bidirectional_interop evidence
581        if rec.status == "drop_in"
582            && !rec.evidence_level.is_empty()
583            && rec.evidence_level != "paired_oracle"
584            && rec.evidence_level != "bidirectional_interop"
585        {
586            errs.push(StrictValidationError::DropInRequiresStrongEvidence {
587                id: rec.id.clone(),
588                evidence_level: rec.evidence_level.clone(),
589            });
590        }
591
592        // Rule 8: structural comparator + drop_in requires non-structural evidence
593        // Structural comparators only verify existence/signature/structure, not behavior.
594        // A drop_in record must have behavioral evidence (paired_oracle or bidirectional_interop)
595        // backed by a behavioral comparator (protocol_wire, cipher_kat, cipher_roundtrip, etc.).
596        let is_structural_comparator = matches!(
597            rec.comparator.as_str(),
598            "module_existence"
599                | "constant_value"
600                | "enum_membership"
601                | "method_signature"
602                | "property_existence"
603                | "class_hierarchy"
604        );
605        if rec.status == "drop_in"
606            && is_structural_comparator
607            && (rec.evidence_level == "structural_only" || rec.evidence_level == "none")
608        {
609            errs.push(
610                StrictValidationError::StructuralComparatorDropInRequiresEvidence {
611                    id: rec.id.clone(),
612                    comparator: rec.comparator.clone(),
613                    evidence_level: rec.evidence_level.clone(),
614                },
615            );
616        }
617
618        // Rule 9: structural_only evidence cannot coexist with drop_in
619        if rec.evidence_level == "structural_only" && rec.status == "drop_in" {
620            errs.push(
621                StrictValidationError::StructuralOnlyIncompatibleWithDropIn { id: rec.id.clone() },
622            );
623        }
624
625        // Rule 1: Valid certification_scope
626        if !rec.certification_scope.is_empty()
627            && !ALLOWED_CERTIFICATION_SCOPES.contains(&rec.certification_scope.as_str())
628        {
629            errs.push(StrictValidationError::UnknownCertificationScope {
630                value: rec.certification_scope.clone(),
631                allowed: ALLOWED_CERTIFICATION_SCOPES
632                    .iter()
633                    .map(|s| s.to_string())
634                    .collect(),
635            });
636        }
637
638        // Rule 10a: drop_in + structural comparator for behavioral certification_scope
639        if rec.status == "drop_in"
640            && rec.certification_scope == "behavioral"
641            && is_structural_comparator
642        {
643            errs.push(
644                StrictValidationError::StructuralComparatorBehavioralScopeMismatch {
645                    id: rec.id.clone(),
646                    comparator: rec.comparator.clone(),
647                },
648            );
649        }
650
651        // Rule 10b: closure_required with empty evidence_refs
652        if rec.closure_required && rec.evidence_refs.is_empty() {
653            errs.push(StrictValidationError::ClosureRequiredWithoutEvidence { id: rec.id.clone() });
654        }
655
656        // Rule 10c: missing behavior_record for public structural record
657        // Structural records that are closure_required must have either a
658        // behavior_record link or explicit inventory_only classification.
659        if rec.closure_required
660            && rec.certification_scope == "structural"
661            && rec.behavior_record.is_none()
662            && !rec.inventory_only
663        {
664            errs.push(StrictValidationError::StructuralMissingBehaviorRecord {
665                id: rec.id.clone(),
666            });
667        }
668    }
669
670    if errs.is_empty() {
671        Ok(())
672    } else {
673        Err(errs)
674    }
675}
676
677/// Parse and validate a strict manifest from a filesystem path.
678pub fn validate_strict_manifest_file(
679    path: &Path,
680) -> Result<StrictManifest, StrictValidationErrors> {
681    let content = fs::read_to_string(path).map_err(|e| {
682        let mut errs = StrictValidationErrors::new();
683        errs.push(StrictValidationError::Io {
684            message: format!("failed to read {}: {}", path.display(), e),
685        });
686        errs
687    })?;
688
689    let manifest: StrictManifest = toml::from_str(&content).map_err(|e| {
690        let mut errs = StrictValidationErrors::new();
691        errs.push(StrictValidationError::TomlParse {
692            message: e.to_string(),
693        });
694        errs
695    })?;
696
697    validate_strict_manifest(&manifest)?;
698    Ok(manifest)
699}
700
701// ---------------------------------------------------------------------------
702// Report Generation
703// ---------------------------------------------------------------------------
704
705/// Summary statistics for a strict manifest.
706#[derive(Debug, Clone)]
707pub struct StrictManifestSummary {
708    pub total: usize,
709    pub by_status: Vec<(String, usize)>,
710    pub by_category: Vec<(String, usize)>,
711    pub by_owner: Vec<(String, usize)>,
712    pub by_milestone: Vec<(String, usize)>,
713    pub by_certification_scope: Vec<(String, usize)>,
714    pub terminal_count: usize,
715    pub gap_count: usize,
716}
717
718/// Compute summary statistics from a manifest.
719pub fn summarize_manifest(manifest: &StrictManifest) -> StrictManifestSummary {
720    use std::collections::HashMap;
721
722    let mut status_counts: HashMap<String, usize> = HashMap::new();
723    let mut category_counts: HashMap<String, usize> = HashMap::new();
724    let mut owner_counts: HashMap<String, usize> = HashMap::new();
725    let mut milestone_counts: HashMap<String, usize> = HashMap::new();
726    let mut scope_counts: HashMap<String, usize> = HashMap::new();
727    let mut terminal = 0;
728    let mut gaps = 0;
729
730    for rec in &manifest.record {
731        *status_counts.entry(rec.status.clone()).or_insert(0) += 1;
732        *category_counts.entry(rec.category.clone()).or_insert(0) += 1;
733        if !rec.owner.is_empty() {
734            *owner_counts.entry(rec.owner.clone()).or_insert(0) += 1;
735        }
736        if !rec.milestone.is_empty() {
737            *milestone_counts.entry(rec.milestone.clone()).or_insert(0) += 1;
738        }
739        *scope_counts
740            .entry(rec.certification_scope.clone())
741            .or_insert(0) += 1;
742        if TERMINAL_STATUSES.contains(&rec.status.as_str()) {
743            terminal += 1;
744        } else {
745            gaps += 1;
746        }
747    }
748
749    let mut by_status: Vec<_> = status_counts.into_iter().collect();
750    by_status.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
751    let mut by_category: Vec<_> = category_counts.into_iter().collect();
752    by_category.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
753    let mut by_owner: Vec<_> = owner_counts.into_iter().collect();
754    by_owner.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
755    let mut by_milestone: Vec<_> = milestone_counts.into_iter().collect();
756    by_milestone.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
757    let mut by_certification_scope: Vec<_> = scope_counts.into_iter().collect();
758    by_certification_scope.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
759
760    StrictManifestSummary {
761        total: manifest.record.len(),
762        by_status,
763        by_category,
764        by_owner,
765        by_milestone,
766        by_certification_scope,
767        terminal_count: terminal,
768        gap_count: gaps,
769    }
770}
771
772/// Generate a Markdown report from a manifest.
773pub fn generate_strict_report(manifest: &StrictManifest) -> String {
774    let summary = summarize_manifest(manifest);
775    let mut out = String::with_capacity(4096);
776
777    out.push_str("# pproxy 2.7.9 Strict Compatibility Report\n\n");
778    out.push_str(&format!(
779        "**Oracle version:** pproxy=={}\n",
780        manifest.meta.pproxy_version
781    ));
782    out.push_str(&format!("**Manifest schema:** {}\n", manifest.meta.schema));
783    out.push_str(&format!("**Policy:** {}\n", manifest.meta.policy_ref));
784    out.push_str(&format!("**Oracle ref:** {}\n\n", manifest.meta.oracle_ref));
785
786    out.push_str("## Summary\n\n");
787    out.push_str("| Metric | Count |\n");
788    out.push_str("|--------|-------|\n");
789    out.push_str(&format!("| Total records | {} |\n", summary.total));
790    out.push_str(&format!(
791        "| Terminal (resolved) | {} |\n",
792        summary.terminal_count
793    ));
794    out.push_str(&format!("| Gap (unresolved) | {} |\n", summary.gap_count));
795    out.push_str(&format!(
796        "| Certification readiness | {:.0}% |\n\n",
797        if summary.total > 0 {
798            (summary.terminal_count as f64 / summary.total as f64) * 100.0
799        } else {
800            0.0
801        }
802    ));
803
804    out.push_str("### By Status\n\n");
805    out.push_str("| Status | Count |\n");
806    out.push_str("|--------|-------|\n");
807    for (status, count) in &summary.by_status {
808        out.push_str(&format!("| {} | {} |\n", status, count));
809    }
810    out.push('\n');
811
812    out.push_str("### By Category\n\n");
813    out.push_str("| Category | Count |\n");
814    out.push_str("|----------|-------|\n");
815    for (category, count) in &summary.by_category {
816        out.push_str(&format!("| {} | {} |\n", category, count));
817    }
818    out.push('\n');
819
820    out.push_str("### By Owner\n\n");
821    out.push_str("| Owner | Count |\n");
822    out.push_str("|-------|-------|\n");
823    for (owner, count) in &summary.by_owner {
824        out.push_str(&format!("| {} | {} |\n", owner, count));
825    }
826    out.push('\n');
827
828    out.push_str("### By Milestone\n\n");
829    out.push_str("| Milestone | Count |\n");
830    out.push_str("|-----------|-------|\n");
831    for (milestone, count) in &summary.by_milestone {
832        out.push_str(&format!("| {} | {} |\n", milestone, count));
833    }
834    out.push('\n');
835
836    out.push_str("### By Certification Scope\n\n");
837    out.push_str("| Scope | Count |\n");
838    out.push_str("|-------|-------|\n");
839    for (scope, count) in &summary.by_certification_scope {
840        out.push_str(&format!("| {} | {} |\n", scope, count));
841    }
842    out.push('\n');
843
844    out.push_str("## Gap Records\n\n");
845    out.push_str("Records with non-terminal status requiring resolution:\n\n");
846    let gaps: Vec<_> = manifest
847        .record
848        .iter()
849        .filter(|r| !TERMINAL_STATUSES.contains(&r.status.as_str()))
850        .collect();
851    if gaps.is_empty() {
852        out.push_str("_No unresolved gaps._\n\n");
853    } else {
854        out.push_str("| ID | Status | Category | Owner | Milestone |\n");
855        out.push_str("|----|--------|----------|-------|----------|\n");
856        for rec in &gaps {
857            out.push_str(&format!(
858                "| {} | {} | {} | {} | {} |\n",
859                rec.id, rec.status, rec.category, rec.owner, rec.milestone
860            ));
861        }
862        out.push('\n');
863    }
864
865    out.push_str("## Terminal Records\n\n");
866    let terminals: Vec<_> = manifest
867        .record
868        .iter()
869        .filter(|r| TERMINAL_STATUSES.contains(&r.status.as_str()))
870        .collect();
871    if terminals.is_empty() {
872        out.push_str("_No terminal records._\n\n");
873    } else {
874        out.push_str("| ID | Status | Category | Notes |\n");
875        out.push_str("|----|--------|----------|-------|\n");
876        for rec in &terminals {
877            let notes = if rec.notes.is_empty() {
878                "-".to_string()
879            } else if rec.notes.len() > 80 {
880                format!("{}...", &rec.notes[..77])
881            } else {
882                rec.notes.clone()
883            };
884            out.push_str(&format!(
885                "| {} | {} | {} | {} |\n",
886                rec.id, rec.status, rec.category, notes
887            ));
888        }
889        out.push('\n');
890    }
891
892    // -- Structural inventory records --
893    out.push_str("## Structural Inventory Records\n\n");
894    let structural: Vec<_> = manifest
895        .record
896        .iter()
897        .filter(|r| r.certification_scope == "structural")
898        .collect();
899    if structural.is_empty() {
900        out.push_str("_No structural inventory records._\n\n");
901    } else {
902        out.push_str("| ID | Status | Comparator | Evidence Level | Behavior Record |\n");
903        out.push_str("|----|--------|------------|----------------|------------------|\n");
904        for rec in &structural {
905            let behavior = rec.behavior_record.as_deref().unwrap_or("-");
906            out.push_str(&format!(
907                "| {} | {} | {} | {} | {} |\n",
908                rec.id, rec.status, rec.comparator, rec.evidence_level, behavior
909            ));
910        }
911        out.push('\n');
912    }
913
914    // -- Behaviorally certified records --
915    out.push_str("## Behaviorally Certified Records\n\n");
916    let behavioral: Vec<_> = manifest
917        .record
918        .iter()
919        .filter(|r| r.certification_scope == "behavioral")
920        .collect();
921    if behavioral.is_empty() {
922        out.push_str("_No behaviorally certified records._\n\n");
923    } else {
924        out.push_str("| ID | Status | Comparator | Evidence Level |\n");
925        out.push_str("|----|--------|------------|----------------|\n");
926        for rec in &behavioral {
927            out.push_str(&format!(
928                "| {} | {} | {} | {} |\n",
929                rec.id, rec.status, rec.comparator, rec.evidence_level
930            ));
931        }
932        out.push('\n');
933    }
934
935    // -- Intentional non-parity records --
936    out.push_str("## Intentional Non-Parity Records\n\n");
937    let non_parity: Vec<_> = manifest
938        .record
939        .iter()
940        .filter(|r| r.status == "intentional_non_parity")
941        .collect();
942    if non_parity.is_empty() {
943        out.push_str("_No intentional non-parity records._\n\n");
944    } else {
945        out.push_str("| ID | Category | Notes |\n");
946        out.push_str("|----|----------|-------|\n");
947        for rec in &non_parity {
948            let notes = if rec.notes.is_empty() {
949                "-".to_string()
950            } else if rec.notes.len() > 80 {
951                format!("{}...", &rec.notes[..77])
952            } else {
953                rec.notes.clone()
954            };
955            out.push_str(&format!("| {} | {} | {} |\n", rec.id, rec.category, notes));
956        }
957        out.push('\n');
958    }
959
960    // -- Platform-constrained records --
961    out.push_str("## Platform-Constrained Records\n\n");
962    let platform: Vec<_> = manifest
963        .record
964        .iter()
965        .filter(|r| r.status == "platform_constraint" || r.certification_scope == "platform")
966        .collect();
967    if platform.is_empty() {
968        out.push_str("_No platform-constrained records._\n\n");
969    } else {
970        out.push_str("| ID | Status | Platforms | Notes |\n");
971        out.push_str("|----|--------|-----------|-------|\n");
972        for rec in &platform {
973            let platforms = if rec.platforms.is_empty() {
974                "-".to_string()
975            } else {
976                rec.platforms.join(", ")
977            };
978            let notes = if rec.notes.is_empty() {
979                "-".to_string()
980            } else if rec.notes.len() > 60 {
981                format!("{}...", &rec.notes[..57])
982            } else {
983                rec.notes.clone()
984            };
985            out.push_str(&format!(
986                "| {} | {} | {} | {} |\n",
987                rec.id, rec.status, platforms, notes
988            ));
989        }
990        out.push('\n');
991    }
992
993    // -- Unresolved behavior gaps --
994    out.push_str("## Unresolved Behavior Gaps\n\n");
995    let behavior_gaps: Vec<_> = manifest
996        .record
997        .iter()
998        .filter(|r| {
999            !TERMINAL_STATUSES.contains(&r.status.as_str()) && r.certification_scope == "behavioral"
1000        })
1001        .collect();
1002    if behavior_gaps.is_empty() {
1003        out.push_str("_No unresolved behavior gaps._\n\n");
1004    } else {
1005        out.push_str("| ID | Status | Category | Owner | Milestone |\n");
1006        out.push_str("|----|--------|----------|-------|----------|\n");
1007        for rec in &behavior_gaps {
1008            out.push_str(&format!(
1009                "| {} | {} | {} | {} | {} |\n",
1010                rec.id, rec.status, rec.category, rec.owner, rec.milestone
1011            ));
1012        }
1013        out.push('\n');
1014    }
1015
1016    // -- Missing or stale evidence --
1017    out.push_str("## Missing or Stale Evidence\n\n");
1018    let missing_evidence: Vec<_> = manifest
1019        .record
1020        .iter()
1021        .filter(|r| r.closure_required && r.evidence_refs.is_empty())
1022        .collect();
1023    if missing_evidence.is_empty() {
1024        out.push_str("_No records with missing required evidence._\n\n");
1025    } else {
1026        out.push_str("| ID | Status | Certification Scope | Closure Required |\n");
1027        out.push_str("|----|--------|---------------------|------------------|\n");
1028        for rec in &missing_evidence {
1029            out.push_str(&format!(
1030                "| {} | {} | {} | {} |\n",
1031                rec.id, rec.status, rec.certification_scope, rec.closure_required
1032            ));
1033        }
1034        out.push('\n');
1035    }
1036
1037    out
1038}
1039
1040/// Write the strict report to a file.
1041pub fn write_strict_report(
1042    manifest: &StrictManifest,
1043    output_path: &Path,
1044) -> Result<(), std::io::Error> {
1045    let report = generate_strict_report(manifest);
1046    if let Some(parent) = output_path.parent() {
1047        fs::create_dir_all(parent)?;
1048    }
1049    fs::write(output_path, report)
1050}
1051
1052#[cfg(test)]
1053#[allow(clippy::all)]
1054mod tests {
1055    use super::*;
1056
1057    fn make_meta() -> StrictManifestMeta {
1058        StrictManifestMeta {
1059            manifest_version: "1".to_string(),
1060            pproxy_version: "2.7.9".to_string(),
1061            schema: "strict_1".to_string(),
1062            policy_ref: String::new(),
1063            oracle_ref: String::new(),
1064            closure_through: "C".to_string(),
1065        }
1066    }
1067
1068    fn make_manifest(records: Vec<StrictRecord>) -> StrictManifest {
1069        StrictManifest {
1070            meta: make_meta(),
1071            record: records,
1072        }
1073    }
1074
1075    fn default_record(id: &str) -> StrictRecord {
1076        StrictRecord {
1077            id: id.to_string(),
1078            category: "protocol".to_string(),
1079            kind: "role".to_string(),
1080            module: "http".to_string(),
1081            name: format!("Test {id}"),
1082            oracle_probe: "test.probe".to_string(),
1083            candidate_probe: "test.probe".to_string(),
1084            comparator: "protocol_wire".to_string(),
1085            status: "drop_in".to_string(),
1086            owner: "track-b".to_string(),
1087            milestone: "B".to_string(),
1088            platforms: vec!["linux".to_string()],
1089            python_versions: vec![],
1090            depends_on: vec![],
1091            test_refs: vec!["test_ref".to_string()],
1092            evidence_refs: vec!["evidence_ref".to_string()],
1093            notes: String::new(),
1094            evidence_level: "paired_oracle".to_string(),
1095            implementation_state: "functional".to_string(),
1096            certification_scope: "behavioral".to_string(),
1097            closure_required: true,
1098            behavior_record: None,
1099            inventory_only: false,
1100        }
1101    }
1102
1103    #[test]
1104    fn valid_manifest_passes() {
1105        let rec = default_record("test.ok");
1106        let manifest = make_manifest(vec![rec]);
1107        let result = validate_strict_manifest(&manifest);
1108        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1109    }
1110
1111    #[test]
1112    fn duplicate_ids_fail() {
1113        let manifest = make_manifest(vec![default_record("dup"), default_record("dup")]);
1114        let errs = validate_strict_manifest(&manifest).unwrap_err();
1115        assert!(
1116            errs.errors
1117                .iter()
1118                .any(|e| matches!(e, StrictValidationError::DuplicateId { id, .. } if id == "dup")),
1119            "expected DuplicateId"
1120        );
1121    }
1122
1123    #[test]
1124    fn empty_id_fails() {
1125        let rec = StrictRecord {
1126            id: String::new(),
1127            ..default_record("unused")
1128        };
1129        let manifest = make_manifest(vec![rec]);
1130        let errs = validate_strict_manifest(&manifest).unwrap_err();
1131        assert!(
1132            errs.errors
1133                .iter()
1134                .any(|e| matches!(e, StrictValidationError::EmptyId)),
1135            "expected EmptyId"
1136        );
1137    }
1138
1139    #[test]
1140    fn unknown_category_fails() {
1141        let mut rec = default_record("bad_cat");
1142        rec.category = "bogus".to_string();
1143        let manifest = make_manifest(vec![rec]);
1144        let errs = validate_strict_manifest(&manifest).unwrap_err();
1145        assert!(
1146            errs.errors.iter().any(|e| matches!(
1147                e,
1148                StrictValidationError::UnknownCategory { value, .. } if value == "bogus"
1149            )),
1150            "expected UnknownCategory"
1151        );
1152    }
1153
1154    #[test]
1155    fn unknown_status_fails() {
1156        let mut rec = default_record("bad_status");
1157        rec.status = "bogus".to_string();
1158        let manifest = make_manifest(vec![rec]);
1159        let errs = validate_strict_manifest(&manifest).unwrap_err();
1160        assert!(
1161            errs.errors.iter().any(|e| matches!(
1162                e,
1163                StrictValidationError::UnknownStatus { value, .. } if value == "bogus"
1164            )),
1165            "expected UnknownStatus"
1166        );
1167    }
1168
1169    #[test]
1170    fn unknown_comparator_fails() {
1171        let mut rec = default_record("bad_comp");
1172        rec.comparator = "bogus".to_string();
1173        let manifest = make_manifest(vec![rec]);
1174        let errs = validate_strict_manifest(&manifest).unwrap_err();
1175        assert!(
1176            errs.errors.iter().any(|e| matches!(
1177                e,
1178                StrictValidationError::UnknownComparator { value, .. } if value == "bogus"
1179            )),
1180            "expected UnknownComparator"
1181        );
1182    }
1183
1184    #[test]
1185    fn unknown_owner_fails() {
1186        let mut rec = default_record("bad_owner");
1187        rec.owner = "bogus".to_string();
1188        let manifest = make_manifest(vec![rec]);
1189        let errs = validate_strict_manifest(&manifest).unwrap_err();
1190        assert!(
1191            errs.errors.iter().any(|e| matches!(
1192                e,
1193                StrictValidationError::UnknownOwner { value, .. } if value == "bogus"
1194            )),
1195            "expected UnknownOwner"
1196        );
1197    }
1198
1199    #[test]
1200    fn unknown_milestone_fails() {
1201        let mut rec = default_record("bad_ms");
1202        rec.milestone = "Z".to_string();
1203        let manifest = make_manifest(vec![rec]);
1204        let errs = validate_strict_manifest(&manifest).unwrap_err();
1205        assert!(
1206            errs.errors.iter().any(|e| matches!(
1207                e,
1208                StrictValidationError::UnknownMilestone { value, .. } if value == "Z"
1209            )),
1210            "expected UnknownMilestone"
1211        );
1212    }
1213
1214    #[test]
1215    fn drop_in_without_evidence_fails() {
1216        let mut rec = default_record("no_ev");
1217        rec.status = "drop_in".to_string();
1218        rec.evidence_refs = vec![];
1219        rec.test_refs = vec![];
1220        let manifest = make_manifest(vec![rec]);
1221        let errs = validate_strict_manifest(&manifest).unwrap_err();
1222        assert!(
1223            errs.errors.iter().any(|e| matches!(
1224                e,
1225                StrictValidationError::DropInWithoutEvidence { id, .. } if id == "no_ev"
1226            )),
1227            "expected DropInWithoutEvidence"
1228        );
1229    }
1230
1231    #[test]
1232    fn drop_in_without_oracle_probe_fails() {
1233        let mut rec = default_record("no_probe");
1234        rec.status = "drop_in".to_string();
1235        rec.oracle_probe = String::new();
1236        let manifest = make_manifest(vec![rec]);
1237        let errs = validate_strict_manifest(&manifest).unwrap_err();
1238        assert!(
1239            errs.errors.iter().any(|e| matches!(
1240                e,
1241                StrictValidationError::DropInWithoutOracleProbe { id, .. } if id == "no_probe"
1242            )),
1243            "expected DropInWithoutOracleProbe"
1244        );
1245    }
1246
1247    #[test]
1248    fn drop_in_with_evidence_passes() {
1249        let mut rec = default_record("with_ev");
1250        rec.status = "drop_in".to_string();
1251        rec.evidence_refs = vec!["some_evidence".to_string()];
1252        let manifest = make_manifest(vec![rec]);
1253        let result = validate_strict_manifest(&manifest);
1254        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1255    }
1256
1257    #[test]
1258    fn drop_in_with_tests_passes() {
1259        let mut rec = default_record("with_tests");
1260        rec.status = "drop_in".to_string();
1261        rec.test_refs = vec!["some_test".to_string()];
1262        rec.evidence_refs = vec![];
1263        rec.closure_required = false; // no evidence needed when closure not required
1264        let manifest = make_manifest(vec![rec]);
1265        let result = validate_strict_manifest(&manifest);
1266        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1267    }
1268
1269    #[test]
1270    fn gap_at_current_milestone_fails() {
1271        let mut rec = default_record("gap_ms");
1272        rec.status = "gap".to_string();
1273        rec.milestone = "A".to_string(); // A <= A (current)
1274        let manifest = make_manifest(vec![rec]);
1275        let errs = validate_strict_manifest(&manifest).unwrap_err();
1276        assert!(
1277            errs.errors.iter().any(|e| matches!(
1278                e,
1279                StrictValidationError::UnresolvedProgress { id, status, .. }
1280                    if id == "gap_ms" && status == "gap"
1281            )),
1282            "expected UnresolvedProgress for gap at milestone A"
1283        );
1284    }
1285
1286    #[test]
1287    fn drop_in_at_current_milestone_passes() {
1288        let mut rec = default_record("di_ms");
1289        rec.status = "drop_in".to_string();
1290        rec.milestone = "A".to_string();
1291        let manifest = make_manifest(vec![rec]);
1292        let result = validate_strict_manifest(&manifest);
1293        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1294    }
1295
1296    #[test]
1297    fn gap_at_future_milestone_passes() {
1298        let mut rec = default_record("gap_future");
1299        rec.status = "gap".to_string();
1300        rec.milestone = "E".to_string(); // E > C (current)
1301        let manifest = make_manifest(vec![rec]);
1302        let result = validate_strict_manifest(&manifest);
1303        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1304    }
1305
1306    #[test]
1307    fn multiple_errors_collected() {
1308        let mut rec = default_record("dup");
1309        rec.category = "bogus".to_string();
1310        let manifest = make_manifest(vec![rec, default_record("dup")]);
1311        let errs = validate_strict_manifest(&manifest).unwrap_err();
1312        assert!(
1313            errs.len() >= 2,
1314            "expected at least 2 errors, got {}",
1315            errs.len()
1316        );
1317        assert!(errs
1318            .errors
1319            .iter()
1320            .any(|e| matches!(e, StrictValidationError::DuplicateId { .. })));
1321        assert!(errs
1322            .errors
1323            .iter()
1324            .any(|e| matches!(e, StrictValidationError::UnknownCategory { .. })));
1325    }
1326
1327    #[test]
1328    fn all_allowed_categories_are_valid() {
1329        for cat in ALLOWED_CATEGORIES {
1330            let mut rec = default_record(&format!("cat_{cat}"));
1331            rec.category = cat.to_string();
1332            rec.status = "gap".to_string();
1333            rec.milestone = "F".to_string(); // future milestone to avoid unresolved
1334            let manifest = make_manifest(vec![rec]);
1335            let errs = validate_strict_manifest(&manifest);
1336            if let Err(ref e) = errs {
1337                assert!(
1338                    !e.errors
1339                        .iter()
1340                        .any(|e| matches!(e, StrictValidationError::UnknownCategory { .. })),
1341                    "category \"{cat}\" should be valid"
1342                );
1343            }
1344        }
1345    }
1346
1347    #[test]
1348    fn all_allowed_statuses_are_valid() {
1349        for status in ALLOWED_STATUSES {
1350            let mut rec = default_record(&format!("status_{status}"));
1351            rec.status = status.to_string();
1352            rec.milestone = "F".to_string();
1353            let manifest = make_manifest(vec![rec]);
1354            let errs = validate_strict_manifest(&manifest);
1355            if let Err(ref e) = errs {
1356                assert!(
1357                    !e.errors
1358                        .iter()
1359                        .any(|e| matches!(e, StrictValidationError::UnknownStatus { .. })),
1360                    "status \"{status}\" should be valid"
1361                );
1362            }
1363        }
1364    }
1365
1366    #[test]
1367    fn all_allowed_comparators_are_valid() {
1368        for comp in ALLOWED_COMPARATORS {
1369            let mut rec = default_record(&format!("comp_{comp}"));
1370            rec.comparator = comp.to_string();
1371            rec.status = "gap".to_string();
1372            rec.milestone = "F".to_string();
1373            let manifest = make_manifest(vec![rec]);
1374            let errs = validate_strict_manifest(&manifest);
1375            if let Err(ref e) = errs {
1376                assert!(
1377                    !e.errors
1378                        .iter()
1379                        .any(|e| matches!(e, StrictValidationError::UnknownComparator { .. })),
1380                    "comparator \"{comp}\" should be valid"
1381                );
1382            }
1383        }
1384    }
1385
1386    #[test]
1387    fn all_allowed_owners_are_valid() {
1388        for owner in ALLOWED_OWNERS {
1389            let mut rec = default_record(&format!("owner_{owner}"));
1390            rec.owner = owner.to_string();
1391            rec.status = "gap".to_string();
1392            rec.milestone = "F".to_string();
1393            let manifest = make_manifest(vec![rec]);
1394            let errs = validate_strict_manifest(&manifest);
1395            if let Err(ref e) = errs {
1396                assert!(
1397                    !e.errors
1398                        .iter()
1399                        .any(|e| matches!(e, StrictValidationError::UnknownOwner { .. })),
1400                    "owner \"{owner}\" should be valid"
1401                );
1402            }
1403        }
1404    }
1405
1406    #[test]
1407    fn all_allowed_milestones_are_valid() {
1408        for ms in ALLOWED_MILESTONES {
1409            let mut rec = default_record(&format!("ms_{ms}"));
1410            rec.milestone = ms.to_string();
1411            rec.status = "gap".to_string();
1412            let manifest = make_manifest(vec![rec]);
1413            let errs = validate_strict_manifest(&manifest);
1414            if let Err(ref e) = errs {
1415                assert!(
1416                    !e.errors
1417                        .iter()
1418                        .any(|e| matches!(e, StrictValidationError::UnknownMilestone { .. })),
1419                    "milestone \"{ms}\" should be valid"
1420                );
1421            }
1422        }
1423    }
1424
1425    #[test]
1426    fn validation_errors_collection() {
1427        let mut errs = StrictValidationErrors::new();
1428        assert!(errs.is_empty());
1429        assert_eq!(errs.len(), 0);
1430        errs.push(StrictValidationError::EmptyId);
1431        errs.push(StrictValidationError::DuplicateId {
1432            id: "a".to_string(),
1433        });
1434        assert!(!errs.is_empty());
1435        assert_eq!(errs.len(), 2);
1436    }
1437
1438    #[test]
1439    fn validate_strict_manifest_file_missing_path() {
1440        let path = Path::new("/nonexistent/path/strict_manifest.toml");
1441        let result = validate_strict_manifest_file(path);
1442        assert!(result.is_err());
1443        let errs = result.unwrap_err();
1444        assert!(errs
1445            .errors
1446            .iter()
1447            .any(|e| matches!(e, StrictValidationError::Io { .. })));
1448    }
1449
1450    #[test]
1451    fn toml_parse_error() {
1452        let bad_toml = "this is not [valid toml {{{{";
1453        let result: Result<StrictManifest, _> = toml::from_str(bad_toml);
1454        assert!(result.is_err());
1455    }
1456
1457    #[test]
1458    fn validate_real_strict_manifest() {
1459        let path = match find_strict_manifest_path() {
1460            Some(p) => p,
1461            None => {
1462                eprintln!("strict manifest not found, skipping");
1463                return;
1464            }
1465        };
1466        eprintln!("Validating strict manifest at: {}", path.display());
1467        match validate_strict_manifest_file(&path) {
1468            Ok(manifest) => {
1469                eprintln!(
1470                    "Strict manifest OK: {} records, meta.schema={}",
1471                    manifest.record.len(),
1472                    manifest.meta.schema
1473                );
1474            }
1475            Err(errs) => {
1476                eprintln!(
1477                    "Strict manifest validation FAILED with {} errors:",
1478                    errs.len()
1479                );
1480                for (i, err) in errs.errors.iter().enumerate() {
1481                    eprintln!("  ERROR {}: {}", i + 1, err);
1482                }
1483                panic!(
1484                    "strict manifest validation failed with {} errors (see above)",
1485                    errs.len()
1486                );
1487            }
1488        }
1489    }
1490
1491    #[test]
1492    fn milestone_index_ordering() {
1493        assert!(milestone_index("A") < milestone_index("B"));
1494        assert!(milestone_index("B") < milestone_index("C"));
1495        assert!(milestone_index("C") < milestone_index("D"));
1496        assert!(milestone_index("D") < milestone_index("E"));
1497        assert!(milestone_index("E") < milestone_index("F"));
1498    }
1499
1500    #[test]
1501    fn terminal_statuses_not_flagged_at_current_milestone() {
1502        for status in TERMINAL_STATUSES {
1503            let mut rec = default_record(&format!("term_{status}"));
1504            rec.status = status.to_string();
1505            rec.milestone = "B".to_string();
1506            let manifest = make_manifest(vec![rec]);
1507            let errs = validate_strict_manifest(&manifest);
1508            if let Err(ref e) = errs {
1509                assert!(
1510                    !e.errors
1511                        .iter()
1512                        .any(|e| matches!(e, StrictValidationError::UnresolvedProgress { .. })),
1513                    "terminal status \"{status}\" should not be flagged at current milestone"
1514                );
1515            }
1516        }
1517    }
1518
1519    #[test]
1520    fn gap_at_milestone_a_also_flagged() {
1521        let mut rec = default_record("gap_a");
1522        rec.status = "gap".to_string();
1523        rec.milestone = "A".to_string();
1524        let manifest = make_manifest(vec![rec]);
1525        let errs = validate_strict_manifest(&manifest).unwrap_err();
1526        assert!(
1527            errs.errors
1528                .iter()
1529                .any(|e| matches!(e, StrictValidationError::UnresolvedProgress { .. })),
1530            "gap at milestone A should be flagged"
1531        );
1532    }
1533
1534    // -- Oracle provenance verification tests --
1535
1536    #[test]
1537    fn verify_manifest_oracle_version_matches() {
1538        let manifest = make_manifest(vec![]);
1539        let provenance = OracleProvenance {
1540            oracle: OracleProvenanceInner {
1541                package: "pproxy".to_string(),
1542                version: "2.7.9".to_string(),
1543                source: "pypi".to_string(),
1544                license: "MIT".to_string(),
1545                retrieval_date: "2026-07-16".to_string(),
1546            },
1547        };
1548        assert!(verify_manifest_oracle_version(&manifest, &provenance).is_ok());
1549    }
1550
1551    #[test]
1552    fn verify_manifest_oracle_version_mismatch() {
1553        let mut meta = make_meta();
1554        meta.pproxy_version = "2.7.8".to_string();
1555        let manifest = StrictManifest {
1556            meta,
1557            record: vec![],
1558        };
1559        let provenance = OracleProvenance {
1560            oracle: OracleProvenanceInner {
1561                package: "pproxy".to_string(),
1562                version: "2.7.9".to_string(),
1563                source: "pypi".to_string(),
1564                license: "MIT".to_string(),
1565                retrieval_date: "2026-07-16".to_string(),
1566            },
1567        };
1568        let err = verify_manifest_oracle_version(&manifest, &provenance).unwrap_err();
1569        assert!(matches!(err, OracleError::VersionMismatch { .. }));
1570    }
1571
1572    #[test]
1573    fn verify_manifest_oracle_package_mismatch() {
1574        let manifest = make_manifest(vec![]);
1575        let provenance = OracleProvenance {
1576            oracle: OracleProvenanceInner {
1577                package: "not_pproxy".to_string(),
1578                version: "2.7.9".to_string(),
1579                source: "pypi".to_string(),
1580                license: "MIT".to_string(),
1581                retrieval_date: "2026-07-16".to_string(),
1582            },
1583        };
1584        let err = verify_manifest_oracle_version(&manifest, &provenance).unwrap_err();
1585        assert!(matches!(err, OracleError::PackageMismatch { .. }));
1586    }
1587
1588    #[test]
1589    fn load_oracle_hashes_from_workspace() {
1590        if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
1591            let path = PathBuf::from(&manifest_dir);
1592            match load_oracle_hashes(&path) {
1593                Ok(hashes) => {
1594                    assert_eq!(hashes.package.version, "2.7.9");
1595                    assert!(hashes.package.sha256_wheel.is_some());
1596                    let wheel_hash = hashes.package.sha256_wheel.unwrap();
1597                    assert_ne!(wheel_hash, "placeholder_update_on_first_run");
1598                    assert_eq!(wheel_hash.len(), 64); // SHA256 hex = 64 chars
1599                }
1600                Err(e) => {
1601                    eprintln!("load_oracle_hashes: {}", e);
1602                }
1603            }
1604        }
1605    }
1606
1607    #[test]
1608    fn load_oracle_provenance_from_workspace() {
1609        if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
1610            let path = PathBuf::from(&manifest_dir);
1611            match load_oracle_provenance(&path) {
1612                Ok(prov) => {
1613                    assert_eq!(prov.oracle.package, "pproxy");
1614                    assert_eq!(prov.oracle.version, "2.7.9");
1615                    assert_eq!(prov.oracle.source, "pypi");
1616                }
1617                Err(e) => {
1618                    eprintln!("load_oracle_provenance: {}", e);
1619                }
1620            }
1621        }
1622    }
1623
1624    #[test]
1625    fn manifest_version_matches_provenance() {
1626        if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
1627            let path = PathBuf::from(&manifest_dir);
1628            let hashes = load_oracle_hashes(&path);
1629            let provenance = load_oracle_provenance(&path);
1630            if let (Ok(h), Ok(p)) = (hashes, provenance) {
1631                assert_eq!(h.package.version, p.oracle.version);
1632            }
1633        }
1634    }
1635
1636    // -- Report generation tests --
1637
1638    #[test]
1639    fn summarize_manifest_basic() {
1640        let mut r1 = default_record("a");
1641        r1.status = "drop_in".to_string();
1642        r1.category = "protocol".to_string();
1643        r1.owner = "track-b".to_string();
1644        let mut r2 = default_record("b");
1645        r2.status = "gap".to_string();
1646        r2.category = "python_namespace".to_string();
1647        r2.owner = "track-a".to_string();
1648        r2.milestone = "F".to_string(); // future to avoid unresolved
1649        let manifest = make_manifest(vec![r1, r2]);
1650        let summary = summarize_manifest(&manifest);
1651        assert_eq!(summary.total, 2);
1652        assert_eq!(summary.terminal_count, 1);
1653        assert_eq!(summary.gap_count, 1);
1654        assert!(!summary.by_certification_scope.is_empty());
1655    }
1656
1657    #[test]
1658    fn summarize_manifest_all_terminal() {
1659        let mut r1 = default_record("a");
1660        r1.status = "drop_in".to_string();
1661        let mut r2 = default_record("b");
1662        r2.status = "not_applicable".to_string();
1663        let manifest = make_manifest(vec![r1, r2]);
1664        let summary = summarize_manifest(&manifest);
1665        assert_eq!(summary.total, 2);
1666        assert_eq!(summary.terminal_count, 2);
1667        assert_eq!(summary.gap_count, 0);
1668    }
1669
1670    #[test]
1671    fn generate_strict_report_basic() {
1672        let mut r1 = default_record("a");
1673        r1.status = "drop_in".to_string();
1674        r1.category = "protocol".to_string();
1675        r1.notes = "Test note".to_string();
1676        let mut r2 = default_record("b");
1677        r2.status = "gap".to_string();
1678        r2.category = "python_namespace".to_string();
1679        let manifest = make_manifest(vec![r1, r2]);
1680        let report = generate_strict_report(&manifest);
1681        assert!(report.contains("# pproxy 2.7.9 Strict Compatibility Report"));
1682        assert!(report.contains("pproxy==2.7.9"));
1683        assert!(report.contains("Total records"));
1684        assert!(report.contains("Gap Records"));
1685        assert!(report.contains("Terminal Records"));
1686    }
1687
1688    #[test]
1689    fn generate_strict_report_empty() {
1690        let manifest = make_manifest(vec![]);
1691        let report = generate_strict_report(&manifest);
1692        assert!(report.contains("Total records"));
1693        assert!(report.contains("_No unresolved gaps._"));
1694        assert!(report.contains("_No terminal records._"));
1695    }
1696
1697    #[test]
1698    fn write_strict_report_to_temp() {
1699        let manifest = make_manifest(vec![]);
1700        let dir = std::env::temp_dir().join("eggress_strict_report_test");
1701        let path = dir.join("report.md");
1702        let result = write_strict_report(&manifest, &path);
1703        assert!(result.is_ok());
1704        assert!(path.exists());
1705        let content = fs::read_to_string(&path).unwrap();
1706        assert!(content.contains("pproxy 2.7.9 Strict Compatibility Report"));
1707        // cleanup
1708        let _ = fs::remove_file(&path);
1709        let _ = fs::remove_dir(&dir);
1710    }
1711
1712    #[test]
1713    fn generate_strict_report_truncates_long_notes() {
1714        let mut rec = default_record("long_notes");
1715        rec.notes = "A".repeat(200);
1716        rec.status = "drop_in".to_string();
1717        let manifest = make_manifest(vec![rec]);
1718        let report = generate_strict_report(&manifest);
1719        assert!(report.contains("..."));
1720    }
1721
1722    // -- Evidence level / implementation_state regression tests (AC1) --
1723
1724    #[test]
1725    fn module_existence_drop_in_structural_only_rejected() {
1726        let mut rec = default_record("me_drop_struct");
1727        rec.comparator = "module_existence".to_string();
1728        rec.status = "drop_in".to_string();
1729        rec.evidence_level = "structural_only".to_string();
1730        let manifest = make_manifest(vec![rec]);
1731        let errs = validate_strict_manifest(&manifest).unwrap_err();
1732        assert!(
1733            errs.errors.iter().any(|e| matches!(
1734                e,
1735                StrictValidationError::StructuralComparatorDropInRequiresEvidence { id, .. }
1736                    if id == "me_drop_struct"
1737            )),
1738            "expected StructuralComparatorDropInRequiresEvidence for module_existence + drop_in + structural_only"
1739        );
1740    }
1741
1742    #[test]
1743    fn structural_only_with_drop_in_rejected() {
1744        let mut rec = default_record("struct_drop");
1745        rec.comparator = "protocol_wire".to_string();
1746        rec.status = "drop_in".to_string();
1747        rec.evidence_level = "structural_only".to_string();
1748        let manifest = make_manifest(vec![rec]);
1749        let errs = validate_strict_manifest(&manifest).unwrap_err();
1750        assert!(
1751            errs.errors.iter().any(|e| matches!(
1752                e,
1753                StrictValidationError::StructuralOnlyIncompatibleWithDropIn { id, .. }
1754                    if id == "struct_drop"
1755            )),
1756            "expected StructuralOnlyIncompatibleWithDropIn for structural_only + drop_in"
1757        );
1758    }
1759
1760    #[test]
1761    fn drop_in_with_candidate_only_rejected() {
1762        let mut rec = default_record("drop_candidate");
1763        rec.comparator = "protocol_wire".to_string();
1764        rec.status = "drop_in".to_string();
1765        rec.evidence_level = "candidate_only".to_string();
1766        let manifest = make_manifest(vec![rec]);
1767        let errs = validate_strict_manifest(&manifest).unwrap_err();
1768        assert!(
1769            errs.errors.iter().any(|e| matches!(
1770                e,
1771                StrictValidationError::DropInRequiresStrongEvidence { id, .. }
1772                    if id == "drop_candidate"
1773            )),
1774            "expected DropInRequiresStrongEvidence for drop_in + candidate_only"
1775        );
1776    }
1777
1778    #[test]
1779    fn drop_in_with_paired_oracle_passes() {
1780        let mut rec = default_record("drop_paired");
1781        rec.status = "drop_in".to_string();
1782        rec.evidence_level = "paired_oracle".to_string();
1783        let manifest = make_manifest(vec![rec]);
1784        let result = validate_strict_manifest(&manifest);
1785        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1786    }
1787
1788    #[test]
1789    fn drop_in_with_bidirectional_interop_passes() {
1790        let mut rec = default_record("drop_bidi");
1791        rec.status = "drop_in".to_string();
1792        rec.evidence_level = "bidirectional_interop".to_string();
1793        let manifest = make_manifest(vec![rec]);
1794        let result = validate_strict_manifest(&manifest);
1795        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1796    }
1797
1798    #[test]
1799    fn all_allowed_evidence_levels_are_valid() {
1800        for ev in ALLOWED_EVIDENCE_LEVELS {
1801            let mut rec = default_record(&format!("ev_{ev}"));
1802            rec.evidence_level = ev.to_string();
1803            rec.status = "gap".to_string();
1804            rec.milestone = "F".to_string();
1805            let manifest = make_manifest(vec![rec]);
1806            let errs = validate_strict_manifest(&manifest);
1807            if let Err(ref e) = errs {
1808                assert!(
1809                    !e.errors
1810                        .iter()
1811                        .any(|e| matches!(e, StrictValidationError::UnknownEvidenceLevel { .. })),
1812                    "evidence_level \"{ev}\" should be valid"
1813                );
1814            }
1815        }
1816    }
1817
1818    #[test]
1819    fn all_allowed_implementation_states_are_valid() {
1820        for ist in ALLOWED_IMPLEMENTATION_STATES {
1821            let mut rec = default_record(&format!("ist_{ist}"));
1822            rec.implementation_state = ist.to_string();
1823            rec.status = "gap".to_string();
1824            rec.milestone = "F".to_string();
1825            let manifest = make_manifest(vec![rec]);
1826            let errs = validate_strict_manifest(&manifest);
1827            if let Err(ref e) = errs {
1828                assert!(
1829                    !e.errors.iter().any(|e| matches!(
1830                        e,
1831                        StrictValidationError::UnknownImplementationState { .. }
1832                    )),
1833                    "implementation_state \"{ist}\" should be valid"
1834                );
1835            }
1836        }
1837    }
1838
1839    #[test]
1840    fn unknown_evidence_level_fails() {
1841        let mut rec = default_record("bad_ev");
1842        rec.evidence_level = "bogus".to_string();
1843        let manifest = make_manifest(vec![rec]);
1844        let errs = validate_strict_manifest(&manifest).unwrap_err();
1845        assert!(
1846            errs.errors.iter().any(|e| matches!(
1847                e,
1848                StrictValidationError::UnknownEvidenceLevel { value, .. } if value == "bogus"
1849            )),
1850            "expected UnknownEvidenceLevel"
1851        );
1852    }
1853
1854    #[test]
1855    fn unknown_implementation_state_fails() {
1856        let mut rec = default_record("bad_ist");
1857        rec.implementation_state = "bogus".to_string();
1858        let manifest = make_manifest(vec![rec]);
1859        let errs = validate_strict_manifest(&manifest).unwrap_err();
1860        assert!(
1861            errs.errors.iter().any(|e| matches!(
1862                e,
1863                StrictValidationError::UnknownImplementationState { value, .. } if value == "bogus"
1864            )),
1865            "expected UnknownImplementationState"
1866        );
1867    }
1868
1869    #[test]
1870    fn constant_value_drop_in_structural_only_rejected() {
1871        let mut rec = default_record("cv_drop_struct");
1872        rec.comparator = "constant_value".to_string();
1873        rec.status = "drop_in".to_string();
1874        rec.evidence_level = "structural_only".to_string();
1875        let manifest = make_manifest(vec![rec]);
1876        let errs = validate_strict_manifest(&manifest).unwrap_err();
1877        assert!(
1878            errs.errors.iter().any(|e| matches!(
1879                e,
1880                StrictValidationError::StructuralComparatorDropInRequiresEvidence { id, .. }
1881                    if id == "cv_drop_struct"
1882            )),
1883            "expected StructuralComparatorDropInRequiresEvidence for constant_value + drop_in + structural_only"
1884        );
1885    }
1886
1887    #[test]
1888    fn gap_with_structural_only_passes() {
1889        let mut rec = default_record("gap_struct");
1890        rec.status = "gap".to_string();
1891        rec.milestone = "F".to_string();
1892        rec.evidence_level = "structural_only".to_string();
1893        let manifest = make_manifest(vec![rec]);
1894        let result = validate_strict_manifest(&manifest);
1895        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1896    }
1897
1898    // -- Rule 10 tests --
1899
1900    #[test]
1901    fn rule_10a_structural_comparator_behavioral_scope_rejected() {
1902        let mut rec = default_record("r10a_reject");
1903        rec.comparator = "module_existence".to_string();
1904        rec.status = "drop_in".to_string();
1905        rec.certification_scope = "behavioral".to_string();
1906        let manifest = make_manifest(vec![rec]);
1907        let errs = validate_strict_manifest(&manifest).unwrap_err();
1908        assert!(
1909            errs.errors.iter().any(|e| matches!(
1910                e,
1911                StrictValidationError::StructuralComparatorBehavioralScopeMismatch { id, .. }
1912                    if id == "r10a_reject"
1913            )),
1914            "expected StructuralComparatorBehavioralScopeMismatch for module_existence + drop_in + behavioral"
1915        );
1916    }
1917
1918    #[test]
1919    fn rule_10a_structural_comparator_structural_scope_passes() {
1920        let mut rec = default_record("r10a_pass");
1921        rec.comparator = "module_existence".to_string();
1922        rec.status = "structural".to_string();
1923        rec.certification_scope = "structural".to_string();
1924        rec.behavior_record = Some("python.pproxy.server.Connection".to_string());
1925        let manifest = make_manifest(vec![rec]);
1926        let result = validate_strict_manifest(&manifest);
1927        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1928    }
1929
1930    #[test]
1931    fn rule_10b_closure_required_without_evidence_rejected() {
1932        let mut rec = default_record("r10b_reject");
1933        rec.closure_required = true;
1934        rec.evidence_refs = vec![];
1935        let manifest = make_manifest(vec![rec]);
1936        let errs = validate_strict_manifest(&manifest).unwrap_err();
1937        assert!(
1938            errs.errors.iter().any(|e| matches!(
1939                e,
1940                StrictValidationError::ClosureRequiredWithoutEvidence { id, .. }
1941                    if id == "r10b_reject"
1942            )),
1943            "expected ClosureRequiredWithoutEvidence"
1944        );
1945    }
1946
1947    #[test]
1948    fn rule_10b_closure_not_required_without_evidence_passes() {
1949        let mut rec = default_record("r10b_pass");
1950        rec.closure_required = false;
1951        rec.evidence_refs = vec![];
1952        let manifest = make_manifest(vec![rec]);
1953        let result = validate_strict_manifest(&manifest);
1954        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1955    }
1956
1957    #[test]
1958    fn rule_10c_structural_missing_behavior_record_fails() {
1959        // Rule 10c: structural closure_required records without behavior_record
1960        // or inventory_only must fail validation.
1961        let mut rec = default_record("r10c_reject");
1962        rec.certification_scope = "structural".to_string();
1963        rec.behavior_record = None;
1964        rec.inventory_only = false;
1965        let manifest = make_manifest(vec![rec]);
1966        let errs = validate_strict_manifest(&manifest).unwrap_err();
1967        assert!(
1968            errs.errors.iter().any(|e| matches!(
1969                e,
1970                StrictValidationError::StructuralMissingBehaviorRecord { id, .. }
1971                    if id == "r10c_reject"
1972            )),
1973            "expected StructuralMissingBehaviorRecord"
1974        );
1975    }
1976
1977    #[test]
1978    fn rule_10c_structural_inventory_only_passes() {
1979        // Rule 10c: structural records with inventory_only = true are exempt.
1980        let mut rec = default_record("r10c_inventory_only");
1981        rec.certification_scope = "structural".to_string();
1982        rec.behavior_record = None;
1983        rec.inventory_only = true;
1984        let manifest = make_manifest(vec![rec]);
1985        let result = validate_strict_manifest(&manifest);
1986        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1987    }
1988
1989    #[test]
1990    fn rule_10c_structural_with_behavior_record_passes() {
1991        let mut rec = default_record("r10c_pass");
1992        rec.certification_scope = "structural".to_string();
1993        rec.behavior_record = Some("some.behavior.record".to_string());
1994        let manifest = make_manifest(vec![rec]);
1995        let result = validate_strict_manifest(&manifest);
1996        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1997    }
1998
1999    #[test]
2000    fn rule_10c_behavioral_scope_no_behavior_record_passes() {
2001        let mut rec = default_record("r10c_behavioral_pass");
2002        rec.certification_scope = "behavioral".to_string();
2003        rec.behavior_record = None;
2004        let manifest = make_manifest(vec![rec]);
2005        let result = validate_strict_manifest(&manifest);
2006        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
2007    }
2008
2009    #[test]
2010    fn unknown_certification_scope_fails() {
2011        let mut rec = default_record("bad_scope");
2012        rec.certification_scope = "bogus".to_string();
2013        let manifest = make_manifest(vec![rec]);
2014        let errs = validate_strict_manifest(&manifest).unwrap_err();
2015        assert!(
2016            errs.errors.iter().any(|e| matches!(
2017                e,
2018                StrictValidationError::UnknownCertificationScope { value, .. } if value == "bogus"
2019            )),
2020            "expected UnknownCertificationScope"
2021        );
2022    }
2023
2024    #[test]
2025    fn all_allowed_certification_scopes_are_valid() {
2026        for scope in ALLOWED_CERTIFICATION_SCOPES {
2027            let mut rec = default_record(&format!("scope_{scope}"));
2028            rec.certification_scope = scope.to_string();
2029            rec.status = "gap".to_string();
2030            rec.milestone = "F".to_string();
2031            let manifest = make_manifest(vec![rec]);
2032            let errs = validate_strict_manifest(&manifest);
2033            if let Err(ref e) = errs {
2034                assert!(
2035                    !e.errors.iter().any(|e| matches!(
2036                        e,
2037                        StrictValidationError::UnknownCertificationScope { .. }
2038                    )),
2039                    "certification_scope \"{scope}\" should be valid"
2040                );
2041            }
2042        }
2043    }
2044}