1use crate::{
4 Diagnostic, DiagnosticSeverity, Error, FileStatus, FileTrace, LeanFile, Provenance, Result,
5 TraceConfig, parse_lean_diagnostics,
6};
7use camino::Utf8Path;
8use chrono::Utc;
9use std::process::Stdio;
10use std::time::{Duration, Instant};
11use tokio::process::Command;
12use tokio::time;
13use uuid::Uuid;
14
15#[derive(Clone, Debug)]
17pub struct LeanInvocation {
18 pub file: LeanFile,
20 pub args: Vec<String>,
22}
23
24#[derive(Clone, Debug)]
26pub struct LeanRunOutput {
27 pub exit_code: Option<i32>,
29 pub stdout: String,
31 pub stderr: String,
33}
34
35pub async fn capture_provenance(lake_root: &Utf8Path) -> Provenance {
40 Provenance {
41 lean_version: tool_version("lean").await,
42 lake_version: tool_version("lake").await,
43 git_commit: git_head(lake_root).await,
44 }
45}
46
47pub async fn run_lean_file(
53 config: &TraceConfig,
54 provenance: &Provenance,
55 file: LeanFile,
56) -> FileTrace {
57 let started = Instant::now();
58 let result = run_lake_lean(config, &file).await;
59 let elapsed = started.elapsed();
60
61 match result {
62 Ok(output) => {
63 let combined = format!("{}\n{}", output.stderr, output.stdout);
64 let diagnostics = parse_lean_diagnostics(&combined, config.include_warnings);
65 let has_error = diagnostics
66 .iter()
67 .any(|d| d.severity == DiagnosticSeverity::Error);
68 let status = if output.exit_code == Some(0) && !has_error {
69 FileStatus::Passed
70 } else {
71 FileStatus::Failed
72 };
73
74 FileTrace {
75 run_id: Uuid::new_v4(),
76 file,
77 status,
78 exit_code: output.exit_code,
79 elapsed,
80 diagnostics,
81 stdout: config.keep_raw_output.then_some(output.stdout),
82 stderr: config.keep_raw_output.then_some(output.stderr),
83 lean_version: provenance.lean_version.clone(),
84 lake_version: provenance.lake_version.clone(),
85 git_commit: provenance.git_commit.clone(),
86 created_at: Utc::now(),
87 }
88 }
89 Err(Error::Timeout {
90 timeout_seconds, ..
91 }) => runner_failure(
92 file,
93 provenance,
94 FileStatus::TimedOut,
95 elapsed,
96 format!("Lean process timed out after {timeout_seconds}s"),
97 ),
98 Err(err) => runner_failure(
99 file,
100 provenance,
101 FileStatus::RunnerError,
102 elapsed,
103 format!("runner error: {err}"),
104 ),
105 }
106}
107
108fn runner_failure(
110 file: LeanFile,
111 provenance: &Provenance,
112 status: FileStatus,
113 elapsed: Duration,
114 message: String,
115) -> FileTrace {
116 FileTrace {
117 run_id: Uuid::new_v4(),
118 file,
119 status,
120 exit_code: None,
121 elapsed,
122 diagnostics: vec![Diagnostic {
123 file: None,
124 line: None,
125 column: None,
126 severity: DiagnosticSeverity::Unknown,
127 message: message.clone(),
128 goal_state: None,
129 }],
130 stdout: None,
131 stderr: Some(message),
132 lean_version: provenance.lean_version.clone(),
133 lake_version: provenance.lake_version.clone(),
134 git_commit: provenance.git_commit.clone(),
135 created_at: Utc::now(),
136 }
137}
138
139async fn run_lake_lean(config: &TraceConfig, file: &LeanFile) -> Result<LeanRunOutput> {
140 let mut command = Command::new("lake");
143 command
144 .arg("lean")
145 .arg(file.as_path().as_str())
146 .current_dir(&config.lake_root)
147 .kill_on_drop(true)
148 .stdout(Stdio::piped())
149 .stderr(Stdio::piped());
150
151 let child = command.spawn()?;
152 let output = match time::timeout(config.timeout, child.wait_with_output()).await {
153 Ok(result) => result?,
154 Err(_) => {
155 return Err(crate::Error::Timeout {
156 file: file.as_path().clone(),
157 timeout_seconds: config.timeout.as_secs(),
158 });
159 }
160 };
161
162 Ok(LeanRunOutput {
163 exit_code: output.status.code(),
164 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
165 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
166 })
167}
168
169async fn tool_version(tool: &str) -> Option<String> {
171 let output = Command::new(tool).arg("--version").output().await.ok()?;
172 if !output.status.success() {
173 return None;
174 }
175 let text = String::from_utf8_lossy(&output.stdout);
176 let text = if text.trim().is_empty() {
177 String::from_utf8_lossy(&output.stderr).into_owned()
178 } else {
179 text.into_owned()
180 };
181 let line = text.lines().next().unwrap_or("").trim();
182 (!line.is_empty()).then(|| line.to_owned())
183}
184
185async fn git_head(lake_root: &Utf8Path) -> Option<String> {
187 let output = Command::new("git")
188 .arg("-C")
189 .arg(lake_root.as_str())
190 .arg("rev-parse")
191 .arg("HEAD")
192 .output()
193 .await
194 .ok()?;
195 if !output.status.success() {
196 return None;
197 }
198 let commit = String::from_utf8_lossy(&output.stdout).trim().to_owned();
199 (!commit.is_empty()).then_some(commit)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use camino::Utf8PathBuf;
206
207 #[tokio::test]
208 async fn spawn_failure_becomes_runner_error() {
209 let config = TraceConfig::new(Utf8PathBuf::from("/no/such/lake/root/xyzzy"));
212 let provenance = Provenance::default();
213 let file = LeanFile(Utf8PathBuf::from("/no/such/lake/root/xyzzy/Foo.lean"));
214
215 let trace = run_lean_file(&config, &provenance, file).await;
216
217 assert_eq!(trace.status, FileStatus::RunnerError);
218 assert_eq!(trace.exit_code, None);
219 assert_eq!(trace.diagnostics.len(), 1);
220 assert!(trace.stderr.is_some());
221 }
222}