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 super::{PathPolicy, Tool};

pub struct FileReadTool {
    pub path_policy: PathPolicy,
}

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

fn image_ext(path: &str) -> Option<&'static str> {
    let lower = path.to_lowercase();
    for ext in ["jpeg", "jpg", "png", "gif", "webp"] {
        if lower.ends_with(&format!(".{ext}")) {
            return Some(match ext {
                "jpg" | "jpeg" => "jpeg",
                other => other,
            });
        }
    }
    None
}

pub(crate) fn b64_encode(input: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
    let mut chunks = input.chunks_exact(3);
    for chunk in &mut chunks {
        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
        out.push(TABLE[((n >> 18) & 63) as usize] as char);
        out.push(TABLE[((n >> 12) & 63) as usize] as char);
        out.push(TABLE[((n >> 6) & 63) as usize] as char);
        out.push(TABLE[(n & 63) as usize] as char);
    }
    let rem = chunks.remainder();
    if !rem.is_empty() {
        let b0 = rem[0] as u32;
        let b1 = if rem.len() > 1 { rem[1] as u32 } else { 0 };
        let n = (b0 << 16) | (b1 << 8);
        out.push(TABLE[((n >> 18) & 63) as usize] as char);
        out.push(TABLE[((n >> 12) & 63) as usize] as char);
        if rem.len() > 1 {
            out.push(TABLE[((n >> 6) & 63) as usize] as char);
        } else {
            out.push('=');
        }
        out.push('=');
    }
    out
}

#[async_trait]
impl Tool for FileReadTool {
    fn name(&self) -> &'static str {
        "file_read"
    }
    fn description(&self) -> &'static str {
        "Read the contents of a file at the given path. Returns text or, for image files, base64 with mime type."
    }
    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Absolute or relative path to the file" },
                "offset": { "type": "integer", "minimum": 1, "description": "Start reading from this line (1-indexed)" },
                "limit": { "type": "integer", "minimum": 1, "description": "Maximum number of lines to return" }
            },
            "required": ["path"]
        })
    }
    fn require_approval(&self, args: &Value) -> bool {
        args.get("path")
            .and_then(Value::as_str)
            .map(|p| self.path_policy.require_prompt_for_path(p))
            .unwrap_or(false)
    }
    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(ext) = image_ext(&path) {
            return match tokio::fs::read(&resolved.normalized).await {
                Ok(bytes) => json!({
                    "type": "image",
                    "mimeType": format!("image/{ext}"),
                    "data": b64_encode(&bytes),
                }),
                Err(e) => map_err(&path, e),
            };
        }
        let content = match tokio::fs::read_to_string(&resolved.normalized).await {
            Ok(c) => c,
            Err(e) => return map_err(&path, e),
        };
        let lines: Vec<&str> = content.split('\n').collect();
        let total = lines.len();
        if let Some(o) = args.offset {
            if o > total {
                return json!({
                    "error": format!("offset {o} is past end of file ({total} lines)")
                });
            }
        }
        let start = args
            .offset
            .map(|o| o.saturating_sub(1))
            .unwrap_or(0)
            .min(total);
        let end = args.limit.map(|l| start + l).unwrap_or(total).min(total);
        let slice = &lines[start..end];
        let mut obj = serde_json::Map::new();
        obj.insert("content".into(), json!(slice.join("\n")));
        obj.insert("totalLines".into(), json!(total));
        if end < total {
            obj.insert("truncated".into(), json!(true));
            obj.insert("nextOffset".into(), json!(end + 1));
        }
        Value::Object(obj)
    }
}

fn map_err(path: &str, e: std::io::Error) -> Value {
    use std::io::ErrorKind::*;
    match e.kind() {
        NotFound => json!({ "error": format!("File not found: {path}") }),
        PermissionDenied => json!({ "error": format!("Permission denied: {path}") }),
        _ => json!({ "error": e.to_string() }),
    }
}

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

    #[test]
    fn b64_rfc4648_vectors() {
        assert_eq!(b64_encode(b""), "");
        assert_eq!(b64_encode(b"f"), "Zg==");
        assert_eq!(b64_encode(b"fo"), "Zm8=");
        assert_eq!(b64_encode(b"foo"), "Zm9v");
        assert_eq!(b64_encode(b"foob"), "Zm9vYg==");
        assert_eq!(b64_encode(b"fooba"), "Zm9vYmE=");
        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");
    }

    #[test]
    fn image_ext_detection() {
        assert_eq!(image_ext("a.png"), Some("png"));
        assert_eq!(image_ext("a.PNG"), Some("png"));
        assert_eq!(image_ext("a.jpg"), Some("jpeg"));
        assert_eq!(image_ext("a.jpeg"), Some("jpeg"));
        assert_eq!(image_ext("a.gif"), Some("gif"));
        assert_eq!(image_ext("a.webp"), Some("webp"));
        assert_eq!(image_ext("a.txt"), None);
        assert_eq!(image_ext("foo"), None);
    }

    #[tokio::test]
    async fn reads_text_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("hello.txt");
        tokio::fs::write(&path, "line1\nline2\nline3")
            .await
            .unwrap();

        let result = FileReadTool {
            path_policy: PathPolicy::default(),
        }
        .execute(json!({ "path": path.to_str().unwrap() }))
        .await;

        assert_eq!(result["content"].as_str().unwrap(), "line1\nline2\nline3");
        assert_eq!(result["totalLines"].as_u64().unwrap(), 3);
        assert!(result.get("truncated").is_none());
    }

    #[tokio::test]
    async fn reads_with_offset_and_limit() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("a.txt");
        tokio::fs::write(&path, "1\n2\n3\n4\n5").await.unwrap();

        let result = FileReadTool {
            path_policy: PathPolicy::default(),
        }
        .execute(json!({
            "path": path.to_str().unwrap(),
            "offset": 2,
            "limit": 2
        }))
        .await;

        assert_eq!(result["content"].as_str().unwrap(), "2\n3");
        assert!(result["truncated"].as_bool().unwrap());
        assert_eq!(result["nextOffset"].as_u64().unwrap(), 4);
    }

    #[tokio::test]
    async fn reads_with_offset_past_end_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("a.txt");
        tokio::fs::write(&path, "line1\nline2\nline3")
            .await
            .unwrap();

        let result = FileReadTool {
            path_policy: PathPolicy::default(),
        }
        .execute(json!({
            "path": path.to_str().unwrap(),
            "offset": 100
        }))
        .await;

        let err = result["error"].as_str().unwrap();
        assert!(err.contains("past end of file"), "{err}");
        assert!(err.contains("3 lines"), "{err}");
    }

    #[tokio::test]
    async fn missing_file_returns_error() {
        let result = FileReadTool {
            path_policy: PathPolicy::default(),
        }
        .execute(json!({ "path": "/nonexistent/path/abc.xyz" }))
        .await;
        assert!(result["error"].as_str().unwrap().contains("not found"));
    }
}