1pub mod challenge;
18pub mod comparator;
19pub mod formalization;
20pub mod lex;
21pub mod refinement;
22pub mod summary;
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26
27use crate::ontology::extract::lean::{reference_matches, references_of, Statement};
28
29pub const ESCAPE_KINDS: &[&str] = &[
31 "sorry",
32 "admit",
33 "axiom",
34 "native_decide",
35 "implemented_by",
36 "extern",
37 "unsafe",
38 "partial",
39];
40pub const DEFAULT_AXIOMS: &[&str] = &["propext", "Classical.choice", "Quot.sound"];
42pub const ALLOWLIST: &str = "escape-allowlist.yaml";
43pub const LABELS: &str = "unresolved-labels.json";
44pub const AXIOMS_FILE: &str = "Axioms.lean";
45const ROOT_MODULE: &str = "ProvableContracts";
46
47#[derive(Debug, Clone)]
49pub struct LeanFile {
50 pub rel: String,
52 pub module: String,
53 pub toks: Vec<lex::Token>,
54 pub decls: Vec<lex::Decl>,
55 pub imports: Vec<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct Tree {
61 pub dir: PathBuf,
62 pub files: Vec<LeanFile>,
63}
64
65impl Tree {
66 pub fn load(dir: &Path) -> Result<Self, String> {
68 let root = dir.join(format!("{ROOT_MODULE}.lean"));
69 if !root.is_file() {
70 return Err(format!("no {ROOT_MODULE}.lean under {}", dir.display()));
71 }
72 let mut paths = vec![root];
73 collect_lean(&dir.join(ROOT_MODULE), &mut paths);
74 paths.sort();
75 let files = paths.iter().filter_map(|p| parse_file(dir, p)).collect();
76 Ok(Self {
77 dir: dir.to_path_buf(),
78 files,
79 })
80 }
81
82 #[must_use]
84 pub fn cone(&self) -> BTreeSet<String> {
85 let by_mod: BTreeMap<&str, &LeanFile> =
86 self.files.iter().map(|f| (f.module.as_str(), f)).collect();
87 let mut seen = BTreeSet::new();
88 let mut todo = vec![ROOT_MODULE.to_string()];
89 while let Some(m) = todo.pop() {
90 let Some(f) = by_mod.get(m.as_str()) else {
91 continue;
92 };
93 if seen.insert(m.clone()) {
94 todo.extend(f.imports.iter().cloned());
95 }
96 }
97 seen
98 }
99}
100
101fn collect_lean(dir: &Path, out: &mut Vec<PathBuf>) {
102 let Ok(rd) = std::fs::read_dir(dir) else {
103 return;
104 };
105 for e in rd.flatten() {
106 let p = e.path();
107 if p.is_dir() {
108 collect_lean(&p, out);
109 } else if p.extension().is_some_and(|x| x == "lean") {
110 out.push(p);
111 }
112 }
113}
114
115fn parse_file(dir: &Path, p: &Path) -> Option<LeanFile> {
116 let src = std::fs::read_to_string(p).ok()?;
117 let rel = p
118 .strip_prefix(dir)
119 .ok()?
120 .to_string_lossy()
121 .replace('\\', "/");
122 let blanked = lex::blank(&src);
123 let toks = lex::tokens(&blanked);
124 Some(LeanFile {
125 module: rel.trim_end_matches(".lean").replace('/', "."),
126 decls: lex::decls(&toks),
127 imports: lex::imports(&blanked),
128 toks,
129 rel,
130 })
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
135pub struct Escape {
136 pub file: String,
137 pub line: usize,
138 pub kind: String,
139 pub decl: String,
142}
143
144#[must_use]
146pub fn escapes(tree: &Tree) -> Vec<Escape> {
147 let mut out = Vec::new();
148 for f in &tree.files {
149 for (i, t) in f.toks.iter().enumerate() {
150 if ESCAPE_KINDS.contains(&t.text.as_str()) {
151 out.push(Escape {
152 file: f.rel.clone(),
153 line: t.line,
154 kind: t.text.clone(),
155 decl: owner(&f.decls, i, &t.text),
156 });
157 }
158 }
159 }
160 out
161}
162
163fn owner(decls: &[lex::Decl], i: usize, kind: &str) -> String {
164 let k = decls.partition_point(|d| d.at <= i);
169 let pick = match kind {
170 "implemented_by" | "extern" | "unsafe" | "partial" => decls.get(k),
171 _ => k.checked_sub(1).and_then(|p| decls.get(p)),
172 };
173 pick.map_or_else(|| "<none>".to_string(), |d| d.fqn.clone())
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct Allowed {
179 pub file: String,
180 pub decl: String,
181 pub kind: String,
182 pub reason: String,
183 pub ticket: String,
184 pub confirmed_by: String,
185}
186
187impl Allowed {
188 fn key(&self) -> (String, String, String) {
189 (self.file.clone(), self.decl.clone(), self.kind.clone())
190 }
191
192 fn missing(&self) -> Vec<&'static str> {
194 [
195 ("file", &self.file),
196 ("decl", &self.decl),
197 ("kind", &self.kind),
198 ("reason", &self.reason),
199 ("ticket", &self.ticket),
200 ("confirmed_by", &self.confirmed_by),
201 ]
202 .into_iter()
203 .filter(|(_, v)| v.trim().is_empty())
204 .map(|(k, _)| k)
205 .collect()
206 }
207}
208
209pub fn load_allowlist(dir: &Path) -> Result<Vec<Allowed>, String> {
211 let p = dir.join(ALLOWLIST);
212 if !p.exists() {
213 return Ok(Vec::new());
214 }
215 let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
216 let doc: serde_yaml::Value =
217 serde_yaml::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?;
218 let entries = match &doc {
219 serde_yaml::Value::Null => return Ok(Vec::new()),
220 serde_yaml::Value::Sequence(s) => s,
221 _ => return Err(format!("{}: not a list of entries", p.display())),
222 };
223 let field = |e: &serde_yaml::Value, k: &str| {
224 e.get(k).map(|v| match v {
225 serde_yaml::Value::String(s) => s.clone(),
226 other => serde_yaml::to_string(other)
227 .unwrap_or_default()
228 .trim()
229 .to_string(),
230 })
231 };
232 Ok(entries
233 .iter()
234 .map(|e| Allowed {
235 file: field(e, "file").unwrap_or_default(),
236 decl: field(e, "decl").unwrap_or_default(),
237 kind: field(e, "kind").unwrap_or_default(),
238 reason: field(e, "reason").unwrap_or_default(),
239 ticket: field(e, "ticket").unwrap_or_default(),
240 confirmed_by: field(e, "confirmed_by").unwrap_or_default(),
241 })
242 .collect())
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct Formalization {
249 pub axioms: Vec<String>,
250 pub capstones: Vec<String>,
251}
252
253pub fn load_formalization(dir: &Path) -> Result<Formalization, String> {
254 let p = dir.join("formalization.yaml");
255 let strings = |v: Option<&serde_yaml::Value>| -> Vec<String> {
256 v.and_then(serde_yaml::Value::as_sequence)
257 .map(|s| {
258 s.iter()
259 .filter_map(|x| x.as_str().map(str::to_string))
260 .collect()
261 })
262 .unwrap_or_default()
263 };
264 let doc: serde_yaml::Value = if p.exists() {
265 let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
266 serde_yaml::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?
267 } else {
268 serde_yaml::Value::Null
269 };
270 let mut axioms = strings(doc.get("status").and_then(|s| s.get("axioms")));
271 if axioms.is_empty() {
272 axioms = DEFAULT_AXIOMS.iter().map(|s| (*s).to_string()).collect();
273 }
274 Ok(Formalization {
275 axioms,
276 capstones: strings(doc.get("capstones")),
277 })
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
282pub struct Root {
283 pub fqn: String,
284 pub module: String,
285}
286
287#[derive(Debug, Clone, Default)]
289pub struct Binding {
290 pub roots: BTreeSet<Root>,
291 pub by_contract: BTreeMap<String, BTreeSet<Root>>,
293 pub missing: BTreeSet<(String, String)>,
295 pub unresolved_labels: BTreeSet<(String, String)>,
297}
298
299#[must_use]
301pub fn is_exact_name(reference: &str) -> bool {
302 reference.starts_with("ProvableContracts.") && !reference.contains(char::is_whitespace)
303}
304
305fn statements(tree: &Tree) -> Vec<(Statement, Root)> {
307 let mut out = Vec::new();
308 for f in &tree.files {
309 let parts: Vec<&str> = f.rel.split('/').collect();
310 let [_, "Theorems", domain, file] = parts.as_slice() else {
311 continue;
312 };
313 for d in f.decls.iter().filter(|d| is_theorem(d)) {
314 let s = Statement {
315 domain: (*domain).to_string(),
316 stem: file.trim_end_matches(".lean").to_string(),
317 name: d.name.clone(),
318 file: f.rel.clone(),
319 sorry_free: true,
320 };
321 out.push((
322 s,
323 Root {
324 fqn: d.fqn.clone(),
325 module: f.module.clone(),
326 },
327 ));
328 }
329 }
330 out
331}
332
333fn theorem_fqns(tree: &Tree) -> BTreeSet<&str> {
334 tree.files
335 .iter()
336 .flat_map(|f| {
337 f.decls
338 .iter()
339 .filter(|d| is_theorem(d))
340 .map(|d| d.fqn.as_str())
341 })
342 .collect()
343}
344
345fn is_theorem(d: &lex::Decl) -> bool {
346 matches!(d.keyword.as_str(), "theorem" | "lemma") && !d.private
347}
348
349#[must_use]
351pub fn bind(tree: &Tree, contract_dir: &Path) -> Binding {
352 let stmts = statements(tree);
353 let accepted: Vec<BTreeSet<String>> = stmts.iter().map(|(s, _)| s.accepted_names()).collect();
354 let theorems: BTreeMap<String, Root> = tree
355 .files
356 .iter()
357 .flat_map(|f| {
358 f.decls
359 .iter()
360 .filter(|d| is_theorem(d))
361 .map(move |d| (d.fqn.clone(), f.module.clone()))
362 })
363 .map(|(fqn, module)| (fqn.clone(), Root { fqn, module }))
364 .collect();
365 let mut b = Binding::default();
366 for (stem, _rel, doc) in crate::ontology::extract::pv_contract::documents(contract_dir) {
367 for r in references_of(&doc) {
368 bind_one(&mut b, &stem, &r, &theorems, &stmts, &accepted);
369 }
370 }
371 b
372}
373
374fn bind_one(
375 b: &mut Binding,
376 stem: &str,
377 r: &str,
378 theorems: &BTreeMap<String, Root>,
379 stmts: &[(Statement, Root)],
380 accepted: &[BTreeSet<String>],
381) {
382 if is_exact_name(r) {
383 match theorems.get(r) {
384 Some(root) => {
385 b.roots.insert(root.clone());
386 b.by_contract
387 .entry(stem.to_string())
388 .or_default()
389 .insert(root.clone());
390 }
391 None => {
392 b.missing.insert((stem.to_string(), r.to_string()));
393 }
394 }
395 return;
396 }
397 let hits: Vec<&Root> = stmts
398 .iter()
399 .zip(accepted)
400 .filter(|(_, names)| reference_matches(r, names))
401 .map(|((_, root), _)| root)
402 .collect();
403 if hits.is_empty() {
404 b.unresolved_labels
405 .insert((stem.to_string(), r.to_string()));
406 }
407 if !hits.is_empty() {
408 b.by_contract
409 .entry(stem.to_string())
410 .or_default()
411 .extend(hits.iter().map(|r| (*r).clone()));
412 }
413 b.roots.extend(hits.into_iter().cloned());
414}
415
416pub fn load_labels(dir: &Path) -> Result<Option<BTreeSet<(String, String)>>, String> {
419 let p = dir.join(LABELS);
420 if !p.exists() {
421 return Ok(None);
422 }
423 let bad = |e: &dyn std::fmt::Display| format!("{}: {e}", p.display());
424 let text = std::fs::read_to_string(&p).map_err(|e| bad(&e))?;
425 let doc: serde_json::Value = serde_json::from_str(&text).map_err(|e| bad(&e))?;
426 let labels = doc
427 .get("labels")
428 .and_then(serde_json::Value::as_array)
429 .ok_or_else(|| bad(&"no `labels` array"))?;
430 labels
431 .iter()
432 .map(|l| {
433 let f = |k: &str| {
434 l.get(k)
435 .and_then(serde_json::Value::as_str)
436 .map(str::to_string)
437 };
438 f("contract")
439 .zip(f("label"))
440 .ok_or_else(|| bad(&format!("entry without contract/label: {l}")))
441 })
442 .collect::<Result<BTreeSet<_>, _>>()
443 .map(Some)
444}
445
446#[must_use]
448pub fn render_labels(set: &BTreeSet<(String, String)>) -> String {
449 use serde_json::{Map, Value};
450 let obj = |pairs: [(&str, Value); 2]| {
451 Value::Object(
452 pairs
453 .into_iter()
454 .map(|(k, v)| (k.to_string(), v))
455 .collect::<Map<_, _>>(),
456 )
457 };
458 let labels = set
459 .iter()
460 .map(|(c, l)| {
461 obj([
462 ("contract", Value::from(c.as_str())),
463 ("label", Value::from(l.as_str())),
464 ])
465 })
466 .collect();
467 let doc = obj([
468 ("command", Value::from("make label-ratchet")),
469 ("labels", Value::Array(labels)),
470 ]);
471 format!(
472 "{}\n",
473 serde_json::to_string_pretty(&doc).unwrap_or_default()
474 )
475}
476
477fn name_lit(fqn: &str) -> String {
479 let parts: Vec<String> = fqn
480 .split('.')
481 .map(|p| {
482 let plain = p
483 .chars()
484 .next()
485 .is_some_and(|c| c.is_alphabetic() || c == '_')
486 && p.chars()
487 .all(|c| c.is_alphanumeric() || c == '_' || c == '\'');
488 if plain {
489 p.to_string()
490 } else {
491 format!("«{p}»")
492 }
493 })
494 .collect();
495 format!("`{}", parts.join("."))
496}
497
498#[must_use]
501pub fn pinned_axioms(form: &Formalization, allow: &[Allowed]) -> Vec<String> {
502 let mut out = form.axioms.clone();
503 for a in allow
504 .iter()
505 .filter(|a| a.kind == "axiom" && !a.decl.is_empty())
506 {
507 if !out.contains(&a.decl) {
508 out.push(a.decl.clone());
509 }
510 }
511 out
512}
513
514#[must_use]
516pub fn render_axioms(
517 roots: &BTreeSet<Root>,
518 cone: &BTreeSet<String>,
519 pinned: &[String],
520 form: &Formalization,
521) -> String {
522 let in_cone: Vec<&Root> = roots.iter().filter(|r| cone.contains(&r.module)).collect();
523 let orphaned = roots.len() - in_cone.len();
524 let mut s = format!(
525 "-- GENERATED by `pv discharge gen-axioms` (PVL-001 EV-6a, #4139). Do not edit: regenerate.\n\
526 -- Every contract-bound theorem in the root's import cone may use only the pinned axioms. It is a SUBSET pin,\n\
527 -- so a proof that needs fewer stays green; `sorryAx`, or any axiom outside the set, fails elaboration.\n\
528 -- {} pinned; {orphaned} bound outside the root's import cone (ORPHANED-ROOT, pinned once EV-5c imports them).\n\
529 import {ROOT_MODULE}\n\n\
530 open Lean Elab Command in\n\
531 /-- `n` exists and every axiom it depends on is in `pinned`. -/\n\
532 def pvlAxiomsSubset (n : Name) (pinned : List Name) : CommandElabM Unit := do\n\
533 \x20 unless (← getEnv).contains n do\n\
534 \x20 throwError \"MISSING-ROOT {{n}}\"\n\
535 \x20 let axs ← collectAxioms n\n\
536 \x20 let extra := axs.toList.filter (fun a => !pinned.contains a)\n\
537 \x20 unless extra.isEmpty do\n\
538 \x20 throwError \"AXIOMS {{n}}: {{extra}} outside the pinned set\"\n\n\
539 open Lean in\n\
540 /-- formalization.yaml `status.axioms`, plus every axiom escape-allowlist.yaml exempts. -/\n\
541 def pvlPinned : List Name := [{}]\n\n",
542 in_cone.len(),
543 pinned.iter().map(|a| name_lit(a)).collect::<Vec<_>>().join(", "),
544 );
545 for r in &in_cone {
546 s.push_str(&format!(
547 "run_cmd pvlAxiomsSubset {} pvlPinned\n",
548 name_lit(&r.fqn)
549 ));
550 }
551 for c in &form.capstones {
552 s.push_str(&format!(
553 "\n/-- info: '{c}' depends on axioms: [{}] -/\n#guard_msgs in #print axioms {c}\n",
554 form.axioms.join(", ")
555 ));
556 }
557 s
558}
559
560#[derive(Debug, Clone)]
562pub struct Generated {
563 pub text: String,
564 pub tree: Tree,
565 pub binding: Binding,
566 pub allow: Vec<Allowed>,
567 pub form: Formalization,
568}
569
570pub fn generate(lean_dir: &Path, contract_dir: &Path) -> Result<Generated, String> {
573 let tree = Tree::load(lean_dir)?;
574 let binding = bind(&tree, contract_dir);
575 let allow = load_allowlist(lean_dir)?;
576 let form = load_formalization(lean_dir)?;
577 let text = render_axioms(
578 &binding.roots,
579 &tree.cone(),
580 &pinned_axioms(&form, &allow),
581 &form,
582 );
583 Ok(Generated {
584 text,
585 tree,
586 binding,
587 allow,
588 form,
589 })
590}
591
592#[derive(Debug, Clone, Default, PartialEq, Eq)]
594pub struct Report {
595 pub lines: Vec<String>,
596 pub reject: bool,
597 pub decline: Option<String>,
598 pub lake_exit: Option<i32>,
600 pub leanchecker_exit: Option<i32>,
602 pub challenges: Option<comparator::Closure>,
604 pub escapes_ok: Option<bool>,
606 pub axioms_fresh: Option<bool>,
608}
609
610impl Report {
611 fn fail(&mut self, line: String) {
612 self.lines.push(format!("FAIL {line}"));
613 self.reject = true;
614 }
615}
616
617#[derive(Debug, Clone, Copy, Default)]
619pub struct CheckOpts {
620 pub strict: bool,
621 pub validate_formalization: bool,
623}
624
625pub fn judge_escapes(found: &[Escape], allow: &[Allowed], strict: bool, r: &mut Report) {
627 let listed: BTreeSet<_> = allow.iter().map(Allowed::key).collect();
628 for e in found
629 .iter()
630 .filter(|e| !listed.contains(&(e.file.clone(), e.decl.clone(), e.kind.clone())))
631 {
632 r.fail(format!(
633 "ESCAPE {}:{} `{}` in {} -- not in {ALLOWLIST}",
634 e.file, e.line, e.kind, e.decl
635 ));
636 }
637 let present: BTreeSet<_> = found
638 .iter()
639 .map(|e| (e.file.clone(), e.decl.clone(), e.kind.clone()))
640 .collect();
641 for a in allow {
642 let miss = a.missing();
643 if !miss.is_empty() {
644 r.fail(format!(
645 "{ALLOWLIST} entry {}/{}/{} has no {}",
646 a.file,
647 a.decl,
648 a.kind,
649 miss.join("/")
650 ));
651 } else if !present.contains(&a.key()) {
652 r.fail(format!(
653 "STALE {ALLOWLIST} entry {}/{}/{} -- no such escape: remove it",
654 a.file, a.decl, a.kind
655 ));
656 }
657 }
658 let pending: Vec<&Allowed> = allow
659 .iter()
660 .filter(|a| a.confirmed_by.trim() == "pending")
661 .collect();
662 if !pending.is_empty() {
663 r.lines.push(format!("PENDING ({})", pending.len()));
664 for a in &pending {
665 r.lines.push(format!(
666 " {} {} in {} -- {}",
667 a.kind, a.decl, a.file, a.ticket
668 ));
669 }
670 if strict {
671 r.fail(format!(
672 "--strict: {} allowlist entr(ies) still confirmed_by: pending",
673 pending.len()
674 ));
675 }
676 }
677}
678
679pub fn judge_labels(
682 current: &BTreeSet<(String, String)>,
683 base: Option<&BTreeSet<(String, String)>>,
684 r: &mut Report,
685) {
686 let empty = BTreeSet::new();
687 let base = base.unwrap_or(&empty);
688 for (s, l) in current.difference(base) {
689 r.fail(format!(
690 "NEW-UNRESOLVED-LABEL {s}: {l} -- names no theorem, file or domain in the tree"
691 ));
692 }
693 let resolved = base.difference(current).count();
694 if resolved > 0 {
695 r.lines.push(format!(
696 "RESOLVED-LABEL ({resolved}) still listed in {LABELS} -- `make label-ratchet` removes them"
697 ));
698 }
699 r.lines.push(format!(
700 "UNRESOLVED-LABEL ({}) (listed {})",
701 current.len(),
702 base.len()
703 ));
704}
705
706pub fn check(lean_dir: &Path, contract_dir: &Path, opts: CheckOpts) -> Report {
709 let mut r = Report::default();
710 let g = match generate(lean_dir, contract_dir) {
711 Ok(g) => g,
712 Err(e) => {
713 r.decline = Some(e);
714 return r;
715 }
716 };
717 let fails = |r: &Report| r.lines.iter().filter(|l| l.starts_with("FAIL")).count();
718 let before = fails(&r);
719 judge_escapes(&escapes(&g.tree), &g.allow, opts.strict, &mut r);
720 r.escapes_ok = Some(fails(&r) == before);
721 judge_roots(&g, &mut r);
722 judge_ratchet(lean_dir, &g.binding, &mut r);
723 let before = fails(&r);
724 judge_axioms_file(lean_dir, &g.text, &mut r);
725 r.axioms_fresh = Some(fails(&r) == before);
726 if opts.validate_formalization {
727 formalization::judge(lean_dir, &g.tree, &mut r);
728 }
729 let cone = g.tree.cone();
730 let roots = &g.binding.roots;
731 let pinned = roots.iter().filter(|x| cone.contains(&x.module)).count();
732 r.lines.push(format!(
733 "ROOTS {pinned} pinned, {} ORPHANED-ROOT",
734 roots.len() - pinned
735 ));
736 if pinned == 0 && !r.reject {
740 r.decline = Some(format!(
741 "0 contract-bound theorems in the root's import cone under {}",
742 lean_dir.display()
743 ));
744 }
745 r
746}
747
748fn judge_roots(g: &Generated, r: &mut Report) {
751 let kinds: BTreeMap<&str, &str> = g
752 .tree
753 .files
754 .iter()
755 .flat_map(|f| f.decls.iter().map(|d| (d.fqn.as_str(), d.keyword.as_str())))
756 .collect();
757 for (s, x) in &g.binding.missing {
758 let what = kinds.get(x.as_str()).map_or_else(
759 || "no such theorem in the tree".to_string(),
760 |k| format!("it names an `{k}`, not a proved theorem"),
761 );
762 r.fail(format!("MISSING-ROOT contract {s}: {x} -- {what}"));
763 }
764 let theorems = theorem_fqns(&g.tree);
765 for c in g
766 .form
767 .capstones
768 .iter()
769 .filter(|c| !theorems.contains(c.as_str()))
770 {
771 r.fail(format!(
772 "MISSING-ROOT capstone {c} -- no such theorem in the tree"
773 ));
774 }
775}
776
777fn judge_ratchet(lean_dir: &Path, b: &Binding, r: &mut Report) {
778 match load_labels(lean_dir) {
779 Ok(base) => judge_labels(&b.unresolved_labels, base.as_ref(), r),
780 Err(e) => r.fail(format!("{LABELS} unreadable: {e}")),
781 }
782}
783
784pub fn ratchet_labels(lean_dir: &Path, contract_dir: &Path) -> Report {
787 let mut r = Report::default();
788 let tree = match Tree::load(lean_dir) {
789 Ok(t) => t,
790 Err(e) => {
791 r.decline = Some(e);
792 return r;
793 }
794 };
795 let current = bind(&tree, contract_dir).unresolved_labels;
796 let next: BTreeSet<_> = match load_labels(lean_dir) {
797 Ok(Some(base)) => {
798 judge_labels(¤t, Some(&base), &mut r);
799 base.intersection(¤t).cloned().collect()
800 }
801 Ok(None) => current,
802 Err(e) => {
803 r.fail(format!("{LABELS} unreadable: {e}"));
804 return r;
805 }
806 };
807 let p = lean_dir.join(LABELS);
808 match std::fs::write(&p, render_labels(&next)) {
809 Ok(()) => r
810 .lines
811 .push(format!("wrote {} ({} label(s))", p.display(), next.len())),
812 Err(e) => r.fail(format!("cannot write {}: {e}", p.display())),
813 }
814 r
815}
816
817fn judge_axioms_file(lean_dir: &Path, text: &str, r: &mut Report) {
818 match std::fs::read_to_string(lean_dir.join(AXIOMS_FILE)) {
819 Ok(on_disk) if on_disk == text => {}
820 Ok(_) => r.fail(format!(
821 "STALE {AXIOMS_FILE} -- regeneration differs: run `pv discharge gen-axioms`"
822 )),
823 Err(_) => r.fail(format!("no {AXIOMS_FILE} -- run `pv discharge gen-axioms`")),
824 }
825}
826
827#[cfg(test)]
828mod tests;