albatross-cli 2.1.0

Open source, terminal-first AI coding agent with fully transparent multi-model routing. Local (Ollama, LM Studio, MLX, llama.cpp) or cloud, your keys, one TUI.
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::process::Stdio;
use tokio::io::AsyncWriteExt;

use super::{PathPolicy, Tool, ToolPreview};

pub struct ApplyPatchTool {
    pub approve: bool,
    pub path_policy: PathPolicy,
}

#[derive(Deserialize)]
struct Args {
    patch: String,
    #[serde(default)]
    path: Option<String>,
}

pub fn patch_changed_files(patch: &str) -> Vec<String> {
    changed_files(patch)
}

fn changed_files(patch: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in patch.lines() {
        let Some(path) = line
            .strip_prefix("+++ b/")
            .or_else(|| line.strip_prefix("--- a/"))
        else {
            continue;
        };
        if path != "/dev/null" && !out.iter().any(|p| p == path) {
            out.push(path.to_string());
        }
    }
    out
}

fn patch_has_unsafe_paths(patch: &str) -> bool {
    changed_files(patch).into_iter().any(|path| {
        path.starts_with('/')
            || path
                .split('/')
                .any(|segment| segment == ".." || segment.is_empty())
    })
}

async fn git_apply(cwd: &std::path::Path, patch: &str, check_only: bool) -> Result<String, String> {
    let mut cmd = tokio::process::Command::new("git");
    cmd.current_dir(cwd)
        .arg("apply")
        .arg("--whitespace=nowarn")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if check_only {
        cmd.arg("--check");
    }
    let mut child = cmd.spawn().map_err(|e| e.to_string())?;
    if let Some(mut stdin) = child.stdin.take() {
        stdin
            .write_all(patch.as_bytes())
            .await
            .map_err(|e| e.to_string())?;
    }
    let output = child.wait_with_output().await.map_err(|e| e.to_string())?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
    } else {
        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
    }
}

#[async_trait]
impl Tool for ApplyPatchTool {
    fn name(&self) -> &'static str {
        "apply_patch"
    }

    fn description(&self) -> &'static str {
        "Apply a unified diff patch after validating it with `git apply --check`."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "patch": { "type": "string", "description": "Unified diff text to apply" },
                "path": { "type": "string", "description": "Directory to apply the patch in (default: cwd)" }
            },
            "required": ["patch"]
        })
    }

    fn require_approval(&self, args: &Value) -> bool {
        let path_prompt = args
            .get("path")
            .and_then(Value::as_str)
            .map(|p| self.path_policy.require_prompt_for_path(p))
            .unwrap_or_else(|| self.path_policy.require_prompt_for_cwd());
        self.approve || path_prompt
    }

    async fn preview(&self, args: &Value) -> Option<ToolPreview> {
        let args: Args = serde_json::from_value(args.clone()).ok()?;
        let files = changed_files(&args.patch);
        let mut risk = None;
        if patch_has_unsafe_paths(&args.patch) {
            risk = Some("patch contains unsafe absolute, empty, or parent path segments".into());
        } else if let Some(path) = args.path.as_deref() {
            if self.path_policy.resolve(path).outside_workspace {
                risk = Some(format!(
                    "outside workspace root {}",
                    self.path_policy.root().display()
                ));
            }
        }
        Some(ToolPreview {
            summary: format!("Apply patch touching {} file(s)", files.len()),
            diff: Some(args.patch),
            risk,
        })
    }

    async fn execute(&self, args: Value) -> Value {
        let args: Args = match serde_json::from_value(args) {
            Ok(a) => a,
            Err(e) => return json!({ "error": format!("invalid args: {e}") }),
        };
        if patch_has_unsafe_paths(&args.patch) {
            return json!({ "error": "patch contains unsafe absolute, empty, or parent path segments" });
        }
        let requested = args.path.unwrap_or_else(|| ".".into());
        if let Some(error) = self.path_policy.deny_path(&requested) {
            return json!({ "error": error });
        }
        let cwd = self.path_policy.resolve(&requested).normalized;
        let files = changed_files(&args.patch);
        if let Err(e) = git_apply(&cwd, &args.patch, true).await {
            return json!({ "applied": false, "error": e, "files": files });
        }
        match git_apply(&cwd, &args.patch, false).await {
            Ok(_) => json!({
                "applied": true,
                "path": cwd.display().to_string(),
                "files": files,
                "diff": args.patch,
            }),
            Err(e) => json!({ "applied": false, "error": e, "files": files }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::OutsideWorkspace;

    #[test]
    fn extracts_changed_files() {
        let patch = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
        assert_eq!(changed_files(patch), vec!["a.txt".to_string()]);
    }

    #[test]
    fn rejects_parent_paths() {
        let patch = "--- a/../x\n+++ b/../x\n";
        assert!(patch_has_unsafe_paths(patch));
    }

    #[tokio::test]
    async fn applies_valid_patch() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("a.txt");
        tokio::fs::write(&file, "old\n").await.unwrap();
        let patch = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
        let tool = ApplyPatchTool {
            approve: false,
            path_policy: PathPolicy::new(dir.path().to_str().unwrap(), OutsideWorkspace::Allow),
        };
        let result = tool
            .execute(json!({ "path": dir.path().to_str().unwrap(), "patch": patch }))
            .await;
        assert!(result["applied"].as_bool().unwrap());
        assert_eq!(tokio::fs::read_to_string(&file).await.unwrap(), "new\n");
    }
}