Skip to main content

lean_agent_core/
process.rs

1//! Running external Lean/Lake processes and capturing run provenance.
2
3use 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/// Lean process invocation metadata.
16#[derive(Clone, Debug)]
17pub struct LeanInvocation {
18    /// File being checked.
19    pub file: LeanFile,
20    /// Arguments sent to Lake/Lean.
21    pub args: Vec<String>,
22}
23
24/// Raw process output from a Lean check.
25#[derive(Clone, Debug)]
26pub struct LeanRunOutput {
27    /// Exit code if process completed.
28    pub exit_code: Option<i32>,
29    /// Captured stdout.
30    pub stdout: String,
31    /// Captured stderr.
32    pub stderr: String,
33}
34
35/// Capture tooling and repository provenance once for a trace run.
36///
37/// Each probe is best-effort: a missing binary or a non-git `lake_root` yields a
38/// `None` field rather than an error, so tracing still proceeds.
39pub 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
47/// Run Lean against one file and return a normalized trace.
48///
49/// This never fails: a timeout becomes a [`FileStatus::TimedOut`] record and a
50/// spawn/wait failure becomes a [`FileStatus::RunnerError`] record, each carrying
51/// the run provenance so every record stays self-describing.
52pub 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
108/// Build a non-success trace record that carries enough metadata to debug.
109fn 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    // We start with `lake lean <file>` instead of LSP. It builds imports and runs Lean in
141    // Lake's environment. This is simpler, reproducible, and enough for v0.1.
142    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
169/// Run `<tool> --version` and return its trimmed first line of output.
170async 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
185/// Return `git rev-parse HEAD` for `lake_root`, or `None` if it is not a git repo.
186async 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        // A non-existent working directory makes the child fail to spawn, which
210        // must surface as a record rather than a dropped error.
211        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}