use crate::error::{PassengerError, Result};
use crate::gate::EditSet;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct TestRun {
pub code: Option<i32>,
pub results: Vec<String>,
pub failing: Vec<String>,
pub tail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Compared {
pub exit: Option<i32>,
pub results: Vec<String>,
pub failing_at_base: Vec<String>,
pub new_failures: Vec<String>,
}
impl Compared {
pub fn ok(&self) -> bool {
self.exit == Some(0) || (self.exit.is_some() && self.new_failures.is_empty())
}
}
pub const PROOF_ENV: &[(&str, &str)] = &[("CARGO_PROFILE_DEV_DEBUG", "0"), ("CARGO_PROFILE_TEST_DEBUG", "0")];
pub const PROOF_DIR_ENV: &str = "CODE_PASSENGER_PROOF_DIR";
pub struct ProofDir {
workspace: PathBuf,
src: PathBuf,
target: PathBuf,
_lock: std::fs::File,
}
fn prune(all: &Path) {
let Ok(entries) = std::fs::read_dir(all) else { return };
for e in entries.flatten() {
let dir = e.path();
let Ok(ws) = std::fs::read_to_string(dir.join("workspace")) else { continue };
if Path::new(ws.trim()).exists() {
continue;
}
let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join("lock")) else { continue };
if lock.try_lock().is_ok() {
let _ = std::fs::remove_dir_all(&dir);
}
}
}
fn skip_dir(name: &std::ffi::OsStr) -> bool {
name == "target" || name == ".git" || name == ".passenger"
}
impl ProofDir {
pub fn take(workspace: &Path) -> Result<Self> {
let workspace = workspace.canonicalize().unwrap_or_else(|_| workspace.to_path_buf());
let base = match std::env::var_os(PROOF_DIR_ENV) {
Some(d) => PathBuf::from(d),
None => {
let all = std::env::temp_dir().join("code_passenger-proof");
prune(&all);
let key = stable_digest::StableDigest::sha256(workspace.to_string_lossy().as_bytes()).as_str().chars().take(16).collect::<String>();
let name = workspace.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
all.join(format!("{name}-{key}"))
}
};
std::fs::create_dir_all(&base)?;
std::fs::write(base.join("workspace"), workspace.to_string_lossy().as_bytes())?;
let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(base.join("lock"))?;
lock.lock().map_err(|e| PassengerError::Io { detail: format!("{}: {e}", base.join("lock").display()) })?;
let me = ProofDir { workspace, src: base.join("src"), target: base.join("target"), _lock: lock };
me.sync()?;
Ok(me)
}
pub fn root(&self) -> &Path {
&self.src
}
pub fn target(&self) -> &Path {
&self.target
}
pub fn sync(&self) -> Result<()> {
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
let walk = WalkDir::new(&self.workspace).into_iter().filter_entry(|e| !(e.depth() > 0 && e.file_type().is_dir() && skip_dir(e.file_name())));
for entry in walk.filter_map(|e| e.ok()) {
if entry.depth() == 0 || entry.file_type().is_symlink() {
continue;
}
let rel = entry.path().strip_prefix(&self.workspace).map_err(|e| PassengerError::path(e.to_string()))?.to_path_buf();
let to = self.src.join(&rel);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&to)?;
} else if entry.file_type().is_file() {
let bytes = std::fs::read(entry.path())?;
if std::fs::read(&to).ok().as_deref() != Some(&bytes[..]) {
if let Some(d) = to.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(&to, &bytes)?;
}
}
seen.insert(rel);
}
let stale: Vec<PathBuf> = WalkDir::new(&self.src)
.contents_first(true)
.into_iter()
.filter_entry(|e| !(e.depth() > 0 && e.file_type().is_dir() && skip_dir(e.file_name())))
.filter_map(|e| e.ok())
.filter(|e| e.depth() > 0)
.filter_map(|e| e.path().strip_prefix(&self.src).ok().map(Path::to_path_buf))
.filter(|rel| !seen.contains(rel))
.collect();
for rel in stale {
let p = self.src.join(&rel);
if p.is_dir() {
let _ = std::fs::remove_dir(&p);
} else {
let _ = std::fs::remove_file(&p);
}
}
Ok(())
}
pub fn write(&self, edits: &EditSet) -> Result<Vec<PathBuf>> {
edits.write_to(&self.workspace, &self.src)
}
pub fn expand_fns(&self, package: &str, items: &[String]) -> Result<(BTreeMap<String, Option<String>>, String, i64)> {
use custodian_kit::exec::{line, Cargo, Plan, Watch};
let l = Cargo::expand(self.src.join("Cargo.toml").as_os_str(), package).lib().ugly();
let mut ln = line("expand", &l).map_err(|e| PassengerError::TransformRefused { detail: e.to_string() })?;
ln.env.push(("CARGO_TARGET_DIR".into(), self.target.display().to_string()));
ln.env.extend(PROOF_ENV.iter().map(|(k, v)| (k.to_string(), v.to_string())));
let shown = format!("cargo expand -p {package} --lib --ugly");
let walked = Plan::new("proof-expand").then(ln).walk(Watch::Quiet, |_, _| {}).map_err(|e| PassengerError::TransformRefused { detail: e.to_string() })?;
let w = walked.into_iter().next().ok_or_else(|| PassengerError::TransformRefused { detail: "cargo expand did not run".into() })?;
if w.code != Some(0) {
let tail = w.stderr.lines().rev().take(12).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
return Err(PassengerError::TransformRefused { detail: format!("cargo expand -p {package}: exit {:?}: {tail}", w.code) });
}
let file = syn::parse_file(&w.stdout).map_err(|e| PassengerError::TransformRefused { detail: format!("cargo expand's output for {package} does not parse: {e}") })?;
let found = items.iter().map(|item| (item.clone(), crate::to_toolkit::fn_in(&file.items, package, item).map(|f| custodian_kit::toolkit::normal::proof_form(&f)))).collect();
Ok((found, shown, w.millis))
}
pub fn test(&self, package: &str) -> Result<TestRun> {
use custodian_kit::exec::{line, Cargo, Plan, Watch};
let l = Cargo::test(self.src.join("Cargo.toml").as_os_str(), package);
let mut ln = line("tests", &l).map_err(|e| PassengerError::TransformRefused { detail: e.to_string() })?.going_on();
ln.env.push(("CARGO_TARGET_DIR".into(), self.target.display().to_string()));
ln.env.extend(PROOF_ENV.iter().map(|(k, v)| (k.to_string(), v.to_string())));
let walked = Plan::new("proof-tests").then(ln).walk(Watch::Quiet, |_, _| {}).map_err(|e| PassengerError::TransformRefused { detail: e.to_string() })?;
let w = walked.into_iter().next().ok_or_else(|| PassengerError::TransformRefused { detail: "cargo test did not run".into() })?;
let results = w.stdout.lines().chain(w.stderr.lines()).filter(|l| l.starts_with("test result:")).map(str::to_string).collect();
let failing = w.stdout.lines().filter_map(|l| l.strip_prefix("test ").and_then(|r| r.strip_suffix(" ... FAILED"))).map(str::to_string).collect();
let tail = w.stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or_default().to_string();
Ok(TestRun { code: w.code, results, failing, tail })
}
pub fn tests_compared(&self, package: &str, edits: &EditSet) -> Result<Compared> {
self.write(edits)?;
let after = self.test(package)?;
if after.code == Some(0) {
return Ok(Compared { exit: after.code, results: after.results, failing_at_base: Vec::new(), new_failures: Vec::new() });
}
if after.failing.is_empty() {
let why = format!("the tests did not run to a result: {}", after.tail);
return Ok(Compared { exit: after.code, results: after.results, failing_at_base: Vec::new(), new_failures: vec![why] });
}
self.sync()?;
let base = self.test(package);
self.write(edits)?;
let base = base?;
let at_base: BTreeSet<&String> = base.failing.iter().collect();
let (same, new): (Vec<String>, Vec<String>) = after.failing.into_iter().partition(|t| at_base.contains(t));
Ok(Compared { exit: after.code, results: after.results, failing_at_base: same, new_failures: new })
}
pub fn in_copy(&self, path: &Path) -> Result<PathBuf> {
Ok(self.src.join(crate::gate::relative(&self.workspace, path)?))
}
}