use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{escapes, is_theorem, Report, Tree};
pub const SUMMARY_FILE: &str = "discharge-summary.json";
pub const LOG_FILE: &str = "discharge.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Module {
pub path: String,
pub blake3: String,
pub theorems: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Summary {
pub tree_sha: Option<String>,
pub toolchain: Option<String>,
pub mathlib_rev: Option<String>,
pub build_exit: Option<i32>,
pub lake_exit: Option<i32>,
pub leanchecker_exit: Option<i32>,
pub axioms_ok: bool,
pub escapes_ok: bool,
pub challenges_closed: Option<String>,
pub modules: Vec<Module>,
}
impl Summary {
#[must_use]
pub fn is_green(&self) -> bool {
self.build_exit == Some(0)
&& self.lake_exit == Some(0)
&& self.leanchecker_exit == Some(0)
&& self.axioms_ok
&& self.escapes_ok
}
#[must_use]
pub fn derived(&self) -> BTreeSet<&str> {
if !self.is_green() {
return BTreeSet::new();
}
self.modules
.iter()
.flat_map(|m| m.theorems.iter().map(String::as_str))
.collect()
}
#[must_use]
pub fn render(&self) -> String {
let mut s = serde_json::to_string_pretty(self)
.unwrap_or_else(|e| format!("{{\"error\": {:?}}}", e.to_string()));
s.push('\n');
s
}
}
#[must_use]
pub fn summary_path(lean_dir: &Path) -> PathBuf {
lean_dir
.parent()
.unwrap_or_else(|| Path::new(""))
.join(SUMMARY_FILE)
}
pub fn load(path: &Path) -> Result<Summary, String> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("{}: not a discharge summary: {e}", path.display()))
}
#[must_use]
pub fn claim_derived(derived: &BTreeSet<&str>, theorem: &str) -> bool {
derived.contains(theorem) || derived.contains(format!("ProvableContracts.{theorem}").as_str())
}
#[must_use]
pub fn modules(tree: &Tree) -> Vec<Module> {
let cone = tree.cone();
let escaped: BTreeSet<(String, String)> = escapes(tree)
.into_iter()
.map(|e| (e.file, e.decl))
.collect();
let mut out: Vec<Module> = tree
.files
.iter()
.filter(|f| cone.contains(&f.module))
.map(|f| {
let bytes = std::fs::read(tree.dir.join(&f.rel));
let (blake3, readable) = match &bytes {
Ok(b) => (blake3::hash(b).to_hex().to_string(), true),
Err(_) => (String::new(), false),
};
let theorems = f
.decls
.iter()
.filter(|d| readable && is_theorem(d))
.filter(|d| !escaped.contains(&(f.rel.clone(), d.fqn.clone())))
.map(|d| d.fqn.clone())
.collect();
Module {
path: f.rel.clone(),
blake3,
theorems,
}
})
.collect();
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[must_use]
pub fn pins(lean_dir: &Path) -> (Option<String>, Option<String>) {
let toolchain = std::fs::read_to_string(lean_dir.join("lean-toolchain"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let mathlib = std::fs::read_to_string(lean_dir.join("lake-manifest.json"))
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|doc| {
doc.get("packages")?
.as_array()?
.iter()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some("mathlib"))?
.get("rev")?
.as_str()
.map(str::to_string)
});
(toolchain, mathlib)
}
#[must_use]
pub fn summarize(
r: &Report,
tree: Option<&Tree>,
lean_dir: &Path,
tree_sha: Option<String>,
build_exit: Option<i32>,
) -> Summary {
let (toolchain, mathlib_rev) = pins(lean_dir);
Summary {
tree_sha,
toolchain,
mathlib_rev,
build_exit,
lake_exit: r.lake_exit,
leanchecker_exit: r.leanchecker_exit,
axioms_ok: r.axioms_fresh == Some(true) && r.lake_exit == Some(0),
escapes_ok: r.escapes_ok == Some(true),
challenges_closed: r.challenges.map(|c| format!("{}/{}", c.closed, c.total)),
modules: tree.map(modules).unwrap_or_default(),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum L4Source {
Discharge,
#[default]
SelfDeclared,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Discharged {
pub theorems: BTreeSet<String>,
pub withheld: Option<String>,
pub unrefined: BTreeSet<String>,
pub module_of: BTreeMap<String, String>,
}
impl Discharged {
#[must_use]
pub fn grants(&self, theorem: &str) -> bool {
let t = theorem.trim().trim_matches('"');
self.theorems.contains(t) || self.theorems.contains(&format!("ProvableContracts.{t}"))
}
pub fn require_refinement(&mut self, l4_modules: &BTreeSet<String>) {
let (kept, dropped) = std::mem::take(&mut self.theorems)
.into_iter()
.partition(|t| {
self.module_of
.get(t)
.is_some_and(|m| l4_modules.contains(m))
});
self.theorems = kept;
self.unrefined = dropped;
}
}
fn all_challenges_closed(closed: Option<&str>) -> bool {
let Some((n, m)) = closed.and_then(|c| c.split_once('/')) else {
return false;
};
matches!((n.trim().parse::<u32>(), m.trim().parse::<u32>()), (Ok(n), Ok(m)) if m > 0 && n == m)
}
#[must_use]
pub fn discharged(s: &Summary, current_tree_sha: Option<&str>) -> Discharged {
let withheld = if s.tree_sha.is_none() || s.tree_sha.as_deref() != current_tree_sha {
Some(format!(
"stale discharge: summary tree_sha {} != current lean tree {}",
s.tree_sha.as_deref().unwrap_or("null"),
current_tree_sha.unwrap_or("unknown")
))
} else if !s.is_green() {
Some(format!(
"red discharge: build_exit {:?}, lake_exit {:?}, leanchecker_exit {:?}, axioms_ok {}, escapes_ok {}",
s.build_exit, s.lake_exit, s.leanchecker_exit, s.axioms_ok, s.escapes_ok
))
} else if !all_challenges_closed(s.challenges_closed.as_deref()) {
Some(format!(
"challenges not closed: {}",
s.challenges_closed.as_deref().unwrap_or("never judged")
))
} else {
None
};
let theorems = if withheld.is_none() {
s.derived().into_iter().map(str::to_string).collect()
} else {
BTreeSet::new()
};
let module_of = s
.modules
.iter()
.flat_map(|m| m.theorems.iter().map(|t| (t.clone(), m.path.clone())))
.collect();
Discharged {
theorems,
withheld,
unrefined: BTreeSet::new(),
module_of,
}
}
#[must_use]
pub fn current_tree_sha(lean_dir: &Path) -> Option<String> {
let o = std::process::Command::new("git")
.args(["rev-parse", "HEAD:./"])
.current_dir(lean_dir)
.output()
.ok()?;
let sha = String::from_utf8_lossy(&o.stdout).trim().to_string();
(o.status.success() && !sha.is_empty()).then_some(sha)
}
#[cfg(test)]
#[path = "summary_tests.rs"]
mod tests;