rho-coding-agent 0.4.0

A lightweight agent harness inspired by Pi
use crate::tool::*;
use serde::Deserialize;
use serde_json::json;

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

#[async_trait::async_trait]
impl Tool for ReadFile {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "read_file".into(),
            description: "Reads a UTF-8 text file.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "path": {"type": "string"},
                    "offset": {"type": "integer", "minimum": 1},
                    "limit": {"type": "integer", "minimum": 1}
                },
                "required": ["path"]
            }),
        }
    }
    async fn call(
        &self,
        args: serde_json::Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: Args = serde_json::from_value(args)?;
        let content = std::fs::read_to_string(resolve_path(&ctx.cwd, &args.path))?;
        let content = select_line_range(&content, args.offset, args.limit)?;
        Ok(ToolResult {
            id,
            ok: true,
            content: truncate(content, ctx.max_output_bytes),
        })
    }
}

fn select_line_range(
    content: &str,
    offset: Option<usize>,
    limit: Option<usize>,
) -> Result<String, ToolError> {
    if offset == Some(0) {
        return Err(ToolError::Message("offset must be greater than 0".into()));
    }
    if limit == Some(0) {
        return Err(ToolError::Message("limit must be greater than 0".into()));
    }
    if offset.is_none() && limit.is_none() {
        return Ok(content.to_string());
    }

    let start = offset.unwrap_or(1) - 1;
    let lines = content.split_inclusive('\n').skip(start);
    let selected = match limit {
        Some(limit) => lines.take(limit).collect(),
        None => lines.collect(),
    };
    Ok(selected)
}

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

    use serde_json::json;
    use tempfile::TempDir;

    use super::*;

    fn test_context() -> (TempDir, ToolContext) {
        let dir = tempfile::tempdir().unwrap();
        let ctx = ToolContext {
            cwd: dir.path().to_path_buf(),
            max_output_bytes: 12000,
        };
        (dir, ctx)
    }

    #[tokio::test]
    async fn reads_selected_line_range() {
        let (_dir, ctx) = test_context();
        fs::write(ctx.cwd.join("sample.txt"), "one\ntwo\nthree\nfour\n").unwrap();

        let result = ReadFile
            .call(
                json!({"path": "sample.txt", "offset": 2, "limit": 2}),
                ctx,
                "call_1".into(),
            )
            .await
            .unwrap();

        assert_eq!(result.content, "two\nthree\n");
    }

    #[tokio::test]
    async fn rejects_zero_offset() {
        let (_dir, ctx) = test_context();
        fs::write(ctx.cwd.join("sample.txt"), "one\n").unwrap();

        let err = ReadFile
            .call(
                json!({"path": "sample.txt", "offset": 0}),
                ctx,
                "call_1".into(),
            )
            .await
            .unwrap_err();

        assert_eq!(err.to_string(), "offset must be greater than 0");
    }

    #[tokio::test]
    async fn rejects_zero_limit() {
        let (_dir, ctx) = test_context();
        fs::write(ctx.cwd.join("sample.txt"), "one\n").unwrap();

        let err = ReadFile
            .call(
                json!({"path": "sample.txt", "limit": 0}),
                ctx,
                "call_1".into(),
            )
            .await
            .unwrap_err();

        assert_eq!(err.to_string(), "limit must be greater than 0");
    }
}