xcodeai 2.1.0

Autonomous AI coding agent — zero human intervention, sbox sandboxed, OpenAI-compatible
Documentation
use crate::tools::{Tool, ToolContext, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use std::path::{Path, PathBuf};

pub struct FileReadTool;

#[async_trait]
impl Tool for FileReadTool {
    fn name(&self) -> &str {
        "file_read"
    }

    fn description(&self) -> &str {
        "Read a file and return its contents with line numbers. Supports offset and limit parameters."
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to read (relative to working_dir or absolute)"
                },
                "offset": {
                    "type": "integer",
                    "description": "Line number to start from (1-indexed, default: 1)"
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of lines to return (default: all)"
                }
            },
            "required": ["path"]
        })
    }

    async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
        let path_str = match args["path"].as_str() {
            Some(p) => p.to_string(),
            None => {
                return Ok(ToolResult {
                    output: "Error: 'path' parameter is required".to_string(),
                    is_error: true,
                });
            }
        };

        let path = resolve_path(&path_str, &ctx.working_dir);

        let content = match std::fs::read_to_string(&path) {
            Ok(c) => c,
            Err(e) => {
                return Ok(ToolResult {
                    output: format!("Error: failed to read file '{}': {}", path_str, e),
                    is_error: true,
                });
            }
        };

        let lines: Vec<&str> = content.lines().collect();
        let total = lines.len();

        let offset = args["offset"].as_u64().unwrap_or(1).saturating_sub(1) as usize;
        let limit = args["limit"].as_u64().map(|l| l as usize).unwrap_or(total);

        let end = (offset + limit).min(total);
        let selected = &lines[offset.min(total)..end];

        let output = selected
            .iter()
            .enumerate()
            .map(|(i, line)| format!("{}: {}", offset + i + 1, line))
            .collect::<Vec<_>>()
            .join("\n");

        // In compact mode, cap the output to 50 lines to save tokens.
        // The agent can always call file_read again with offset/limit to get more.
        let output = if ctx.compact_mode && selected.len() > 50 {
            // Take only the first 50 lines of the selected slice.
            let capped = selected[..50]
                .iter()
                .enumerate()
                .map(|(i, line)| format!("{}: {}", offset + i + 1, line))
                .collect::<Vec<_>>()
                .join("\n");
            format!(
                "{}\n[compact: showing first 50 of {} lines — use offset/limit to read more]",
                capped,
                selected.len()
            )
        } else {
            output
        };

        Ok(ToolResult {
            output,
            is_error: false,
        })
    }
}

fn resolve_path(path_str: &str, working_dir: &Path) -> PathBuf {
    let p = PathBuf::from(path_str);
    if p.is_absolute() {
        p
    } else {
        working_dir.join(p)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn make_ctx(dir: &std::path::Path) -> ToolContext {
        ToolContext {
            working_dir: dir.to_path_buf(),
            sandbox_enabled: false,
            io: std::sync::Arc::new(crate::io::NullIO),
            compact_mode: false,
            lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
            mcp_client: None,
            nesting_depth: 0,
            llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
            tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
            permissions: vec![],
            formatters: std::collections::HashMap::new(),
        }
    }
    #[tokio::test]
    async fn test_file_read_existing_file() {
        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "line one").unwrap();
        writeln!(f, "line two").unwrap();
        writeln!(f, "line three").unwrap();
        let path = f.path().to_string_lossy().to_string();
        let ctx = make_ctx(f.path().parent().unwrap());

        let tool = FileReadTool;
        let result = tool
            .execute(serde_json::json!({ "path": path }), &ctx)
            .await
            .unwrap();

        assert!(!result.is_error);
        assert!(result.output.contains("1: line one"));
        assert!(result.output.contains("2: line two"));
        assert!(result.output.contains("3: line three"));
    }

    #[tokio::test]
    async fn test_file_read_missing_file() {
        let ctx = make_ctx(std::path::Path::new("/tmp"));
        let tool = FileReadTool;
        let result = tool
            .execute(
                serde_json::json!({ "path": "/tmp/this_file_does_not_exist_xcode_test.txt" }),
                &ctx,
            )
            .await
            .unwrap();

        assert!(result.is_error);
        assert!(result.output.contains("Error"));
    }

    #[tokio::test]
    async fn test_file_read_with_offset_limit() {
        let mut f = NamedTempFile::new().unwrap();
        for i in 1..=10 {
            writeln!(f, "line {}", i).unwrap();
        }
        let path = f.path().to_string_lossy().to_string();
        let ctx = make_ctx(f.path().parent().unwrap());

        let tool = FileReadTool;
        let result = tool
            .execute(
                serde_json::json!({ "path": path, "offset": 3, "limit": 3 }),
                &ctx,
            )
            .await
            .unwrap();

        assert!(!result.is_error);
        assert!(result.output.contains("3: line 3"));
        assert!(result.output.contains("4: line 4"));
        assert!(result.output.contains("5: line 5"));
        assert!(!result.output.contains("line 1\n") || result.output.starts_with("3:"));
        assert!(!result.output.contains("6: line 6"));
    }
}