Skip to main content

mdx_rust_analysis/
editing.rs

1//! Safe code editing and validation pipeline (Phase 2+).
2//
3//! This module now has real (early) support for git worktrees + patch application + validation.
4
5use std::path::{Component, Path, PathBuf};
6use std::process::{Command, ExitStatus, Stdio};
7use std::time::{Duration, Instant};
8
9/// A proposed change to the agent's source code.
10#[derive(Debug, Clone)]
11pub struct ProposedEdit {
12    pub file: PathBuf,
13    pub description: String,
14    /// Unified diff (for now)
15    pub patch: String,
16}
17
18/// Result of validating a proposed edit in a worktree.
19#[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/// Auditable record for a validation command.
29#[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/// Captured command execution result.
48#[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
67/// Create a git worktree for safe experimentation (best when agent_path is a git repo root).
68/// Falls back to a filesystem copy if worktree creation fails (e.g. agent lives inside another repo).
69pub fn create_isolated_workspace(agent_path: &Path, name: &str) -> anyhow::Result<PathBuf> {
70    // Try git worktree first (fast, shares objects, real git history)
71    // But skip it if the agent lives deep inside another repo (common for examples/ in monorepos)
72    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    // Fallback: proper temp directory copy outside the source tree (prevents recursion and .worktrees self-copy)
123    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    // Use improved copy that excludes dangerous dirs
130    copy_dir_all_excluding(
131        agent_path,
132        &isolated_path,
133        &[".git", ".worktrees", "target", ".mdx-rust"],
134    )?;
135
136    // Init git in the copy for cargo/git commands
137    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
180/// Apply the proposed patch inside an isolated directory.
181/// Strategy:
182///
183/// - Try real `git apply` (best when the patch was generated with context).
184/// - Fall back to smart string replacement for the common Rig preamble/tool cases.
185///
186/// This keeps the system reliable even when perfect unified diffs are hard to generate.
187pub fn apply_patch(dir: &Path, patch: &str) -> anyhow::Result<()> {
188    apply_patch_with_target(dir, None, patch)
189}
190
191/// Apply a proposed edit to an isolated workspace or the real agent tree.
192///
193/// The edit's `file` field is authoritative for fallback edits. The unified
194/// diff is attempted first, but string-based fallback is constrained to the
195/// resolved target file so a patch can never drift into an unrelated source.
196pub 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    // First attempt: real git apply (respects the patch the optimizer generated)
211    // Protected by timeout so a stuck git process cannot hang the optimizer (P0).
212    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    // Fallback: targeted smart edit for the things we commonly optimize.
231    // In real edit application, this is constrained to ProposedEdit.file.
232    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
320/// Run cargo check + clippy in a directory with timeout.
321/// Returns (success, combined output).
322/// A hanging or extremely slow cargo command must fail the validation instead of hanging the optimizer (P0).
323pub 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
422/// Run a Command with a timeout. Returns None on timeout (treated as failure by callers).
423pub 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
554/// High-level helper: take a ProposedEdit, create an isolated workspace (git worktree or copy),
555/// apply the edit, run cargo check + clippy, then clean up.
556/// This is the core safety primitive of mdx-rust.
557pub 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        // Only try git worktree remove if it looks like a real worktree dir
593        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        // Create normal source
627        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        // Create dangerous dirs that must be excluded
636        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        // Assertions: dangerous content must not be present
656        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}