Skip to main content

aprender_contracts_cli/commands/
validate.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use provable_contracts::error::Severity;
5use provable_contracts::lint::collect_yaml_files;
6use provable_contracts::schema::{parse_contract_str, validate_artifact, ArtifactKind};
7
8use crate::contract_walk::{has_contract_files, ZeroContracts};
9
10/// `pv validate <path>` — validate whatever artifact `path` holds.
11///
12/// Dispatches on what the file IS, not on the assumption that everything under
13/// `contracts/` is a `Contract`. Five files in the corpus are not: two pv
14/// binding registries and three publish manifests, all of which failed here
15/// with ``missing field `metadata` `` while the directory walkers that lint
16/// them already knew to treat them differently. See
17/// `provable_contracts::schema::artifact`.
18///
19/// PVL-1 (PMAT-1099): a path that does not exist, or a directory without a
20/// contract file, is a DECLINE — exit 2, [`ZeroContracts`] — never an OS error
21/// at exit 1. Measured 2026-09-11 by the third review quorum on #3093, after
22/// two PASS quorums: `pv validate <empty dir>` answered `Is a directory (os
23/// error 21)` and `pv validate /nonexistent` answered `No such file or
24/// directory (os error 2)`, both exit 1 — validate reads one artifact and never
25/// went through the walker every other reporting command was routed through.
26/// A directory WITH contract files validates every file lint would walk
27/// (the one definition of the corpus) and fails if any of them fails:
28/// measured, and named.
29pub fn run(path: &Path, check_ids: bool) -> Result<(), Box<dyn std::error::Error>> {
30    if !path.exists() || (path.is_dir() && !has_contract_files(path)) {
31        return Err(ZeroContracts {
32            path: path.to_path_buf(),
33            filter: None,
34        }
35        .into());
36    }
37    if check_ids {
38        return run_check_ids(path);
39    }
40    if path.is_dir() {
41        return run_dir(path);
42    }
43    run_file(path)
44}
45
46/// `pv validate <path> --check-ids` — report the obligation-id denominators.
47///
48/// This exists to be RUN ONCE, as the evidence that a consumer reads the key.
49/// `ProofObligation` had no `id` field until #3314's follow-up: `id:` was
50/// written to disk and silently dropped on parse, so 3,612 generated ids were
51/// decoration. Counting them from YAML would have proved nothing -- the count
52/// has to come through the same deserialization every other pv command uses,
53/// which is why this walks parsed contracts and not the text.
54///
55/// Reports, deliberately, three numbers and not a verdict:
56///   obligations  every proof obligation in the corpus
57///   with id      how many carry an `id:` AFTER deserialization
58///   referenced   kani harnesses whose `obligation:` RESOLVES to one of them
59///
60/// `referenced` is the second half of the same question: an id nothing cites is
61/// still not a citation. Requiring these (ids mandatory, references resolving,
62/// `N evaluated` instead of `0 errors`) is the NEXT change; this one only
63/// counts, so it can land without red-lining a corpus that is still being named.
64fn run_check_ids(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
65    let mut files: Vec<PathBuf> = Vec::new();
66    if path.is_dir() {
67        collect_yaml_files(path, &mut files);
68    } else {
69        files.push(path.to_path_buf());
70    }
71    files.sort();
72
73    let (mut obligations, mut with_id, mut referenced, mut contracts) =
74        (0usize, 0usize, 0usize, 0usize);
75    for file in &files {
76        let Ok(content) = std::fs::read_to_string(file) else {
77            continue;
78        };
79        // Not every YAML under contracts/ is a Contract -- binding registries
80        // and publish manifests live there too. A parse failure here is "not a
81        // contract", not an error: `pv validate` already judges those.
82        let Ok(contract) = parse_contract_str(&content) else {
83            continue;
84        };
85        contracts += 1;
86        let ids: HashSet<&str> = contract
87            .proof_obligations
88            .iter()
89            .filter_map(|ob| ob.id.as_deref())
90            .collect();
91        obligations += contract.proof_obligations.len();
92        with_id += ids.len();
93        referenced += contract
94            .kani_harnesses
95            .iter()
96            .filter(|h| ids.contains(h.obligation.as_str()))
97            .count();
98    }
99
100    println!(
101        "{} obligations, {} with id, {} referenced   ({} contract(s) under {})",
102        obligations,
103        with_id,
104        referenced,
105        contracts,
106        path.display()
107    );
108    Ok(())
109}
110
111/// Validate every contract file under `dir`, accumulating failures instead of
112/// stopping at the first, so the verdict names all of them.
113fn run_dir(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
114    let mut files: Vec<PathBuf> = Vec::new();
115    collect_yaml_files(dir, &mut files);
116    files.sort();
117    let mut failed: Vec<PathBuf> = Vec::new();
118    for file in &files {
119        println!("== {}", file.display());
120        if let Err(e) = run_file(file) {
121            println!("{e}");
122            failed.push(file.clone());
123        }
124    }
125    println!(
126        "\n{} artifact(s) under {}, {} failed",
127        files.len(),
128        dir.display(),
129        failed.len()
130    );
131    if failed.is_empty() {
132        Ok(())
133    } else {
134        Err(format!(
135            "{} of {} artifacts under {} failed validation",
136            failed.len(),
137            files.len(),
138            dir.display()
139        )
140        .into())
141    }
142}
143
144fn run_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
145    let (kind, violations) = validate_artifact(path)?;
146
147    let errors: Vec<_> = violations
148        .iter()
149        .filter(|v| v.severity == Severity::Error)
150        .collect();
151    let warnings: Vec<_> = violations
152        .iter()
153        .filter(|v| v.severity == Severity::Warning)
154        .collect();
155
156    for v in &violations {
157        println!("{v}");
158    }
159
160    println!("\n{} error(s), {} warning(s)", errors.len(), warnings.len());
161
162    if errors.is_empty() {
163        println!("{} is valid.", noun(kind));
164        Ok(())
165    } else {
166        Err(format!("{} has {} validation error(s)", noun(kind), errors.len()).into())
167    }
168}
169
170/// How to name the artifact in the verdict line. Naming the kind is the point:
171/// a reader who runs `pv validate contracts/binding.yaml` and is told
172/// "Contract is valid." has been told something false about which rules ran.
173fn noun(kind: ArtifactKind) -> &'static str {
174    match kind {
175        ArtifactKind::Contract => "Contract",
176        ArtifactKind::Binding => "Binding registry (kind: binding)",
177        ArtifactKind::PublishManifest => "Publish manifest (kind: publish-manifest)",
178        ArtifactKind::ExternalCorpora => "External-corpora declaration (kind: external-corpora)",
179    }
180}