1use std::path::PathBuf;
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::tools::child_proc;
18
19const DEFAULT_MAX_RETRIES: usize = 2;
26
27const DEFAULT_TIMEOUT_SECS: u64 = 1800;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BuildRunnerConfig {
35 pub max_retries: usize,
37 pub timeout_secs: u64,
39 #[serde(default)]
42 pub extra_args: Vec<String>,
43 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CompileError {
71 pub severity: DiagnosticSeverity,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub code: Option<String>,
75 pub message: String,
77 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct BuildResult {
89 pub success: bool,
90 pub exit_code: i32,
91 pub attempt: usize,
93 pub errors: Vec<CompileError>,
94 pub warnings: Vec<CompileError>,
95 pub raw_output: String,
97 pub command: String,
99}
100
101impl BuildResult {
102 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
129pub 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 pub async fn run_build(&self) -> BuildResult {
147 self.run_with_retry("build").await
148 }
149
150 pub async fn run_test(&self) -> BuildResult {
152 self.run_with_retry("test").await
153 }
154
155 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 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 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
309fn 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
329pub 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 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
374fn parse_diagnostic_header(line: &str) -> Option<(DiagnosticSeverity, &str)> {
379 let trimmed = line.trim_start();
380 if let Some(rest) = trimmed.strip_prefix("error") {
381 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
391fn 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 let msg = rest.strip_prefix(':').unwrap_or(rest).trim_start();
405 (None, msg.to_string())
406}
407
408fn 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 if trimmed.starts_with("error") || trimmed.starts_with("warning") {
417 return (None, None, None);
418 }
419 }
420 (None, None, None)
421}
422
423fn parse_span(span: &str) -> (Option<String>, Option<u32>, Option<u32>) {
425 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}