llm_coding_tools_serdesai/absolute/
read.rs

1//! Read file tool using [`AbsolutePathResolver`].
2
3use async_trait::async_trait;
4use llm_coding_tools_core::ToolContext;
5use llm_coding_tools_core::operations::read_file;
6use llm_coding_tools_core::path::AbsolutePathResolver;
7use llm_coding_tools_core::tool_names;
8use serde::Deserialize;
9use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult};
10
11use crate::convert::to_serdes_result;
12
13const DEFAULT_OFFSET: usize = 1;
14const DEFAULT_LIMIT: usize = 2000;
15
16fn default_offset() -> usize {
17    DEFAULT_OFFSET
18}
19
20fn default_limit() -> usize {
21    DEFAULT_LIMIT
22}
23
24/// Internal args for JSON deserialization.
25#[derive(Debug, Deserialize)]
26struct ReadArgs {
27    /// Absolute path to the file.
28    file_path: String,
29    /// Line offset to start reading from (1-based). Defaults to 1.
30    #[serde(default = "default_offset")]
31    offset: usize,
32    /// Maximum number of lines to return. Defaults to 2000.
33    #[serde(default = "default_limit")]
34    limit: usize,
35}
36
37/// Tool for reading file contents with optional line numbers.
38///
39/// The `LINE_NUMBERS` const generic controls output format:
40/// - `true` (default): Lines prefixed with `L{number}: `
41/// - `false`: Raw file content
42#[derive(Debug, Clone, Default)]
43pub struct ReadTool<const LINE_NUMBERS: bool = true>;
44
45impl<const LINE_NUMBERS: bool> ReadTool<LINE_NUMBERS> {
46    /// Creates a new read tool instance.
47    #[inline]
48    pub fn new() -> Self {
49        Self
50    }
51}
52
53#[async_trait]
54impl<Deps: Send + Sync, const LINE_NUMBERS: bool> Tool<Deps> for ReadTool<LINE_NUMBERS> {
55    fn definition(&self) -> ToolDefinition {
56        // Description matches rig exactly
57        let description = if LINE_NUMBERS {
58            "Read file contents with line numbers. Returns lines prefixed with L{number}: format."
59        } else {
60            "Read file contents. Returns raw file content without line number prefixes."
61        };
62        let schema = SchemaBuilder::new()
63            .string("file_path", "Absolute path to the file", true)
64            .integer_constrained(
65                "offset",
66                "Line offset to start reading from (1-based). Defaults to 1.",
67                false,
68                Some(1),
69                None,
70            )
71            .integer_constrained(
72                "limit",
73                "Maximum number of lines to return. Defaults to 2000.",
74                false,
75                Some(1),
76                None,
77            )
78            .build()
79            .expect("schema build should not fail");
80
81        ToolDefinition::new(tool_names::READ, description).with_parameters(schema)
82    }
83
84    async fn call(&self, _ctx: &RunContext<Deps>, args: serde_json::Value) -> ToolResult {
85        let args: ReadArgs = serde_json::from_value(args)
86            .map_err(|e| ToolError::validation_error(tool_names::READ, None, e.to_string()))?;
87
88        let resolver = AbsolutePathResolver;
89        // Core uses 1-indexed offset directly; args.offset defaults to 1
90        let result =
91            read_file::<_, LINE_NUMBERS>(&resolver, &args.file_path, args.offset, args.limit).await;
92        to_serdes_result(tool_names::READ, result)
93    }
94}
95
96impl<const LINE_NUMBERS: bool> ToolContext for ReadTool<LINE_NUMBERS> {
97    const NAME: &'static str = tool_names::READ;
98
99    fn context(&self) -> &'static str {
100        llm_coding_tools_core::context::READ_ABSOLUTE
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108    use serdes_ai::tools::RunContext;
109    use std::io::Write as _;
110    use tempfile::NamedTempFile;
111
112    fn mock_ctx() -> RunContext<()> {
113        RunContext::new((), "test-model")
114    }
115
116    #[tokio::test]
117    async fn reads_file_with_offset_and_limit() {
118        let mut temp = NamedTempFile::new().unwrap();
119        temp.write_all(b"line1\nline2\nline3\nline4\n").unwrap();
120        let tool: ReadTool<true> = ReadTool::new();
121
122        let result = tool
123            .call(
124                &mock_ctx(),
125                json!({
126                    "file_path": temp.path().to_string_lossy(),
127                    "offset": 2,
128                    "limit": 2
129                }),
130            )
131            .await
132            .unwrap();
133
134        let text = result.as_text().unwrap();
135        assert!(text.contains("L2: line2"));
136        assert!(text.contains("L3: line3"));
137        assert!(!text.contains("L1:"));
138        assert!(!text.contains("L4:"));
139    }
140}