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