use crate::{
Diagnostic, DiagnosticSeverity, Error, FileStatus, FileTrace, LeanFile, Provenance, Result,
TraceConfig, parse_lean_diagnostics,
};
use camino::Utf8Path;
use chrono::Utc;
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::process::Command;
use tokio::time;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct LeanInvocation {
pub file: LeanFile,
pub args: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct LeanRunOutput {
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
pub async fn capture_provenance(lake_root: &Utf8Path) -> Provenance {
Provenance {
lean_version: tool_version("lean").await,
lake_version: tool_version("lake").await,
git_commit: git_head(lake_root).await,
}
}
pub async fn run_lean_file(
config: &TraceConfig,
provenance: &Provenance,
file: LeanFile,
) -> FileTrace {
let started = Instant::now();
let result = run_lake_lean(config, &file).await;
let elapsed = started.elapsed();
match result {
Ok(output) => {
let combined = format!("{}\n{}", output.stderr, output.stdout);
let diagnostics = parse_lean_diagnostics(&combined, config.include_warnings);
let has_error = diagnostics
.iter()
.any(|d| d.severity == DiagnosticSeverity::Error);
let status = if output.exit_code == Some(0) && !has_error {
FileStatus::Passed
} else {
FileStatus::Failed
};
FileTrace {
run_id: Uuid::new_v4(),
file,
status,
exit_code: output.exit_code,
elapsed,
diagnostics,
stdout: config.keep_raw_output.then_some(output.stdout),
stderr: config.keep_raw_output.then_some(output.stderr),
lean_version: provenance.lean_version.clone(),
lake_version: provenance.lake_version.clone(),
git_commit: provenance.git_commit.clone(),
created_at: Utc::now(),
}
}
Err(Error::Timeout {
timeout_seconds, ..
}) => runner_failure(
file,
provenance,
FileStatus::TimedOut,
elapsed,
format!("Lean process timed out after {timeout_seconds}s"),
),
Err(err) => runner_failure(
file,
provenance,
FileStatus::RunnerError,
elapsed,
format!("runner error: {err}"),
),
}
}
fn runner_failure(
file: LeanFile,
provenance: &Provenance,
status: FileStatus,
elapsed: Duration,
message: String,
) -> FileTrace {
FileTrace {
run_id: Uuid::new_v4(),
file,
status,
exit_code: None,
elapsed,
diagnostics: vec![Diagnostic {
file: None,
line: None,
column: None,
severity: DiagnosticSeverity::Unknown,
message: message.clone(),
goal_state: None,
}],
stdout: None,
stderr: Some(message),
lean_version: provenance.lean_version.clone(),
lake_version: provenance.lake_version.clone(),
git_commit: provenance.git_commit.clone(),
created_at: Utc::now(),
}
}
async fn run_lake_lean(config: &TraceConfig, file: &LeanFile) -> Result<LeanRunOutput> {
let mut command = Command::new("lake");
command
.arg("lean")
.arg(file.as_path().as_str())
.current_dir(&config.lake_root)
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = command.spawn()?;
let output = match time::timeout(config.timeout, child.wait_with_output()).await {
Ok(result) => result?,
Err(_) => {
return Err(crate::Error::Timeout {
file: file.as_path().clone(),
timeout_seconds: config.timeout.as_secs(),
});
}
};
Ok(LeanRunOutput {
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
async fn tool_version(tool: &str) -> Option<String> {
let output = Command::new(tool).arg("--version").output().await.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let text = if text.trim().is_empty() {
String::from_utf8_lossy(&output.stderr).into_owned()
} else {
text.into_owned()
};
let line = text.lines().next().unwrap_or("").trim();
(!line.is_empty()).then(|| line.to_owned())
}
async fn git_head(lake_root: &Utf8Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(lake_root.as_str())
.arg("rev-parse")
.arg("HEAD")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let commit = String::from_utf8_lossy(&output.stdout).trim().to_owned();
(!commit.is_empty()).then_some(commit)
}
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8PathBuf;
#[tokio::test]
async fn spawn_failure_becomes_runner_error() {
let config = TraceConfig::new(Utf8PathBuf::from("/no/such/lake/root/xyzzy"));
let provenance = Provenance::default();
let file = LeanFile(Utf8PathBuf::from("/no/such/lake/root/xyzzy/Foo.lean"));
let trace = run_lean_file(&config, &provenance, file).await;
assert_eq!(trace.status, FileStatus::RunnerError);
assert_eq!(trace.exit_code, None);
assert_eq!(trace.diagnostics.len(), 1);
assert!(trace.stderr.is_some());
}
}