Skip to main content

aprender_contracts_cli/
contract_walk.rs

1//! Shared recursive contract directory walker.
2//!
3//! Multiple `pv` subcommands need to load every YAML contract under
4//! a directory tree (including subdirectories like `contracts/aprender/`,
5//! `contracts/trueno/`, `contracts/patterns/`). This module provides the
6//! canonical walker so commands agree on what counts as a contract and
7//! which sidecar files to skip.
8
9use std::path::Path;
10
11use provable_contracts::schema::{parse_contract, Contract};
12
13/// Walk `dir` recursively and collect every parseable `.yaml` contract
14/// into `out` as `(stem, contract)` pairs.
15///
16/// Skips:
17/// - `binding.yaml` / `binding.yml` (registry sidecar)
18/// - any file whose stem contains `playbook` (playbook sidecars)
19///
20/// Silently drops unparseable files — callers wanting strict loading
21/// should use `pv validate` or a bespoke loader.
22pub fn collect_contracts(dir: &Path, out: &mut Vec<(String, Contract)>) {
23    let Ok(entries) = std::fs::read_dir(dir) else {
24        return;
25    };
26    for entry in entries.flatten() {
27        let path = entry.path();
28        if path.is_dir() {
29            collect_contracts(&path, out);
30        } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
31            let stem = path
32                .file_stem()
33                .and_then(|s| s.to_str())
34                .unwrap_or("unknown")
35                .to_string();
36            if stem == "binding" || stem.contains("playbook") {
37                continue;
38            }
39            if let Ok(c) = parse_contract(&path) {
40                out.push((stem, c));
41            }
42        }
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn collect_from_missing_dir_is_empty() {
52        let mut out = Vec::new();
53        collect_contracts(Path::new("/nonexistent/path/to/contracts"), &mut out);
54        assert!(out.is_empty());
55    }
56
57    #[test]
58    fn collect_from_real_contracts_dir() {
59        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
60        if !dir.exists() {
61            return; // skip on CI without contracts
62        }
63        let mut out = Vec::new();
64        collect_contracts(&dir, &mut out);
65        // Should find hundreds of contracts including subdirs.
66        assert!(
67            out.len() > 100,
68            "expected > 100 contracts across the tree, got {}",
69            out.len()
70        );
71        // Every entry must have a non-empty stem.
72        for (stem, _) in &out {
73            assert!(!stem.is_empty(), "empty stem in collected contracts");
74            assert!(
75                !stem.contains("playbook"),
76                "playbook sidecar was not skipped: {stem}"
77            );
78            assert_ne!(stem, "binding", "binding sidecar was not skipped");
79        }
80    }
81
82    #[test]
83    fn collect_skips_binding_yaml() {
84        let tmp = tempfile::tempdir().unwrap();
85        std::fs::write(
86            tmp.path().join("binding.yaml"),
87            "crates: []\nbindings: []\n",
88        )
89        .unwrap();
90        std::fs::write(
91            tmp.path().join("real-contract-v1.yaml"),
92            "metadata:\n  version: \"1.0.0\"\n  description: \"t\"\n  references: [\"x\"]\nequations:\n  eq1:\n    formula: \"f(x)=x\"\nproof_obligations: []\nfalsification_tests: []\nkani_harnesses: []\n",
93        )
94        .unwrap();
95
96        let mut out = Vec::new();
97        collect_contracts(tmp.path(), &mut out);
98
99        let stems: Vec<_> = out.iter().map(|(s, _)| s.as_str()).collect();
100        assert!(!stems.contains(&"binding"), "binding should be skipped");
101    }
102
103    #[test]
104    fn collect_recurses_into_subdirs() {
105        let tmp = tempfile::tempdir().unwrap();
106        let sub = tmp.path().join("sub");
107        std::fs::create_dir_all(&sub).unwrap();
108        let minimal = "metadata:\n  version: \"1.0.0\"\n  description: \"t\"\n  references: [\"x\"]\nequations:\n  eq1:\n    formula: \"f(x)=x\"\nproof_obligations: []\nfalsification_tests: []\nkani_harnesses: []\n";
109        std::fs::write(tmp.path().join("top-v1.yaml"), minimal).unwrap();
110        std::fs::write(sub.join("nested-v1.yaml"), minimal).unwrap();
111
112        let mut out = Vec::new();
113        collect_contracts(tmp.path(), &mut out);
114
115        let stems: Vec<_> = out.iter().map(|(s, _)| s.clone()).collect();
116        assert!(stems.contains(&"top-v1".to_string()));
117        assert!(stems.contains(&"nested-v1".to_string()));
118    }
119}