Skip to main content

sgr_agent_tools/
read.rs

1//! ReadTool — read file contents with trust metadata.
2//!
3//! Core read logic without workflow guards or content scanning.
4//! For PAC1-specific behavior (workflow tracking, guard_content), wrap this tool.
5//!
6//! Supports two modes:
7//! - "slice" (default): read a range of lines (start_line/end_line)
8//! - "indentation": expand bidirectionally from an anchor line by indent level
9
10use std::sync::Arc;
11
12use schemars::JsonSchema;
13use serde::Deserialize;
14use serde_json::Value;
15use sgr_agent_core::agent_tool::{Tool, ToolError, ToolOutput, parse_args};
16use sgr_agent_core::context::AgentContext;
17use sgr_agent_core::schema::json_schema_for;
18
19use crate::backend::FileBackend;
20use crate::helpers::backend_err;
21use crate::trust::wrap_with_meta;
22
23pub struct ReadTool<B: FileBackend>(pub Arc<B>);
24
25#[derive(Deserialize, JsonSchema)]
26struct ReadArgs {
27    /// File path
28    path: String,
29    /// Show line numbers (like cat -n)
30    #[serde(default)]
31    number: bool,
32    /// Start line (1-indexed, like sed)
33    #[serde(default)]
34    start_line: i32,
35    #[serde(default)]
36    end_line: i32,
37    /// Reading mode: "slice" (default) or "indentation"
38    #[serde(default)]
39    mode: Option<String>,
40    /// Anchor line for indentation mode (1-indexed)
41    #[serde(default)]
42    anchor_line: Option<usize>,
43    /// Max indent levels to expand (0 = unlimited, default: 0)
44    #[serde(default)]
45    max_levels: Option<usize>,
46}
47
48/// Spaces per tab for indent calculation.
49const TAB_WIDTH: usize = 4;
50
51/// Compute the effective indent (in spaces) for each line.
52/// Blank lines inherit the indent of the previous non-blank line.
53fn compute_effective_indents(lines: &[&str]) -> Vec<usize> {
54    let mut indents: Vec<usize> = Vec::with_capacity(lines.len());
55    let mut prev_indent: usize = 0;
56
57    for line in lines {
58        if line.trim().is_empty() {
59            // Blank line inherits previous indent
60            indents.push(prev_indent);
61        } else {
62            let indent = line
63                .chars()
64                .take_while(|c| c.is_whitespace())
65                .map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
66                .sum();
67            indents.push(indent);
68            prev_indent = indent;
69        }
70    }
71
72    indents
73}
74
75/// Read a block around an anchor line based on indentation.
76///
77/// Expands bidirectionally from the anchor, including lines whose effective
78/// indent is >= min_indent (anchor_indent - max_levels * TAB_WIDTH).
79/// Stops at the first line at min_indent boundary in each direction (siblings).
80fn read_indentation_block(content: &str, anchor: usize, max_levels: usize) -> String {
81    let lines: Vec<&str> = content.lines().collect();
82    if lines.is_empty() || anchor == 0 || anchor > lines.len() {
83        return content.to_string();
84    }
85
86    let indents = compute_effective_indents(&lines);
87    let anchor_idx = anchor - 1; // convert 1-indexed to 0-indexed
88    let anchor_indent = indents[anchor_idx];
89
90    let min_indent = if max_levels == 0 {
91        0
92    } else {
93        anchor_indent.saturating_sub(max_levels * TAB_WIDTH)
94    };
95
96    // Expand upward from anchor: include the containing parent at min_indent,
97    // but stop at the previous sibling (a second line at min_indent).
98    let mut start = anchor_idx;
99    for i in (0..anchor_idx).rev() {
100        if indents[i] < min_indent {
101            break;
102        }
103        if indents[i] == min_indent {
104            // Found the containing parent — include it and stop
105            start = i;
106            break;
107        }
108        start = i;
109    }
110
111    // Expand downward from anchor: include children (indent > min_indent),
112    // but stop before the next sibling at min_indent.
113    let mut end = anchor_idx;
114    for i in (anchor_idx + 1)..lines.len() {
115        if indents[i] < min_indent {
116            break;
117        }
118        if indents[i] == min_indent {
119            // Next sibling — do not include it
120            break;
121        }
122        end = i;
123    }
124
125    // Trim leading/trailing blank lines within range
126    while start <= end && lines[start].trim().is_empty() {
127        start += 1;
128    }
129    while end > start && lines[end].trim().is_empty() {
130        end -= 1;
131    }
132
133    // Format with line numbers: L{n}: {content}
134    let mut result = String::new();
135    for i in start..=end {
136        result.push_str(&format!("L{}: {}\n", i + 1, lines[i]));
137    }
138
139    result
140}
141
142#[async_trait::async_trait]
143impl<B: FileBackend> Tool for ReadTool<B> {
144    fn name(&self) -> &str {
145        "read"
146    }
147    fn description(&self) -> &str {
148        "Read file contents. Use number=true to see line numbers (like cat -n). \
149         Use start_line/end_line to read a specific range (like sed -n '5,10p'). \
150         For large files: first read with number=true, then read specific ranges. \
151         Indentation mode: mode=\"indentation\", anchor_line=N expands around line N \
152         by indent level (max_levels=0 for full scope)."
153    }
154    fn is_read_only(&self) -> bool {
155        true
156    }
157    fn parameters_schema(&self) -> Value {
158        json_schema_for::<ReadArgs>()
159    }
160    async fn execute(&self, args: Value, ctx: &mut AgentContext) -> Result<ToolOutput, ToolError> {
161        self.execute_readonly(args, ctx).await
162    }
163    async fn execute_readonly(
164        &self,
165        args: Value,
166        _ctx: &AgentContext,
167    ) -> Result<ToolOutput, ToolError> {
168        let a: ReadArgs = parse_args(&args)?;
169
170        if a.mode.as_deref() == Some("indentation") {
171            let anchor = a.anchor_line.unwrap_or(1);
172            let max_levels = a.max_levels.unwrap_or(0);
173
174            // Read full file (no line numbers, no range)
175            let content = self
176                .0
177                .read(&a.path, false, 0, 0)
178                .await
179                .map_err(backend_err)?;
180
181            let block = read_indentation_block(&content, anchor, max_levels);
182            return Ok(ToolOutput::text(wrap_with_meta(&a.path, &block)));
183        }
184
185        // Default slice mode — delegate to backend
186        let result = self
187            .0
188            .read(&a.path, a.number, a.start_line, a.end_line)
189            .await
190            .map_err(backend_err)?;
191        Ok(ToolOutput::text(wrap_with_meta(&a.path, &result)))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn effective_indents_basic() {
201        let lines = vec!["def foo():", "    x = 1", "    y = 2", ""];
202        let indents = compute_effective_indents(&lines);
203        assert_eq!(indents, vec![0, 4, 4, 4]); // blank inherits
204    }
205
206    #[test]
207    fn effective_indents_tabs() {
208        let lines = vec!["\tdef foo():", "\t\tx = 1"];
209        let indents = compute_effective_indents(&lines);
210        assert_eq!(indents, vec![4, 8]);
211    }
212
213    #[test]
214    fn indentation_block_simple() {
215        let content = "class Foo:\n    def bar(self):\n        x = 1\n        y = 2\n    def baz(self):\n        z = 3\n";
216        // Anchor on "x = 1" (line 3), max_levels=1 should expand within bar()
217        let result = read_indentation_block(content, 3, 1);
218        assert!(result.contains("def bar"));
219        assert!(result.contains("x = 1"));
220        assert!(result.contains("y = 2"));
221        // Should stop before baz
222        assert!(!result.contains("baz"));
223    }
224
225    #[test]
226    fn indentation_block_unlimited() {
227        let content = "a\n  b\n    c\n  d\ne\n";
228        // Anchor on "c" (line 3), unlimited levels
229        let result = read_indentation_block(content, 3, 0);
230        // min_indent = 0, so everything is included until boundary
231        assert!(result.contains("L1: a"));
232        assert!(result.contains("L3:     c"));
233    }
234
235    #[test]
236    fn indentation_block_anchor_out_of_range() {
237        let content = "line1\nline2";
238        let result = read_indentation_block(content, 99, 0);
239        // Returns full content when anchor out of range
240        assert_eq!(result, content);
241    }
242
243    #[test]
244    fn indentation_block_blank_lines_trimmed() {
245        let content = "\n\ndef foo():\n    x = 1\n\n\n";
246        // Anchor on "x = 1" (line 4), max_levels=1
247        let result = read_indentation_block(content, 4, 1);
248        assert!(result.contains("def foo()"));
249        assert!(result.contains("x = 1"));
250        // Leading blank lines should be trimmed
251        assert!(!result.starts_with("L1: \n"));
252    }
253
254    #[test]
255    fn indentation_block_nested() {
256        let content = "\
257fn outer() {
258    fn inner() {
259        let x = 1;
260        let y = 2;
261    }
262    fn other() {
263        let z = 3;
264    }
265}";
266        // Anchor on "let x = 1;" (line 3), max_levels=1
267        let result = read_indentation_block(content, 3, 1);
268        assert!(result.contains("fn inner()"));
269        assert!(result.contains("let x = 1"));
270        assert!(result.contains("let y = 2"));
271        // Should not include other()
272        assert!(!result.contains("other"));
273    }
274}