albatross-cli 2.0.3

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 super::{diff::unified_diff, PathPolicy, Tool, ToolPreview};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};

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

#[derive(Deserialize)]
struct Args {
    path: String,
    content: String,
}

#[async_trait]
impl Tool for FileWriteTool {
    fn name(&self) -> &'static str {
        "file_write"
    }
    fn description(&self) -> &'static str {
        "Write content to a file. Creates parent directories if needed. Overwrites if the file exists."
    }
    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Absolute or relative path to the file" },
                "content": { "type": "string", "description": "Full content to write" }
            },
            "required": ["path", "content"]
        })
    }
    fn require_approval(&self, _args: &Value) -> bool {
        self.approve
            || _args
                .get("path")
                .and_then(Value::as_str)
                .map(|p| self.path_policy.require_prompt_for_path(p))
                .unwrap_or(false)
    }
    async fn preview(&self, args: &Value) -> Option<ToolPreview> {
        let args: Args = serde_json::from_value(args.clone()).ok()?;
        let resolved = self.path_policy.resolve(&args.path);
        let old = tokio::fs::read_to_string(&resolved.normalized)
            .await
            .unwrap_or_default();
        let mut risk = None;
        if resolved.outside_workspace {
            risk = Some(format!(
                "outside workspace root {}",
                self.path_policy.root().display()
            ));
        }
        let path = resolved.normalized.display().to_string();
        Some(ToolPreview {
            summary: format!("Write {path} ({} bytes)", args.content.len()),
            diff: Some(unified_diff(&old, &args.content, &path)),
            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 let Some(error) = self.path_policy.deny_path(&args.path) {
            return json!({ "error": error });
        }
        let resolved = self.path_policy.resolve(&args.path);
        let path = resolved.normalized.display().to_string();
        if let Some(parent) = resolved.normalized.parent() {
            if !parent.as_os_str().is_empty() {
                if let Err(e) = tokio::fs::create_dir_all(parent).await {
                    return json!({ "error": e.to_string() });
                }
            }
        }
        match tokio::fs::write(&resolved.normalized, args.content.as_bytes()).await {
            Ok(_) => json!({
                "written": true,
                "path": path,
                "bytes": args.content.len(),
            }),
            Err(e) => json!({ "error": e.to_string() }),
        }
    }
}

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

    #[tokio::test]
    async fn writes_file_to_existing_dir() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("out.txt");
        let result = FileWriteTool {
            approve: false,
            path_policy: PathPolicy::default(),
        }
        .execute(json!({
            "path": path.to_str().unwrap(),
            "content": "hello"
        }))
        .await;
        assert!(result["written"].as_bool().unwrap());
        assert_eq!(result["bytes"].as_u64().unwrap(), 5);
        let read = tokio::fs::read_to_string(&path).await.unwrap();
        assert_eq!(read, "hello");
    }

    #[tokio::test]
    async fn writes_file_creating_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("a/b/c/file.txt");
        let result = FileWriteTool {
            approve: false,
            path_policy: PathPolicy::default(),
        }
        .execute(json!({
            "path": path.to_str().unwrap(),
            "content": "deep"
        }))
        .await;
        assert!(result["written"].as_bool().unwrap());
        let read = tokio::fs::read_to_string(&path).await.unwrap();
        assert_eq!(read, "deep");
    }

    #[tokio::test]
    async fn write_overwrites_existing_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("o.txt");
        tokio::fs::write(&path, "old contents").await.unwrap();
        let _ = FileWriteTool {
            approve: false,
            path_policy: PathPolicy::default(),
        }
        .execute(json!({
            "path": path.to_str().unwrap(),
            "content": "new"
        }))
        .await;
        let read = tokio::fs::read_to_string(&path).await.unwrap();
        assert_eq!(read, "new");
    }

    #[test]
    fn approval_field_drives_require_approval() {
        let t1 = FileWriteTool {
            approve: true,
            path_policy: PathPolicy::default(),
        };
        let t2 = FileWriteTool {
            approve: false,
            path_policy: PathPolicy::default(),
        };
        let v = json!({});
        assert!(t1.require_approval(&v));
        assert!(!t2.require_approval(&v));
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn deny_policy_rejects_write_through_workspace_symlink() {
        use crate::config::OutsideWorkspace;
        use std::os::unix::fs::symlink;

        let workspace = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        symlink(outside.path(), workspace.path().join("link")).unwrap();
        let result = FileWriteTool {
            approve: false,
            path_policy: PathPolicy::new(
                workspace.path().to_str().unwrap(),
                OutsideWorkspace::Deny,
            ),
        }
        .execute(json!({ "path": "link/secret.txt", "content": "leak" }))
        .await;
        assert!(result.get("error").is_some());
        assert!(!outside.path().join("secret.txt").exists());
    }
}