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 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/// A proposed change to the agent's source code.
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
14pub struct ProposedEdit {
15    pub file: PathBuf,
16    pub description: String,
17    /// Unified diff (for now)
18    pub patch: String,
19}
20
21/// Result of validating a proposed edit in a worktree.
22#[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/// Auditable record for a validation command.
32#[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/// Captured command execution result.
51#[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
70/// Create a git worktree for safe experimentation (best when agent_path is a git repo root).
71/// Falls back to a filesystem copy if worktree creation fails (e.g. agent lives inside another repo).
72pub fn create_isolated_workspace(agent_path: &Path, name: &str) -> anyhow::Result<PathBuf> {
73    // Try git worktree first (fast, shares objects, real git history)
74    // But skip it if the agent lives deep inside another repo (common for examples/ in monorepos)
75    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    // Fallback: proper temp directory copy outside the source tree (prevents recursion and .worktrees self-copy)
138    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    // Use improved copy that excludes dangerous dirs
145    copy_dir_all_excluding(
146        agent_path,
147        &isolated_path,
148        &[".git", ".worktrees", "target", ".mdx-rust"],
149    )?;
150
151    // Init git in the copy for cargo/git commands
152    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
195/// Apply the proposed patch inside an isolated directory.
196/// Strategy:
197///
198/// - Try real `git apply` (best when the patch was generated with context).
199/// - Fall back to smart string replacement for the common Rig preamble/tool cases.
200///
201/// This keeps the system reliable even when perfect unified diffs are hard to generate.
202pub fn apply_patch(dir: &Path, patch: &str) -> anyhow::Result<()> {
203    apply_patch_with_target(dir, None, patch)
204}
205
206/// Apply a proposed edit to an isolated workspace or the real agent tree.
207///
208/// The edit's `file` field is authoritative for fallback edits. The unified
209/// diff is attempted first, but string-based fallback is constrained to the
210/// resolved target file so a patch can never drift into an unrelated source.
211pub 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    // First attempt: real git apply (respects the patch the optimizer generated)
226    // Protected by timeout so a stuck git process cannot hang the optimizer (P0).
227    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    // Fallback: targeted smart edit for the things we commonly optimize.
246    // In real edit application, this is constrained to ProposedEdit.file.
247    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
389/// Run cargo check + clippy in a directory with timeout.
390/// Returns (success, combined output).
391/// A hanging or extremely slow cargo command must fail the validation instead of hanging the optimizer (P0).
392pub 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
491/// Run a Command with a timeout. Returns None on timeout (treated as failure by callers).
492pub 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
623/// High-level helper: take a ProposedEdit, create an isolated workspace (git worktree or copy),
624/// apply the edit, run cargo check + clippy, then clean up.
625/// This is the core safety primitive of mdx-rust.
626pub 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        // Only try git worktree remove if it looks like a real worktree dir
662        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        // Create normal source
696        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        // Create dangerous dirs that must be excluded
705        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        // Assertions: dangerous content must not be present
725        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}