1use std::path::{Component, Path, PathBuf};
6use std::process::{Command, ExitStatus, Stdio};
7use std::time::{Duration, Instant};
8
9#[derive(Debug, Clone)]
11pub struct ProposedEdit {
12 pub file: PathBuf,
13 pub description: String,
14 pub patch: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct ValidationResult {
21 pub passed: bool,
22 pub cargo_check_output: String,
23 pub clippy_output: String,
24 pub new_score: Option<f32>,
25 pub command_records: Vec<ValidationCommandRecord>,
26}
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct ValidationCommandRecord {
31 pub command: String,
32 pub success: bool,
33 pub timed_out: bool,
34 pub status_code: Option<i32>,
35 pub duration_ms: u64,
36 pub stdout: String,
37 pub stderr: String,
38}
39
40#[derive(Debug, Clone)]
41pub struct ValidationReport {
42 pub passed: bool,
43 pub combined_output: String,
44 pub command_records: Vec<ValidationCommandRecord>,
45}
46
47#[derive(Debug, Clone)]
49pub struct CapturedCommand {
50 pub status: Option<ExitStatus>,
51 pub stdout: String,
52 pub stderr: String,
53 pub timed_out: bool,
54 pub duration_ms: u64,
55}
56
57impl CapturedCommand {
58 pub fn success(&self) -> bool {
59 self.status.is_some_and(|status| status.success()) && !self.timed_out
60 }
61
62 pub fn combined_output(&self) -> String {
63 format!("{}{}", self.stdout, self.stderr)
64 }
65}
66
67pub fn create_isolated_workspace(agent_path: &Path, name: &str) -> anyhow::Result<PathBuf> {
70 let should_try_worktree = !agent_path.to_string_lossy().contains("/examples/")
73 && !agent_path.to_string_lossy().contains("\\examples\\");
74
75 if should_try_worktree {
76 let mut rev_parse = Command::new("git");
77 rev_parse
78 .current_dir(agent_path)
79 .args(["rev-parse", "--show-toplevel"]);
80 let is_git_repo = run_command_with_timeout(&mut rev_parse, Duration::from_secs(10))
81 .map(|output| output.success())
82 .unwrap_or(false);
83
84 if !is_git_repo {
85 return create_temp_workspace_copy(agent_path, name);
86 }
87
88 let base = agent_path.join(".worktrees");
89 std::fs::create_dir_all(&base)?;
90 let worktree_path = base.join(name);
91
92 let mut remove = Command::new("git");
93 remove.current_dir(agent_path).args([
94 "worktree",
95 "remove",
96 "--force",
97 worktree_path.to_str().unwrap(),
98 ]);
99 let _ = run_command_with_timeout(&mut remove, Duration::from_secs(20));
100
101 let mut add = Command::new("git");
102 add.current_dir(agent_path).args([
103 "worktree",
104 "add",
105 "--detach",
106 worktree_path.to_str().unwrap(),
107 "HEAD",
108 ]);
109
110 if run_command_with_timeout(&mut add, Duration::from_secs(30))
111 .map(|output| output.success())
112 .unwrap_or(false)
113 {
114 return Ok(worktree_path);
115 }
116 }
117
118 create_temp_workspace_copy(agent_path, name)
119}
120
121fn create_temp_workspace_copy(agent_path: &Path, name: &str) -> anyhow::Result<PathBuf> {
122 let isolated_parent = tempfile::Builder::new()
124 .prefix("mdx-rust-workspace-")
125 .tempdir()?
126 .keep();
127 let isolated_path = isolated_parent.join(name);
128
129 copy_dir_all_excluding(
131 agent_path,
132 &isolated_path,
133 &[".git", ".worktrees", "target", ".mdx-rust"],
134 )?;
135
136 let mut init = Command::new("git");
138 init.current_dir(&isolated_path).args(["init", "-q"]);
139 let _ = run_command_with_timeout(&mut init, Duration::from_secs(20));
140 let mut add = Command::new("git");
141 add.current_dir(&isolated_path).args(["add", "-A"]);
142 let _ = run_command_with_timeout(&mut add, Duration::from_secs(20));
143 let mut commit = Command::new("git");
144 commit
145 .current_dir(&isolated_path)
146 .args(["commit", "-q", "-m", "mdx-rust isolated copy"]);
147 let _ = run_command_with_timeout(&mut commit, Duration::from_secs(20));
148
149 Ok(isolated_path)
150}
151
152pub(crate) fn copy_dir_all_excluding(
153 src: &Path,
154 dst: &Path,
155 exclude: &[&str],
156) -> std::io::Result<()> {
157 std::fs::create_dir_all(dst)?;
158 for entry in std::fs::read_dir(src)? {
159 let entry = entry?;
160 let name = entry.file_name();
161 let name_str = name.to_string_lossy();
162
163 if exclude.iter().any(|e| name_str == *e) {
164 continue;
165 }
166
167 let ty = entry.file_type()?;
168 let src_path = entry.path();
169 let dst_path = dst.join(name);
170
171 if ty.is_dir() {
172 copy_dir_all_excluding(&src_path, &dst_path, exclude)?;
173 } else {
174 std::fs::copy(&src_path, &dst_path)?;
175 }
176 }
177 Ok(())
178}
179
180pub fn apply_patch(dir: &Path, patch: &str) -> anyhow::Result<()> {
188 apply_patch_with_target(dir, None, patch)
189}
190
191pub fn apply_edit(
197 agent_root: &Path,
198 workspace_root: &Path,
199 edit: &ProposedEdit,
200) -> anyhow::Result<()> {
201 let rel = relative_edit_path(agent_root, &edit.file)?;
202 apply_patch_with_target(workspace_root, Some(&rel), &edit.patch)
203}
204
205pub fn apply_edit_to_agent(agent_root: &Path, edit: &ProposedEdit) -> anyhow::Result<()> {
206 apply_edit(agent_root, agent_root, edit)
207}
208
209fn apply_patch_with_target(dir: &Path, target: Option<&Path>, patch: &str) -> anyhow::Result<()> {
210 let patch_file = dir.join(".mdx_patch.diff");
213 let _ = std::fs::write(&patch_file, patch);
214
215 let mut git_apply = Command::new("git");
216 git_apply
217 .current_dir(dir)
218 .args(["apply", "--whitespace=fix", patch_file.to_str().unwrap()]);
219
220 let apply_ok = run_command_with_timeout(&mut git_apply, Duration::from_secs(30))
221 .map(|output| output.success())
222 .unwrap_or(false);
223
224 let _ = std::fs::remove_file(&patch_file);
225
226 if apply_ok {
227 return Ok(());
228 }
229
230 let candidates: Vec<PathBuf> = if let Some(target) = target {
233 vec![target.to_path_buf()]
234 } else {
235 ["src/main.rs", "main.rs", "lib.rs", "agent.rs"]
236 .into_iter()
237 .map(PathBuf::from)
238 .collect()
239 };
240
241 for rel in &candidates {
242 let target_path = dir.join(rel);
243 if !target_path.exists() {
244 continue;
245 }
246
247 let content = std::fs::read_to_string(&target_path)?;
248 if patch.contains("Best-effort answer after reasoning") {
249 let new_content = content
250 .replace("Echo: {}", "Best-effort answer after reasoning: {}")
251 .replace("Echo: ", "Best-effort answer after reasoning: ");
252 if new_content != content {
253 std::fs::write(&target_path, new_content)?;
254 return Ok(());
255 }
256 }
257
258 let improved = if patch.contains("Think step-by-step before answering") {
259 "You are a concise, helpful assistant. Think step-by-step before answering. Always explain your reasoning in one sentence, then give the final answer."
260 } else if patch.contains("reasoning") {
261 "You are a concise, helpful assistant. Think step-by-step before answering."
262 } else {
263 continue;
264 };
265
266 let new_content = if let Some(start) = content.find(".preamble(\"") {
267 let prefix = &content[..start + 11];
268 let rest = &content[start + 11..];
269 if let Some(end) = rest.find("\"") {
270 format!("{}{}{}", prefix, improved, &rest[end..])
271 } else {
272 content.clone()
273 }
274 } else if content.contains("concise, helpful assistant") {
275 content.replace(
276 "concise, helpful assistant",
277 &improved.replace("You are a ", ""),
278 )
279 } else {
280 content.clone()
281 };
282
283 if new_content != content {
284 std::fs::write(&target_path, new_content)?;
285 return Ok(());
286 }
287 }
288
289 Err(anyhow::anyhow!(
290 "apply_patch could not apply the edit (neither git apply nor fallback succeeded)"
291 ))
292}
293
294fn relative_edit_path(agent_root: &Path, file: &Path) -> anyhow::Result<PathBuf> {
295 let rel = if file.is_absolute() {
296 file.strip_prefix(agent_root)
297 .map_err(|_| {
298 anyhow::anyhow!("edit target is outside the agent root: {}", file.display())
299 })?
300 .to_path_buf()
301 } else {
302 file.to_path_buf()
303 };
304
305 if rel.components().any(|component| {
306 matches!(
307 component,
308 Component::ParentDir | Component::RootDir | Component::Prefix(_)
309 )
310 }) {
311 anyhow::bail!(
312 "edit target contains unsafe path components: {}",
313 rel.display()
314 );
315 }
316
317 Ok(rel)
318}
319
320pub fn validate_build(dir: &Path) -> (bool, String) {
324 let report = validate_build_detailed(dir);
325 (report.passed, report.combined_output)
326}
327
328pub fn validate_build_detailed(dir: &Path) -> ValidationReport {
329 validate_build_detailed_with_budget(dir, Duration::from_secs(180))
330}
331
332pub fn validate_build_detailed_with_budget(dir: &Path, budget: Duration) -> ValidationReport {
333 let started = Instant::now();
334
335 fn run_cargo_with_timeout(
336 dir: &Path,
337 args: &[&str],
338 timeout: Duration,
339 ) -> Option<CapturedCommand> {
340 let mut command = Command::new("cargo");
341 command.current_dir(dir).args(args);
342 run_command_with_timeout(&mut command, timeout)
343 }
344
345 let mut output = String::new();
346 let mut success = true;
347 let mut command_records = Vec::new();
348
349 for (label, args) in [
350 ("cargo check", &["check"][..]),
351 (
352 "cargo clippy -- -D warnings",
353 &["clippy", "--", "-D", "warnings"][..],
354 ),
355 ] {
356 let Some(remaining) = budget.checked_sub(started.elapsed()) else {
357 output.push_str(&format!("[{label} skipped: validation budget exhausted]\n"));
358 success = false;
359 command_records.push(ValidationCommandRecord {
360 command: label.to_string(),
361 success: false,
362 timed_out: true,
363 status_code: None,
364 duration_ms: started.elapsed().as_millis() as u64,
365 stdout: String::new(),
366 stderr: "validation budget exhausted before command started".to_string(),
367 });
368 continue;
369 };
370
371 if remaining.is_zero() {
372 output.push_str(&format!("[{label} skipped: validation budget exhausted]\n"));
373 success = false;
374 command_records.push(ValidationCommandRecord {
375 command: label.to_string(),
376 success: false,
377 timed_out: true,
378 status_code: None,
379 duration_ms: started.elapsed().as_millis() as u64,
380 stdout: String::new(),
381 stderr: "validation budget exhausted before command started".to_string(),
382 });
383 continue;
384 }
385
386 if let Some(result) = run_cargo_with_timeout(dir, args, remaining) {
387 output.push_str(&result.combined_output());
388 if !result.success() {
389 success = false;
390 }
391 command_records.push(ValidationCommandRecord {
392 command: label.to_string(),
393 success: result.success(),
394 timed_out: result.timed_out,
395 status_code: result.status.and_then(|status| status.code()),
396 duration_ms: result.duration_ms,
397 stdout: result.stdout,
398 stderr: result.stderr,
399 });
400 } else {
401 output.push_str(&format!("[{label} failed to start]\n"));
402 success = false;
403 command_records.push(ValidationCommandRecord {
404 command: label.to_string(),
405 success: false,
406 timed_out: false,
407 status_code: None,
408 duration_ms: 0,
409 stdout: String::new(),
410 stderr: "failed to start validation command".to_string(),
411 });
412 }
413 }
414
415 ValidationReport {
416 passed: success,
417 combined_output: output,
418 command_records,
419 }
420}
421
422pub fn run_command_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<CapturedCommand> {
424 configure_process_group(cmd);
425
426 let mut child = match cmd
427 .stdin(Stdio::null())
428 .stdout(Stdio::piped())
429 .stderr(Stdio::piped())
430 .spawn()
431 {
432 Ok(c) => c,
433 Err(_) => return None,
434 };
435
436 let start = Instant::now();
437 loop {
438 match child.try_wait() {
439 Ok(Some(_)) => {
440 let duration_ms = start.elapsed().as_millis() as u64;
441 let output = child.wait_with_output().ok()?;
442 return Some(CapturedCommand {
443 status: Some(output.status),
444 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
445 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
446 timed_out: false,
447 duration_ms,
448 });
449 }
450 Ok(None) if start.elapsed() >= timeout => {
451 terminate_process_group(child.id());
452 let _ = child.kill();
453 let duration_ms = start.elapsed().as_millis() as u64;
454 let output = child.wait_with_output().ok()?;
455 return Some(CapturedCommand {
456 status: Some(output.status),
457 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
458 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
459 timed_out: true,
460 duration_ms,
461 });
462 }
463 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
464 Err(_) => {
465 terminate_process_group(child.id());
466 let _ = child.kill();
467 let _ = child.wait();
468 return None;
469 }
470 }
471 }
472}
473
474#[cfg(unix)]
475fn configure_process_group(cmd: &mut Command) {
476 use std::os::unix::process::CommandExt;
477 cmd.process_group(0);
478}
479
480#[cfg(not(unix))]
481fn configure_process_group(_cmd: &mut Command) {}
482
483#[cfg(unix)]
484fn terminate_process_group(pid: u32) {
485 let group = format!("-{pid}");
486 for signal in ["-TERM", "-KILL"] {
487 let _ = Command::new("kill")
488 .arg(signal)
489 .arg(&group)
490 .stdin(Stdio::null())
491 .stdout(Stdio::null())
492 .stderr(Stdio::null())
493 .status();
494 std::thread::sleep(Duration::from_millis(50));
495 }
496}
497
498#[cfg(not(unix))]
499fn terminate_process_group(_pid: u32) {}
500
501#[derive(Debug)]
502pub struct FileSnapshot {
503 path: PathBuf,
504 content: Option<Vec<u8>>,
505}
506
507pub fn snapshot_file(path: &Path) -> anyhow::Result<FileSnapshot> {
508 let content = if path.exists() {
509 Some(std::fs::read(path)?)
510 } else {
511 None
512 };
513
514 Ok(FileSnapshot {
515 path: path.to_path_buf(),
516 content,
517 })
518}
519
520pub fn restore_file(snapshot: &FileSnapshot) -> anyhow::Result<()> {
521 if let Some(parent) = snapshot.path.parent() {
522 std::fs::create_dir_all(parent)?;
523 }
524
525 match &snapshot.content {
526 Some(content) => std::fs::write(&snapshot.path, content)?,
527 None if snapshot.path.exists() => std::fs::remove_file(&snapshot.path)?,
528 None => {}
529 }
530
531 Ok(())
532}
533
534#[derive(Debug)]
535pub struct TransactionSnapshot {
536 files: Vec<FileSnapshot>,
537}
538
539pub fn snapshot_transaction(paths: &[PathBuf]) -> anyhow::Result<TransactionSnapshot> {
540 let mut files = Vec::with_capacity(paths.len());
541 for path in paths {
542 files.push(snapshot_file(path)?);
543 }
544 Ok(TransactionSnapshot { files })
545}
546
547pub fn restore_transaction(snapshot: &TransactionSnapshot) -> anyhow::Result<()> {
548 for file in snapshot.files.iter().rev() {
549 restore_file(file)?;
550 }
551 Ok(())
552}
553
554pub fn apply_and_validate(
558 agent_path: &Path,
559 edit: &ProposedEdit,
560 name: &str,
561) -> anyhow::Result<ValidationResult> {
562 apply_and_validate_with_budget(agent_path, edit, name, Duration::from_secs(180))
563}
564
565pub fn apply_and_validate_with_budget(
566 agent_path: &Path,
567 edit: &ProposedEdit,
568 name: &str,
569 validation_budget: Duration,
570) -> anyhow::Result<ValidationResult> {
571 let isolated = create_isolated_workspace(agent_path, name)?;
572 apply_edit(agent_path, &isolated, edit)?;
573
574 let report = validate_build_detailed_with_budget(&isolated, validation_budget);
575
576 cleanup_isolated_workspace(agent_path, &isolated);
577
578 Ok(ValidationResult {
579 passed: report.passed,
580 cargo_check_output: report.combined_output,
581 clippy_output: String::new(),
582 new_score: None,
583 command_records: report.command_records,
584 })
585}
586
587pub fn cleanup_isolated_workspace(agent_path: &Path, isolated: &Path) {
588 if isolated
589 .parent()
590 .is_some_and(|p| p.file_name() == Some(std::ffi::OsStr::new(".worktrees")))
591 {
592 let mut remove = Command::new("git");
594 remove.current_dir(agent_path).args([
595 "worktree",
596 "remove",
597 "--force",
598 isolated.to_str().unwrap(),
599 ]);
600 let _ = run_command_with_timeout(&mut remove, Duration::from_secs(20));
601 } else if let Some(parent) = isolated.parent() {
602 if parent
603 .file_name()
604 .is_some_and(|name| name.to_string_lossy().starts_with("mdx-rust-workspace-"))
605 {
606 let _ = std::fs::remove_dir_all(parent);
607 } else {
608 let _ = std::fs::remove_dir_all(isolated);
609 }
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::fs;
617 use std::process::Command;
618 use std::time::{Duration, Instant};
619 use tempfile::tempdir;
620
621 #[test]
622 fn copy_dir_all_excluding_prevents_recursion_into_worktrees_and_target() {
623 let src = tempdir().unwrap();
624 let src_path = src.path();
625
626 fs::create_dir_all(src_path.join("src")).unwrap();
628 fs::write(src_path.join("src/main.rs"), "fn main() {}").unwrap();
629 fs::write(
630 src_path.join("Cargo.toml"),
631 "[package]\nname=\"t\"\nversion=\"0.1\"",
632 )
633 .unwrap();
634
635 fs::create_dir_all(src_path.join(".worktrees").join("some-worktree")).unwrap();
637 fs::write(src_path.join(".worktrees/some-worktree/evil.rs"), "BAD").unwrap();
638
639 fs::create_dir_all(src_path.join("target").join("debug")).unwrap();
640 fs::write(src_path.join("target/debug/bad.o"), "binary").unwrap();
641
642 fs::create_dir_all(src_path.join(".git")).unwrap();
643 fs::write(src_path.join(".git/config"), "git").unwrap();
644
645 let dst = tempdir().unwrap();
646 let dst_path = dst.path().join("copy");
647
648 copy_dir_all_excluding(
649 src_path,
650 &dst_path,
651 &[".git", ".worktrees", "target", ".mdx-rust"],
652 )
653 .unwrap();
654
655 assert!(
657 dst_path.join("src/main.rs").exists(),
658 "normal source must be copied"
659 );
660 assert!(
661 !dst_path.join(".worktrees").exists(),
662 ".worktrees must be excluded (no recursion)"
663 );
664 assert!(!dst_path.join("target").exists(), "target must be excluded");
665 assert!(!dst_path.join(".git").exists(), ".git must be excluded");
666 }
667
668 #[test]
669 fn temp_workspace_for_non_git_repo_does_not_create_source_worktrees_dir() {
670 let src = tempdir().unwrap();
671 fs::create_dir_all(src.path().join("src")).unwrap();
672 fs::write(src.path().join("src/main.rs"), "fn main() {}").unwrap();
673 fs::write(
674 src.path().join("Cargo.toml"),
675 "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"",
676 )
677 .unwrap();
678
679 let isolated = create_isolated_workspace(src.path(), "no-git").unwrap();
680 assert!(isolated.exists());
681 assert!(
682 !src.path().join(".worktrees").exists(),
683 "temp-copy fallback must not mutate the source tree"
684 );
685 cleanup_isolated_workspace(src.path(), &isolated);
686 }
687
688 #[test]
689 fn apply_edit_fallback_only_changes_requested_file() {
690 let root = tempdir().unwrap();
691 let src = root.path().join("src");
692 fs::create_dir_all(&src).unwrap();
693
694 let main = src.join("main.rs");
695 let agent = src.join("agent.rs");
696 let weak =
697 r#"client.agent("m").preamble("You are a concise, helpful assistant.").build();"#;
698 fs::write(&main, weak).unwrap();
699 fs::write(&agent, weak).unwrap();
700
701 let edit = ProposedEdit {
702 file: agent.clone(),
703 description: "strengthen prompt".to_string(),
704 patch: "not a real diff, but Think step-by-step before answering".to_string(),
705 };
706
707 apply_edit(root.path(), root.path(), &edit).unwrap();
708
709 let main_after = fs::read_to_string(main).unwrap();
710 let agent_after = fs::read_to_string(agent).unwrap();
711
712 assert!(
713 !main_after.contains("Think step-by-step"),
714 "fallback must not drift into unrelated files"
715 );
716 assert!(
717 agent_after.contains("Think step-by-step"),
718 "requested edit target should be changed"
719 );
720 }
721
722 #[test]
723 fn apply_edit_fallback_can_replace_echo_response_prefix() {
724 let root = tempdir().unwrap();
725 let src = root.path().join("src");
726 fs::create_dir_all(&src).unwrap();
727
728 let main = src.join("main.rs");
729 fs::write(
730 &main,
731 r#"fn main() { println!("{}", format!("Echo: {}", "hello")); }"#,
732 )
733 .unwrap();
734
735 let edit = ProposedEdit {
736 file: main.clone(),
737 description: "replace echo fallback".to_string(),
738 patch: "not a real diff, but Best-effort answer after reasoning".to_string(),
739 };
740
741 apply_edit(root.path(), root.path(), &edit).unwrap();
742
743 let main_after = fs::read_to_string(main).unwrap();
744 assert!(main_after.contains("Best-effort answer after reasoning"));
745 assert!(!main_after.contains("Echo:"));
746 }
747
748 #[test]
749 fn snapshot_restore_puts_file_back() {
750 let root = tempdir().unwrap();
751 let file = root.path().join("src/main.rs");
752 fs::create_dir_all(file.parent().unwrap()).unwrap();
753 fs::write(&file, "before").unwrap();
754
755 let snapshot = snapshot_file(&file).unwrap();
756 fs::write(&file, "after").unwrap();
757 restore_file(&snapshot).unwrap();
758
759 assert_eq!(fs::read_to_string(file).unwrap(), "before");
760 }
761
762 #[test]
763 fn transaction_restore_rolls_back_multiple_files() {
764 let root = tempdir().unwrap();
765 let first = root.path().join("src/main.rs");
766 let second = root.path().join("src/lib.rs");
767 fs::create_dir_all(first.parent().unwrap()).unwrap();
768 fs::write(&first, "first-before").unwrap();
769 fs::write(&second, "second-before").unwrap();
770
771 let snapshot = snapshot_transaction(&[first.clone(), second.clone()]).unwrap();
772 fs::write(&first, "first-after").unwrap();
773 fs::write(&second, "second-after").unwrap();
774
775 restore_transaction(&snapshot).unwrap();
776
777 assert_eq!(fs::read_to_string(first).unwrap(), "first-before");
778 assert_eq!(fs::read_to_string(second).unwrap(), "second-before");
779 }
780
781 #[test]
782 fn command_timeout_kills_and_captures_without_leaking() {
783 let start = Instant::now();
784 let mut command = Command::new("sh");
785 command
786 .arg("-c")
787 .arg("printf noisy-output; while true; do :; done");
788
789 let result = run_command_with_timeout(&mut command, Duration::from_millis(100)).unwrap();
790
791 assert!(result.timed_out);
792 assert!(start.elapsed() < Duration::from_secs(2));
793 assert_eq!(result.stdout, "noisy-output");
794 assert!(result.duration_ms > 0);
795 }
796
797 #[test]
798 fn validate_build_records_command_outcomes() {
799 let src = tempdir().unwrap();
800 fs::create_dir_all(src.path().join("src")).unwrap();
801 fs::write(src.path().join("src/main.rs"), "fn main() {}").unwrap();
802 fs::write(
803 src.path().join("Cargo.toml"),
804 "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"",
805 )
806 .unwrap();
807
808 let report = validate_build_detailed(src.path());
809
810 assert!(report.passed);
811 assert_eq!(report.command_records.len(), 2);
812 assert!(report
813 .command_records
814 .iter()
815 .all(|record| record.duration_ms > 0));
816 }
817
818 #[test]
819 fn validate_build_budget_exhaustion_records_timeout() {
820 let src = tempdir().unwrap();
821 fs::create_dir_all(src.path().join("src")).unwrap();
822 fs::write(src.path().join("src/main.rs"), "fn main() {}").unwrap();
823 fs::write(
824 src.path().join("Cargo.toml"),
825 "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"",
826 )
827 .unwrap();
828
829 let report = validate_build_detailed_with_budget(src.path(), Duration::from_secs(0));
830
831 assert!(!report.passed);
832 assert_eq!(report.command_records.len(), 2);
833 assert!(report.command_records.iter().all(|record| record.timed_out));
834 }
835}