claux 20260727.0.0

Terminal AI coding assistant with tool execution
use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::fmt::Write;
use std::io::BufRead;

use super::{interrupted_output, Tool, ToolOutput};

pub struct ReadTool;

const MAX_READ_LINES: usize = 2_000;

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

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

    fn description(&self) -> &str {
        "Read a file from the filesystem. Returns content with line numbers."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Absolute path to the file to read"
                },
                "offset": {
                    "type": "integer",
                    "description": "Line number to start reading from (1-indexed)"
                },
                "limit": {
                    "type": "integer",
                    "description": "Number of lines to read"
                }
            },
            "required": ["file_path"]
        })
    }

    fn is_read_only(&self) -> bool {
        true
    }

    fn summarize(&self, input: &Value) -> String {
        let path = input["file_path"].as_str().unwrap_or("?");
        match input["offset"].as_u64() {
            Some(offset) => format!("{path} (from line {offset})"),
            None => path.to_string(),
        }
    }

    async fn execute(
        &self,
        input: Value,
        cancel: tokio_util::sync::CancellationToken,
    ) -> Result<ToolOutput> {
        let params: Params = serde_json::from_value(input)?;
        tokio::task::spawn_blocking(move || read_file(params, cancel)).await?
    }
}

fn read_file(params: Params, cancel: tokio_util::sync::CancellationToken) -> Result<ToolOutput> {
    if cancel.is_cancelled() {
        return Ok(interrupted_output());
    }

    let path = expand_tilde(&params.file_path);
    if !path.exists() {
        return Ok(ToolOutput {
            content: format!("File does not exist: {}", params.file_path),
            is_error: true,
        });
    }
    if !path.is_file() {
        return Ok(ToolOutput {
            content: format!("Not a file: {}", params.file_path),
            is_error: true,
        });
    }

    let reader = std::io::BufReader::new(std::fs::File::open(&path)?);
    let start = params.offset.unwrap_or(1).max(1);
    let limit = params.limit.unwrap_or(MAX_READ_LINES).min(MAX_READ_LINES);
    let mut total_lines = 0;
    let mut selected_lines = 0;
    let mut result = String::new();

    for line in reader.lines() {
        if cancel.is_cancelled() {
            return Ok(interrupted_output());
        }

        let line = line?;
        total_lines += 1;
        if total_lines < start {
            continue;
        }
        if selected_lines >= limit {
            break;
        }
        let _ = writeln!(result, "{total_lines}\t{line}");
        selected_lines += 1;
    }

    if total_lines < start {
        return Ok(ToolOutput {
            content: format!(
                "Offset {} is beyond end of file ({} lines)",
                params.offset.unwrap_or(1),
                total_lines
            ),
            is_error: true,
        });
    }

    Ok(ToolOutput {
        content: result,
        is_error: false,
    })
}

pub fn expand_tilde(path: &str) -> std::path::PathBuf {
    if let Some(stripped) = path.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(stripped);
        }
    }
    std::path::PathBuf::from(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tokio_util::sync::CancellationToken;

    #[tokio::test]
    async fn read_existing_file() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "line one").unwrap();
        writeln!(tmp, "line two").unwrap();
        writeln!(tmp, "line three").unwrap();

        let tool = ReadTool;
        let result = tool
            .execute(
                json!({"file_path": tmp.path().to_str().unwrap()}),
                CancellationToken::new(),
            )
            .await
            .unwrap();

        assert!(!result.is_error);
        assert!(result.content.contains("line one"));
        assert!(result.content.contains("line two"));
        assert!(result.content.contains("1\t"));
    }

    #[tokio::test]
    async fn read_with_offset_and_limit() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        for i in 1..=10 {
            writeln!(tmp, "line {i}").unwrap();
        }

        let tool = ReadTool;
        let result = tool
            .execute(
                json!({
                    "file_path": tmp.path().to_str().unwrap(),
                    "offset": 3,
                    "limit": 2
                }),
                CancellationToken::new(),
            )
            .await
            .unwrap();

        assert!(!result.is_error);
        assert!(result.content.contains("line 3"));
        assert!(result.content.contains("line 4"));
        assert!(!result.content.contains("line 5"));
    }

    #[tokio::test]
    async fn read_nonexistent_file() {
        let tool = ReadTool;
        let result = tool
            .execute(
                json!({"file_path": "/tmp/definitely_does_not_exist_12345"}),
                CancellationToken::new(),
            )
            .await
            .unwrap();

        assert!(result.is_error);
        assert!(result.content.contains("does not exist"));
    }

    #[tokio::test]
    async fn offset_beyond_end_returns_error_instead_of_panicking() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "only line").unwrap();

        let result = ReadTool
            .execute(
                json!({"file_path": tmp.path(), "offset": 99}),
                CancellationToken::new(),
            )
            .await
            .unwrap();

        assert!(result.is_error);
        assert!(result.content.contains("beyond end of file"));
    }

    #[tokio::test]
    async fn explicit_limit_is_bounded() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        for line in 1..=2_100 {
            writeln!(tmp, "line {line}").unwrap();
        }

        let result = ReadTool
            .execute(
                json!({"file_path": tmp.path(), "limit": 10_000}),
                CancellationToken::new(),
            )
            .await
            .unwrap();

        assert!(!result.is_error);
        assert_eq!(result.content.lines().count(), MAX_READ_LINES);
        assert!(!result.content.contains("line 2001"));
    }

    #[tokio::test]
    async fn cancelled_read_stops_before_io() {
        let cancel = CancellationToken::new();
        cancel.cancel();

        let result = ReadTool
            .execute(json!({"file_path": "/etc/hosts"}), cancel)
            .await
            .unwrap();

        assert!(result.is_error);
        assert!(result.content.contains("Interrupted"));
    }

    #[test]
    fn expand_tilde_works() {
        let path = expand_tilde("~/test.txt");
        assert!(!path.to_str().unwrap().contains('~'));
        assert!(path.to_str().unwrap().contains("test.txt"));
    }

    #[test]
    fn expand_tilde_absolute_unchanged() {
        let path = expand_tilde("/tmp/test.txt");
        assert_eq!(path.to_str().unwrap(), "/tmp/test.txt");
    }
}