Skip to main content

apollo/agent/
build_runner.rs

1//! Build runner — orchestrates cargo build/test/run cycles.
2//!
3//! Wraps `cargo build` and `cargo test` with structured error parsing, retry,
4//! and timeout handling. Uses `tokio::process::Command` with the same
5//! environment scrubbing as the shell tool so secrets never reach the child.
6//!
7//! Designed to be driven by the agent loop (via `BuildRunnerTool`) or by the
8//! autonomous loop's validation step, replacing the bare `sh -c` test runner
9//! with one that returns compiler diagnostics in a structured form the model
10//! can act on directly.
11
12use std::path::PathBuf;
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::tools::child_proc;
18
19/// Maximum retries before giving up. A retry only makes sense when the build
20/// failed for a transient reason (a stale lock, a race with another cargo
21/// invocation); compiler errors are deterministic and retrying them just
22/// burns time. The runner still retries unconditionally because the cost of a
23/// false retry is one wasted build, while the cost of not retrying a transient
24/// failure is a spurious red.
25const DEFAULT_MAX_RETRIES: usize = 2;
26
27/// Upper bound on a single cargo invocation. A release build of a large
28/// workspace can take minutes; an hour is well past any legitimate single
29/// compile and signals a stuck process.
30const DEFAULT_TIMEOUT_SECS: u64 = 1800;
31
32/// Configuration for the build runner.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BuildRunnerConfig {
35    /// Maximum number of retries on a failed build/test.
36    pub max_retries: usize,
37    /// Per-invocation timeout in seconds.
38    pub timeout_secs: u64,
39    /// Extra arguments appended to every `cargo` invocation
40    /// (e.g. `["--release"]`).
41    #[serde(default)]
42    pub extra_args: Vec<String>,
43    /// Package filter passed as `cargo <cmd> -p <name>`. Empty = whole workspace.
44    #[serde(default)]
45    pub package: String,
46}
47
48impl Default for BuildRunnerConfig {
49    fn default() -> Self {
50        Self {
51            max_retries: DEFAULT_MAX_RETRIES,
52            timeout_secs: DEFAULT_TIMEOUT_SECS,
53            extra_args: Vec::new(),
54            package: String::new(),
55        }
56    }
57}
58
59/// Severity of a parsed compiler diagnostic.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum DiagnosticSeverity {
63    Error,
64    Warning,
65    Note,
66}
67
68/// A single parsed compiler diagnostic.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CompileError {
71    pub severity: DiagnosticSeverity,
72    /// Rust error code when present (e.g. `E0308`).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub code: Option<String>,
75    /// The headline message (first line of the diagnostic).
76    pub message: String,
77    /// Source file, when the diagnostic carries a `--> path:line:col` span.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub file: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub line: Option<u32>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub column: Option<u32>,
84}
85
86/// Result of a single build or test invocation.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct BuildResult {
89    pub success: bool,
90    pub exit_code: i32,
91    /// Which attempt this is (1-based); >1 means a retry fired.
92    pub attempt: usize,
93    pub errors: Vec<CompileError>,
94    pub warnings: Vec<CompileError>,
95    /// Raw combined stdout+stderr, truncated for size.
96    pub raw_output: String,
97    /// `cargo build`, `cargo test`, etc.
98    pub command: String,
99}
100
101impl BuildResult {
102    /// A compact, model-friendly summary of the diagnostics.
103    pub fn summary(&self) -> String {
104        if self.success {
105            return format!("{} succeeded (attempt {})", self.command, self.attempt);
106        }
107        let mut out = format!(
108            "{} failed (exit {}, attempt {})\n",
109            self.command, self.exit_code, self.attempt
110        );
111        if !self.errors.is_empty() {
112            out.push_str(&format!("--- {} error(s) ---\n", self.errors.len()));
113            for e in &self.errors {
114                out.push_str(&format_error(e));
115                out.push('\n');
116            }
117        }
118        if !self.warnings.is_empty() {
119            out.push_str(&format!("--- {} warning(s) ---\n", self.warnings.len()));
120            for w in &self.warnings {
121                out.push_str(&format_error(w));
122                out.push('\n');
123            }
124        }
125        out
126    }
127}
128
129/// The build/run orchestrator.
130pub struct BuildRunner {
131    workspace: PathBuf,
132    config: BuildRunnerConfig,
133}
134
135impl BuildRunner {
136    pub fn new(workspace: PathBuf, config: BuildRunnerConfig) -> Self {
137        Self { workspace, config }
138    }
139
140    pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
141        self.workspace = workspace;
142        self
143    }
144
145    /// Run `cargo build` with retries. Returns the last attempt's result.
146    pub async fn run_build(&self) -> BuildResult {
147        self.run_with_retry("build").await
148    }
149
150    /// Run `cargo test` with retries. Returns the last attempt's result.
151    pub async fn run_test(&self) -> BuildResult {
152        self.run_with_retry("test").await
153    }
154
155    /// Run `cargo build` then `cargo test`. Stops after the build if it
156    /// fails — testing a broken compile is wasted time.
157    pub async fn run_cycle(&self) -> (BuildResult, Option<BuildResult>) {
158        let build = self.run_build().await;
159        if !build.success {
160            return (build, None);
161        }
162        let test = self.run_test().await;
163        (build, Some(test))
164    }
165
166    /// Run a cargo subcommand, retrying up to `max_retries` times on failure.
167    async fn run_with_retry(&self, subcommand: &str) -> BuildResult {
168        let max_attempts = self.config.max_retries.saturating_add(1);
169        let mut last = BuildResult {
170            success: false,
171            exit_code: -1,
172            attempt: 0,
173            errors: Vec::new(),
174            warnings: Vec::new(),
175            raw_output: String::new(),
176            command: format!("cargo {}", subcommand),
177        };
178
179        for attempt in 1..=max_attempts {
180            let result = self.run_once(subcommand, attempt).await;
181            if result.success {
182                return result;
183            }
184            tracing::warn!(
185                "[build_runner] {} attempt {} failed (exit {})",
186                subcommand,
187                attempt,
188                result.exit_code
189            );
190            last = result;
191        }
192        last
193    }
194
195    /// A single cargo invocation with timeout and output parsing.
196    async fn run_once(&self, subcommand: &str, attempt: usize) -> BuildResult {
197        let mut cmd = tokio::process::Command::new("cargo");
198        cmd.arg(subcommand)
199            .current_dir(&self.workspace)
200            .stdout(std::process::Stdio::piped())
201            .stderr(std::process::Stdio::piped())
202            .kill_on_drop(true);
203        if !self.config.package.is_empty() {
204            cmd.arg("-p").arg(&self.config.package);
205        }
206        for arg in &self.config.extra_args {
207            cmd.arg(arg);
208        }
209        child_proc::scrub(&mut cmd);
210
211        let mut child = match cmd.spawn() {
212            Ok(c) => c,
213            Err(e) => {
214                return BuildResult {
215                    success: false,
216                    exit_code: -1,
217                    attempt,
218                    errors: vec![CompileError {
219                        severity: DiagnosticSeverity::Error,
220                        code: None,
221                        message: format!("failed to spawn cargo: {e}"),
222                        file: None,
223                        line: None,
224                        column: None,
225                    }],
226                    warnings: Vec::new(),
227                    raw_output: format!("failed to spawn cargo: {e}"),
228                    command: format!("cargo {}", subcommand),
229                };
230            }
231        };
232
233        let timeout = Duration::from_secs(self.config.timeout_secs);
234        let output = match child_proc::wait_with_timeout(&mut child, timeout).await {
235            Ok(Some(o)) => o,
236            Ok(None) => {
237                return BuildResult {
238                    success: false,
239                    exit_code: -1,
240                    attempt,
241                    errors: vec![CompileError {
242                        severity: DiagnosticSeverity::Error,
243                        code: None,
244                        message: format!(
245                            "cargo {} timed out after {}s",
246                            subcommand,
247                            timeout.as_secs()
248                        ),
249                        file: None,
250                        line: None,
251                        column: None,
252                    }],
253                    warnings: Vec::new(),
254                    raw_output: format!("timed out after {}s", timeout.as_secs()),
255                    command: format!("cargo {}", subcommand),
256                };
257            }
258            Err(e) => {
259                return BuildResult {
260                    success: false,
261                    exit_code: -1,
262                    attempt,
263                    errors: vec![CompileError {
264                        severity: DiagnosticSeverity::Error,
265                        code: None,
266                        message: format!("cargo {} io error: {e}", subcommand),
267                        file: None,
268                        line: None,
269                        column: None,
270                    }],
271                    warnings: Vec::new(),
272                    raw_output: format!("io error: {e}"),
273                    command: format!("cargo {}", subcommand),
274                };
275            }
276        };
277
278        let stdout = String::from_utf8_lossy(&output.stdout);
279        let stderr = String::from_utf8_lossy(&output.stderr);
280        let combined = if stdout.is_empty() && !stderr.is_empty() {
281            stderr.to_string()
282        } else if !stderr.is_empty() {
283            format!("{}\n{}", stdout, stderr)
284        } else {
285            stdout.to_string()
286        };
287
288        let (errors, warnings) = parse_diagnostics(&combined);
289        let exit_code = output.status.code().unwrap_or(-1);
290        let success = output.status.success();
291
292        let raw = match crate::text::truncate_chars_counted(&combined, 20_000) {
293            Some((head, dropped)) => format!("{}...\n[truncated {} chars]", head, dropped),
294            None => combined,
295        };
296
297        BuildResult {
298            success,
299            exit_code,
300            attempt,
301            errors,
302            warnings,
303            raw_output: raw,
304            command: format!("cargo {}", subcommand),
305        }
306    }
307}
308
309/// Format a single diagnostic for the summary.
310fn format_error(e: &CompileError) -> String {
311    let sev = match e.severity {
312        DiagnosticSeverity::Error => "error",
313        DiagnosticSeverity::Warning => "warning",
314        DiagnosticSeverity::Note => "note",
315    };
316    let code = e
317        .code
318        .as_ref()
319        .map(|c| format!("[{}]", c))
320        .unwrap_or_default();
321    let loc = match (&e.file, e.line, e.column) {
322        (Some(f), Some(l), Some(c)) => format!(" {}:{}:{}", f, l, c),
323        (Some(f), Some(l), None) => format!(" {}:{}", f, l),
324        _ => String::new(),
325    };
326    format!("{}{}: {}{}", sev, code, e.message, loc)
327}
328
329/// Parse cargo/rustc diagnostic output into structured errors and warnings.
330///
331/// Recognises the standard rustc format:
332///
333/// ```text
334/// error[E0308]: mismatched types
335///   --> src/main.rs:10:5
336/// ```
337///
338/// and the `warning:` / `note:` variants. Lines without a `-->` span still
339/// produce a diagnostic (with `file`/`line`/`column` set to `None`) so the
340/// caller sees the headline message.
341pub fn parse_diagnostics(output: &str) -> (Vec<CompileError>, Vec<CompileError>) {
342    let mut errors = Vec::new();
343    let mut warnings = Vec::new();
344
345    let lines: Vec<&str> = output.lines().collect();
346    let mut i = 0;
347    while i < lines.len() {
348        let line = lines[i];
349
350        if let Some((severity, rest)) = parse_diagnostic_header(line) {
351            let (code, message) = parse_code_and_message(rest);
352            // Look ahead for a `--> path:line:col` span.
353            let (file, line_no, col) = look_ahead_for_span(&lines, i + 1);
354            let diag = CompileError {
355                severity: severity.clone(),
356                code,
357                message,
358                file,
359                line: line_no,
360                column: col,
361            };
362            match severity {
363                DiagnosticSeverity::Error => errors.push(diag),
364                DiagnosticSeverity::Warning => warnings.push(diag),
365                DiagnosticSeverity::Note => {}
366            }
367        }
368        i += 1;
369    }
370
371    (errors, warnings)
372}
373
374/// Detect `error[...]:`, `warning:`, or `note:` headers.
375///
376/// Returns the severity and the remainder *after* the severity keyword,
377/// including any `[CODE]` prefix, so `parse_code_and_message` can extract it.
378fn parse_diagnostic_header(line: &str) -> Option<(DiagnosticSeverity, &str)> {
379    let trimmed = line.trim_start();
380    if let Some(rest) = trimmed.strip_prefix("error") {
381        // `error:` or `error[E0308]: ...` — hand the rest (including the
382        // code bracket) to parse_code_and_message.
383        return Some((DiagnosticSeverity::Error, rest));
384    }
385    if let Some(rest) = trimmed.strip_prefix("warning") {
386        return Some((DiagnosticSeverity::Warning, rest));
387    }
388    None
389}
390
391/// Split `[E0308]: mismatched types` into `(Some("E0308"), "mismatched types")`
392/// when a code is present, otherwise `(None, message)` after stripping the
393/// leading `: ` separator.
394fn parse_code_and_message(rest: &str) -> (Option<String>, String) {
395    if rest.starts_with('[') {
396        if let Some(idx) = rest.find(']') {
397            let code = rest[1..idx].to_string();
398            let after = &rest[idx + 1..];
399            let msg = after.strip_prefix(':').unwrap_or(after).trim_start();
400            return (Some(code), msg.to_string());
401        }
402    }
403    // `: could not compile` → strip the leading colon.
404    let msg = rest.strip_prefix(':').unwrap_or(rest).trim_start();
405    (None, msg.to_string())
406}
407
408/// Scan forward from `start` for a `--> path:line:col` span line.
409fn look_ahead_for_span(lines: &[&str], start: usize) -> (Option<String>, Option<u32>, Option<u32>) {
410    for line in &lines[start..] {
411        let trimmed = line.trim_start();
412        if let Some(rest) = trimmed.strip_prefix("-->") {
413            return parse_span(rest.trim());
414        }
415        // A new diagnostic header before a span means this one had none.
416        if trimmed.starts_with("error") || trimmed.starts_with("warning") {
417            return (None, None, None);
418        }
419    }
420    (None, None, None)
421}
422
423/// Parse `src/main.rs:10:5` into file/line/column.
424fn parse_span(span: &str) -> (Option<String>, Option<u32>, Option<u32>) {
425    // The span may carry a trailing note like `src/main.rs:10:5:13:20` or
426    // `src/main.rs:10:5`. Take the first three colon-separated parts.
427    let parts: Vec<&str> = span.splitn(3, ':').collect();
428    if parts.is_empty() {
429        return (None, None, None);
430    }
431    let file = Some(parts[0].to_string());
432    let line = parts.get(1).and_then(|s| s.parse().ok());
433    let column = parts.get(2).and_then(|s| s.parse().ok());
434    (file, line, column)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    const SAMPLE_OUTPUT: &str = "\
442   Compiling apollo v0.4.0 (/Users/undivisible/projects/apollo)
443error[E0308]: mismatched types
444  --> src/main.rs:10:5
445   |
44610 |     let x: i32 = \"hello\";
447   |                 ^^^^^^^ expected `i32`, found `&str`
448
449warning: unused variable: `y`
450  --> src/main.rs:5:9
451   |
4525  |     let y = 1;
453   |         ^^^
454
455error[E0425]: cannot find value `z` in this scope
456  --> src/main.rs:12:13
457   |
45812 |     println!(\"{}\", z);
459   |                     ^ not found in this scope
460";
461
462    #[test]
463    fn parses_errors_and_warnings() {
464        let (errors, warnings) = parse_diagnostics(SAMPLE_OUTPUT);
465        assert_eq!(errors.len(), 2);
466        assert_eq!(warnings.len(), 1);
467
468        assert_eq!(errors[0].code.as_deref(), Some("E0308"));
469        assert_eq!(errors[0].message, "mismatched types");
470        assert_eq!(errors[0].file.as_deref(), Some("src/main.rs"));
471        assert_eq!(errors[0].line, Some(10));
472        assert_eq!(errors[0].column, Some(5));
473
474        assert_eq!(errors[1].code.as_deref(), Some("E0425"));
475        assert_eq!(errors[1].file.as_deref(), Some("src/main.rs"));
476        assert_eq!(errors[1].line, Some(12));
477        assert_eq!(errors[1].column, Some(13));
478
479        assert_eq!(warnings[0].severity, DiagnosticSeverity::Warning);
480        assert_eq!(warnings[0].message, "unused variable: `y`");
481        assert_eq!(warnings[0].line, Some(5));
482    }
483
484    #[test]
485    fn handles_error_without_code() {
486        let output = "error: could not compile `foo` due to 1 previous error";
487        let (errors, _) = parse_diagnostics(output);
488        assert_eq!(errors.len(), 1);
489        assert!(errors[0].code.is_none());
490        assert!(errors[0].file.is_none());
491        assert!(errors[0].line.is_none());
492    }
493
494    #[test]
495    fn handles_error_without_span() {
496        let output = "error[E0308]: mismatched types\nnote: run with `RUST_BACKTRACE=1`";
497        let (errors, _) = parse_diagnostics(output);
498        assert_eq!(errors.len(), 1);
499        assert!(errors[0].file.is_none());
500        assert!(errors[0].line.is_none());
501    }
502
503    #[test]
504    fn summary_lists_errors() {
505        let (errors, warnings) = parse_diagnostics(SAMPLE_OUTPUT);
506        let result = BuildResult {
507            success: false,
508            exit_code: 101,
509            attempt: 1,
510            errors,
511            warnings,
512            raw_output: String::new(),
513            command: "cargo build".to_string(),
514        };
515        let s = result.summary();
516        assert!(s.contains("cargo build failed"));
517        assert!(s.contains("2 error(s)"));
518        assert!(s.contains("1 warning(s)"));
519        assert!(s.contains("E0308"));
520        assert!(s.contains("src/main.rs:10:5"));
521    }
522
523    #[test]
524    fn summary_for_success() {
525        let result = BuildResult {
526            success: true,
527            exit_code: 0,
528            attempt: 1,
529            errors: Vec::new(),
530            warnings: Vec::new(),
531            raw_output: String::new(),
532            command: "cargo test".to_string(),
533        };
534        assert_eq!(result.summary(), "cargo test succeeded (attempt 1)");
535    }
536
537    #[test]
538    fn config_defaults_are_sane() {
539        let c = BuildRunnerConfig::default();
540        assert_eq!(c.max_retries, DEFAULT_MAX_RETRIES);
541        assert_eq!(c.timeout_secs, DEFAULT_TIMEOUT_SECS);
542        assert!(c.extra_args.is_empty());
543        assert!(c.package.is_empty());
544    }
545
546    #[tokio::test]
547    async fn run_build_on_nonexistent_workspace_reports_spawn_error() {
548        let runner = BuildRunner::new(
549            PathBuf::from("/nonexistent/path/that/does/not/exist"),
550            BuildRunnerConfig {
551                max_retries: 0,
552                ..BuildRunnerConfig::default()
553            },
554        );
555        let result = runner.run_build().await;
556        assert!(!result.success);
557        assert_eq!(result.attempt, 1);
558        assert!(!result.errors.is_empty());
559    }
560}