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::path::PathBuf;

use super::{Tool, ToolPreview};
use crate::batch_operations::{
    execute_batch_operations, preview_batch_operations, BatchEditOperation,
};

pub struct BatchEditTool {
    pub workspace_root: String,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Args {
    operations: Vec<BatchEditOperation>,
    #[serde(default = "default_dry_run")]
    dry_run: bool,
}

fn default_dry_run() -> bool {
    true
}

fn op_summary(operations: &[BatchEditOperation]) -> String {
    let files: Vec<&str> = operations.iter().map(|op| op.file_path.as_str()).collect();
    format!(
        "{} operation(s) across {} file(s)",
        operations.len(),
        files.len()
    )
}

#[async_trait]
impl Tool for BatchEditTool {
    fn name(&self) -> &'static str {
        "batch_edit"
    }
    fn description(&self) -> &'static str {
        "Preview or apply coordinated multi-file edits. Always returns a dry-run preview; set dry_run=false to apply after approval."
    }
    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "operations": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                        "type": "object",
                        "properties": {
                            "filePath": { "type": "string" },
                            "operation": {
                                "type": "object",
                                "description": "replace, insert, or delete operation (same schema as /batch JSON)"
                            }
                        },
                        "required": ["filePath", "operation"]
                    }
                },
                "dryRun": {
                    "type": "boolean",
                    "description": "Preview only when true (default). Apply when false."
                }
            },
            "required": ["operations"]
        })
    }
    fn require_approval(&self, args: &Value) -> bool {
        !args
            .get("dryRun")
            .or_else(|| args.get("dry_run"))
            .and_then(Value::as_bool)
            .unwrap_or(true)
    }
    async fn preview(&self, args: &Value) -> Option<ToolPreview> {
        let args: Args = serde_json::from_value(args.clone()).ok()?;
        if args.dry_run {
            return None;
        }
        let summary = op_summary(&args.operations);
        let file_list = args
            .operations
            .iter()
            .map(|op| op.file_path.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        Some(ToolPreview {
            summary: format!("Apply batch edit: {summary}"),
            diff: None,
            risk: Some(format!("Will modify: {file_list}")),
        })
    }
    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 args.operations.is_empty() {
            return json!({ "error": "operations must not be empty" });
        }
        let preview = preview_batch_operations(&args.operations);
        let workspace = PathBuf::from(&self.workspace_root);
        let validation = match execute_batch_operations(&args.operations, &workspace, true) {
            Ok(v) => v,
            Err(e) => return json!({ "error": e.to_string(), "preview": preview }),
        };
        if !validation.failed.is_empty() {
            return json!({
                "preview": preview,
                "applied": false,
                "successful": validation.successful,
                "failed": validation.failed,
            });
        }
        if args.dry_run {
            return json!({
                "preview": preview,
                "applied": false,
                "successful": validation.successful,
                "failed": validation.failed,
                "dryRun": true,
            });
        }
        match execute_batch_operations(&args.operations, &workspace, false) {
            Ok(applied) => json!({
                "preview": preview,
                "applied": true,
                "successful": applied.successful,
                "failed": applied.failed,
            }),
            Err(e) => json!({ "error": e.to_string(), "preview": preview }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn parses_tool_args_json() {
        let v = json!({
            "operations": [{
                "filePath": "hello.txt",
                "operation": { "type": "replace", "old_string": "world", "new_string": "rust" }
            }],
            "dryRun": true
        });
        let parsed: Args = serde_json::from_value(v).expect("parse tool args");
        assert_eq!(parsed.operations.len(), 1);
        assert!(parsed.dry_run);
    }

    #[tokio::test]
    async fn dry_run_preview_only() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("hello.txt");
        fs::write(&path, "hello world\n").unwrap();
        let tool = BatchEditTool {
            workspace_root: dir.path().to_str().unwrap().into(),
        };
        let out = tool
            .execute(json!({
                "operations": [{
                    "filePath": "hello.txt",
                    "operation": { "type": "replace", "old_string": "world", "new_string": "rust" }
                }],
                "dryRun": true
            }))
            .await;
        if out.get("applied").is_none() {
            panic!("unexpected batch_edit output: {out}");
        }
        assert_eq!(out.get("applied").and_then(|v| v.as_bool()), Some(false));
        assert_eq!(fs::read_to_string(&path).unwrap(), "hello world\n");
    }

    #[tokio::test]
    async fn apply_writes_changes() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("hello.txt");
        fs::write(&path, "hello world\n").unwrap();
        let tool = BatchEditTool {
            workspace_root: dir.path().to_str().unwrap().into(),
        };
        let out = tool
            .execute(json!({
                "operations": [{
                    "filePath": "hello.txt",
                    "operation": { "type": "replace", "old_string": "world", "new_string": "rust" }
                }],
                "dryRun": false
            }))
            .await;
        assert_eq!(out.get("applied").and_then(|v| v.as_bool()), Some(true));
        assert_eq!(fs::read_to_string(&path).unwrap(), "hello rust\n");
    }
}