1use std::fmt;
32use std::path::{Path, PathBuf};
33
34use provable_contracts::lint::collect_yaml_files;
35use provable_contracts::ontology::arming::{ArmedGatesShrank, ArmedShapesShrank};
36use provable_contracts::ontology::verdict::Reason;
37use provable_contracts::schema::{parse_contract, Contract};
38
39pub const ZERO_CONTRACTS_EXIT: i32 = 2;
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ZeroContracts {
50 pub path: PathBuf,
52 pub filter: Option<String>,
54}
55
56impl fmt::Display for ZeroContracts {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 write!(f, "0 contracts under {}", self.path.display())?;
59 if let Some(k) = &self.filter {
60 write!(f, " (after --kind {k})")?;
61 }
62 Ok(())
63 }
64}
65
66impl std::error::Error for ZeroContracts {}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ParseErrors {
72 pub path: PathBuf,
74 pub files: usize,
76 pub errors: Vec<(PathBuf, String)>,
78}
79
80impl fmt::Display for ParseErrors {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(
83 f,
84 "{} parse error{} under {}\n {} of {} contract files measured did not parse",
85 self.errors.len(),
86 if self.errors.len() == 1 { "" } else { "s" },
87 self.path.display(),
88 self.errors.len(),
89 self.files,
90 )?;
91 for (file, err) in &self.errors {
92 write!(f, "\n {}: {err}", file.display())?;
93 }
94 Ok(())
95 }
96}
97
98impl std::error::Error for ParseErrors {}
99
100pub fn require_contracts<T>(
102 path: &Path,
103 corpus: &[T],
104 filter: Option<&str>,
105) -> Result<(), ZeroContracts> {
106 if corpus.is_empty() {
107 return Err(ZeroContracts {
108 path: path.to_path_buf(),
109 filter: filter.map(str::to_string),
110 });
111 }
112 Ok(())
113}
114
115pub fn has_contract_files(path: &Path) -> bool {
119 if path.is_file() {
120 return true;
121 }
122 let mut files = Vec::new();
123 collect_yaml_files(path, &mut files);
124 !files.is_empty()
125}
126
127pub fn collect_corpus(path: &Path) -> Result<Vec<(String, Contract)>, Box<dyn std::error::Error>> {
138 let mut out = Vec::new();
139 let mut errors = Vec::new();
140 if path.is_dir() {
141 walk_contracts(path, &mut out, &mut errors);
142 } else if path.is_file() {
143 out.push((stem_of(path), parse_contract(path)?));
144 }
145 if !errors.is_empty() {
146 return Err(ParseErrors {
147 path: path.to_path_buf(),
148 files: out.len() + errors.len(),
149 errors,
150 }
151 .into());
152 }
153 require_contracts(path, &out, None)?;
154 out.sort_by(|a, b| a.0.cmp(&b.0));
155 Ok(out)
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct LintDeclined {
164 pub reason: Reason,
165}
166
167impl fmt::Display for LintDeclined {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 write!(f, "{}", self.reason)
170 }
171}
172
173impl std::error::Error for LintDeclined {}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub struct LintRejected {
178 pub passed: usize,
180 pub armed: usize,
182}
183
184impl fmt::Display for LintRejected {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 write!(
187 f,
188 "lint failed ({}/{} armed gates passed)",
189 self.passed, self.armed
190 )
191 }
192}
193
194impl std::error::Error for LintRejected {}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SigmaMalformed(pub String);
200
201impl fmt::Display for SigmaMalformed {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 write!(f, "{}", self.0)
204 }
205}
206
207impl std::error::Error for SigmaMalformed {}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct UnknownGate {
212 pub asked: String,
213 pub known: Vec<String>,
214}
215
216impl fmt::Display for UnknownGate {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 write!(
219 f,
220 "--gate {}: not a gate this build runs alone (try: {})",
221 self.asked,
222 self.known.join(", ")
223 )
224 }
225}
226
227impl std::error::Error for UnknownGate {}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ReleaseArgsRefused(pub String);
233
234impl fmt::Display for ReleaseArgsRefused {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 write!(f, "{}", self.0)
237 }
238}
239
240impl std::error::Error for ReleaseArgsRefused {}
241
242pub const ARMED_GATES_SHRANK_EXIT: i32 = 3;
244
245pub fn exit_code_for(err: &(dyn std::error::Error + 'static)) -> i32 {
249 if err.downcast_ref::<ZeroContracts>().is_some() || err.downcast_ref::<LintDeclined>().is_some()
250 {
251 ZERO_CONTRACTS_EXIT
252 } else if err.downcast_ref::<ArmedGatesShrank>().is_some()
253 || err.downcast_ref::<ArmedShapesShrank>().is_some()
254 || err.downcast_ref::<SigmaMalformed>().is_some()
255 || err.downcast_ref::<ReleaseArgsRefused>().is_some()
256 {
257 ARMED_GATES_SHRANK_EXIT
258 } else {
259 1
260 }
261}
262
263#[must_use]
269pub fn verdict_for(err: &(dyn std::error::Error + 'static)) -> &'static str {
270 if err.downcast_ref::<ZeroContracts>().is_some() || err.downcast_ref::<LintDeclined>().is_some()
271 {
272 "decline"
273 } else if err.downcast_ref::<ParseErrors>().is_some()
274 || err.downcast_ref::<LintRejected>().is_some()
275 {
276 "reject"
277 } else {
278 "error"
279 }
280}
281
282fn stem_of(path: &Path) -> String {
283 path.file_stem()
284 .and_then(|s| s.to_str())
285 .unwrap_or("unknown")
286 .to_string()
287}
288
289pub fn walk_contracts(
293 dir: &Path,
294 out: &mut Vec<(String, Contract)>,
295 errors: &mut Vec<(PathBuf, String)>,
296) {
297 let mut files = Vec::new();
298 collect_yaml_files(dir, &mut files);
299 for path in files {
300 match parse_contract(&path) {
301 Ok(c) => out.push((stem_of(&path), c)),
302 Err(e) => errors.push((path, e.to_string())),
303 }
304 }
305}
306
307pub fn collect_contracts(dir: &Path, out: &mut Vec<(String, Contract)>) {
313 let mut dropped = Vec::new();
314 walk_contracts(dir, out, &mut dropped);
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 const MINIMAL: &str = "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";
322
323 #[test]
324 fn collect_from_missing_dir_is_empty() {
325 let mut out = Vec::new();
326 collect_contracts(Path::new("/nonexistent/path/to/contracts"), &mut out);
327 assert!(out.is_empty());
328 }
329
330 #[test]
331 fn collect_from_real_contracts_dir() {
332 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
333 if !dir.exists() {
334 return; }
336 let mut out = Vec::new();
337 collect_contracts(&dir, &mut out);
338 assert!(
340 out.len() > 100,
341 "expected > 100 contracts across the tree, got {}",
342 out.len()
343 );
344 for (stem, _) in &out {
346 assert!(!stem.is_empty(), "empty stem in collected contracts");
347 assert_ne!(stem, "binding", "binding sidecar was not skipped");
348 }
349 let mut files = Vec::new();
353 provable_contracts::lint::collect_yaml_files(&dir, &mut files);
354 assert_eq!(
355 out.len(),
356 files.len(),
357 "walker and lint disagree on the corpus"
358 );
359 assert!(
360 out.iter().any(|(stem, _)| stem.contains("playbook")),
361 "the corpus's playbook-named contracts were dropped"
362 );
363 }
364
365 #[test]
366 fn collect_skips_binding_yaml() {
367 let tmp = tempfile::tempdir().expect("temp dir is creatable");
368 std::fs::write(
369 tmp.path().join("binding.yaml"),
370 "crates: []\nbindings: []\n",
371 )
372 .expect("fixture file is writable");
373 std::fs::write(tmp.path().join("real-contract-v1.yaml"), MINIMAL)
374 .expect("fixture file is writable");
375
376 let mut out = Vec::new();
377 collect_contracts(tmp.path(), &mut out);
378
379 let stems: Vec<_> = out.iter().map(|(s, _)| s.as_str()).collect();
380 assert!(!stems.contains(&"binding"), "binding should be skipped");
381 }
382
383 #[test]
384 fn collect_recurses_into_subdirs() {
385 let tmp = tempfile::tempdir().expect("temp dir is creatable");
386 let sub = tmp.path().join("sub");
387 std::fs::create_dir_all(&sub).expect("fixture subdirectory is creatable");
388 std::fs::write(tmp.path().join("top-v1.yaml"), MINIMAL).expect("fixture file is writable");
389 std::fs::write(sub.join("nested-v1.yaml"), MINIMAL).expect("fixture file is writable");
390
391 let mut out = Vec::new();
392 collect_contracts(tmp.path(), &mut out);
393
394 let stems: Vec<_> = out.iter().map(|(s, _)| s.clone()).collect();
395 assert!(stems.contains(&"top-v1".to_string()));
396 assert!(stems.contains(&"nested-v1".to_string()));
397 }
398
399 #[test]
402 fn zero_contracts_names_the_path_and_the_filter() {
403 let plain = ZeroContracts {
404 path: PathBuf::from("/x/contracts"),
405 filter: None,
406 };
407 assert_eq!(plain.to_string(), "0 contracts under /x/contracts");
408 let filtered = ZeroContracts {
409 path: PathBuf::from("/x/contracts"),
410 filter: Some("kernel".to_string()),
411 };
412 assert_eq!(
413 filtered.to_string(),
414 "0 contracts under /x/contracts (after --kind kernel)"
415 );
416 }
417
418 #[test]
419 fn collect_corpus_refuses_a_missing_path_with_exit_2() {
420 let err = collect_corpus(Path::new("/nonexistent/path/to/contracts"))
421 .expect_err("a missing path is an empty corpus");
422 assert!(
423 err.downcast_ref::<ZeroContracts>().is_some(),
424 "not a ZeroContracts: {err}"
425 );
426 assert_eq!(exit_code_for(err.as_ref()), ZERO_CONTRACTS_EXIT);
427 assert_eq!(
428 err.to_string(),
429 "0 contracts under /nonexistent/path/to/contracts"
430 );
431 }
432
433 #[test]
434 fn collect_corpus_refuses_an_empty_dir_and_a_sidecar_only_dir() {
435 let tmp = tempfile::tempdir().expect("temp dir is creatable");
436 assert!(
437 collect_corpus(tmp.path()).is_err(),
438 "empty dir must be refused"
439 );
440 std::fs::write(
441 tmp.path().join("binding.yaml"),
442 "crates: []\nbindings: []\n",
443 )
444 .expect("fixture file is writable");
445 assert!(
446 collect_corpus(tmp.path()).is_err(),
447 "a directory holding only sidecars is an empty corpus"
448 );
449 }
450
451 #[test]
452 fn collect_corpus_treats_a_file_as_a_one_contract_corpus() {
453 let tmp = tempfile::tempdir().expect("temp dir is creatable");
454 let file = tmp.path().join("solo-v1.yaml");
455 std::fs::write(&file, MINIMAL).expect("fixture file is writable");
456 let corpus = collect_corpus(&file).expect("one file is one contract");
457 assert_eq!(corpus.len(), 1);
458 assert_eq!(corpus[0].0, "solo-v1");
459 }
460
461 #[test]
462 fn collect_corpus_propagates_a_parse_error_at_exit_1() {
463 let tmp = tempfile::tempdir().expect("temp dir is creatable");
464 let file = tmp.path().join("garbage.yaml");
465 std::fs::write(&file, "{{{ not yaml at all: [\n").expect("fixture file is writable");
466 let err = collect_corpus(&file).expect_err("garbage is a parse error, not an empty corpus");
467 assert!(
468 err.downcast_ref::<ZeroContracts>().is_none(),
469 "a parse error is not ZeroContracts"
470 );
471 assert_eq!(exit_code_for(err.as_ref()), 1);
472 }
473
474 #[test]
475 fn lint_meet_errors_map_to_the_lattice_exits() {
476 let declined: Box<dyn std::error::Error> = Box::new(LintDeclined {
477 reason: Reason::NotArmed,
478 });
479 assert_eq!(exit_code_for(declined.as_ref()), 2);
480 assert_eq!(verdict_for(declined.as_ref()), "decline");
481 assert_eq!(
482 format!("{}: {declined}", verdict_for(declined.as_ref())),
483 "decline: NotArmed",
484 "the printed line is Verdict::decline_line"
485 );
486
487 let rejected: Box<dyn std::error::Error> = Box::new(LintRejected {
488 passed: 7,
489 armed: 8,
490 });
491 assert_eq!(exit_code_for(rejected.as_ref()), 1);
492 assert_eq!(verdict_for(rejected.as_ref()), "reject");
493
494 let shrank: Box<dyn std::error::Error> = Box::new(ArmedGatesShrank {
495 dropped: vec!["composition".into()],
496 });
497 assert_eq!(exit_code_for(shrank.as_ref()), ARMED_GATES_SHRANK_EXIT);
498 assert_eq!(ARMED_GATES_SHRANK_EXIT, 3);
499 assert_eq!(verdict_for(shrank.as_ref()), "error");
500
501 let other: Box<dyn std::error::Error> = "some other failure".into();
502 assert_eq!(
503 exit_code_for(other.as_ref()),
504 1,
505 "every existing error keeps exit 1"
506 );
507 assert_eq!(verdict_for(other.as_ref()), "error");
508 }
509
510 #[test]
511 fn require_contracts_passes_a_non_empty_corpus() {
512 assert!(require_contracts(Path::new("/x"), &[1], None).is_ok());
513 assert_eq!(
514 require_contracts::<u8>(Path::new("/x"), &[], Some("kernel")),
515 Err(ZeroContracts {
516 path: PathBuf::from("/x"),
517 filter: Some("kernel".to_string()),
518 })
519 );
520 }
521}