Skip to main content

rab/extensions/
file_search.rs

1use crate::agent::extension::{Extension, ToolDefinition, ToolRenderContext, ToolRenderer};
2use crate::tui::Theme;
3use crate::tui::ThemeKey;
4use async_trait::async_trait;
5use std::borrow::Cow;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9// ── Shared command execution ─────────────────────────────────────
10
11/// Output of a shell command execution.
12pub struct ExecOutput {
13    pub stdout: String,
14    pub stderr: String,
15    pub exit_code: Option<i32>,
16}
17
18/// Run a shell command in the given working directory (shared helper for default ops).
19async fn run_shell_command(command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
20    let output = tokio::process::Command::new("sh")
21        .arg("-c")
22        .arg(command)
23        .current_dir(cwd)
24        .output()
25        .await?;
26    Ok(ExecOutput {
27        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
28        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
29        exit_code: output.status.code(),
30    })
31}
32
33// ── GrepOperations (pluggable) ──────────────────────────────────
34
35/// Pluggable operations for the grep tool (matching pi's GrepOperations).
36/// Override these to delegate command execution to remote systems.
37#[async_trait]
38pub trait GrepOperations: Send + Sync {
39    /// Execute a shell command in the given working directory.
40    /// Returns stdout, stderr, and exit code.
41    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput>;
42}
43
44struct DefaultGrepOperations;
45
46#[async_trait]
47impl GrepOperations for DefaultGrepOperations {
48    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
49        run_shell_command(command, cwd).await
50    }
51}
52
53// ── FindOperations (pluggable) ───────────────────────────────────
54
55/// Pluggable operations for the find tool (matching pi's FindOperations).
56/// Override these to delegate command execution to remote systems.
57#[async_trait]
58pub trait FindOperations: Send + Sync {
59    /// Execute a shell command in the given working directory.
60    /// Returns stdout, stderr, and exit code.
61    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput>;
62}
63
64struct DefaultFindOperations;
65
66#[async_trait]
67impl FindOperations for DefaultFindOperations {
68    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
69        run_shell_command(command, cwd).await
70    }
71}
72
73// ── LsOperations (pluggable) ─────────────────────────────────────
74
75/// A directory entry for the ls tool.
76pub struct DirEntry {
77    pub name: String,
78    pub is_dir: bool,
79}
80
81/// Pluggable operations for the ls tool (matching pi's LsOperations).
82/// Override these to delegate directory listing to remote systems.
83pub trait LsOperations: Send + Sync {
84    /// List entries in a directory, returning name and type.
85    fn read_dir(&self, path: &Path) -> anyhow::Result<Vec<DirEntry>>;
86    /// Check if path is a directory.
87    fn is_dir(&self, path: &Path) -> anyhow::Result<bool>;
88    /// Check if path exists.
89    fn path_exists(&self, path: &Path) -> anyhow::Result<bool>;
90}
91
92struct DefaultLsOperations;
93
94impl LsOperations for DefaultLsOperations {
95    fn read_dir(&self, path: &Path) -> anyhow::Result<Vec<DirEntry>> {
96        let rd = std::fs::read_dir(path)?;
97        let mut items: Vec<DirEntry> = rd
98            .flatten()
99            .map(|entry| DirEntry {
100                name: entry.file_name().to_string_lossy().to_string(),
101                is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
102            })
103            .collect();
104        items.sort_by_key(|e| e.name.to_lowercase());
105        Ok(items)
106    }
107    fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
108        Ok(std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false))
109    }
110    fn path_exists(&self, path: &Path) -> anyhow::Result<bool> {
111        Ok(path.exists())
112    }
113}
114
115/// Extension providing grep, find, and ls tools.
116pub struct FileSearchExtension {
117    cwd: PathBuf,
118    grep_operations: Arc<dyn GrepOperations>,
119    find_operations: Arc<dyn FindOperations>,
120    ls_operations: Arc<dyn LsOperations>,
121}
122
123impl FileSearchExtension {
124    pub fn new(cwd: PathBuf) -> Self {
125        Self {
126            cwd,
127            grep_operations: Arc::new(DefaultGrepOperations),
128            find_operations: Arc::new(DefaultFindOperations),
129            ls_operations: Arc::new(DefaultLsOperations),
130        }
131    }
132
133    /// Set custom grep operations (e.g. for SSH targets).
134    pub fn with_grep_operations(mut self, ops: Arc<dyn GrepOperations>) -> Self {
135        self.grep_operations = ops;
136        self
137    }
138
139    /// Set custom find operations (e.g. for SSH targets).
140    pub fn with_find_operations(mut self, ops: Arc<dyn FindOperations>) -> Self {
141        self.find_operations = ops;
142        self
143    }
144
145    /// Set custom ls operations (e.g. for SSH targets).
146    pub fn with_ls_operations(mut self, ops: Arc<dyn LsOperations>) -> Self {
147        self.ls_operations = ops;
148        self
149    }
150}
151
152impl Extension for FileSearchExtension {
153    fn name(&self) -> Cow<'static, str> {
154        "file_search".into()
155    }
156
157    fn tools(&self) -> Vec<ToolDefinition> {
158        vec![
159            ToolDefinition {
160                tool: Box::new(GrepTool {
161                    cwd: self.cwd.clone(),
162                    operations: self.grep_operations.clone(),
163                }),
164                snippet: "Search file contents for patterns (respects .gitignore)",
165                guidelines: &["Use grep for searching file contents with patterns"],
166                prepare_arguments: None,
167                before_tool_call: None,
168                after_tool_call: None,
169                renderer: Some(std::sync::Arc::new(ListRenderer::grep())),
170            },
171            ToolDefinition {
172                tool: Box::new(FindTool {
173                    cwd: self.cwd.clone(),
174                    operations: self.find_operations.clone(),
175                }),
176                snippet: "Find files by glob pattern (respects .gitignore)",
177                guidelines: &["Use find for locating files by pattern"],
178                prepare_arguments: None,
179                before_tool_call: None,
180                after_tool_call: None,
181                renderer: Some(std::sync::Arc::new(ListRenderer::find())),
182            },
183            ToolDefinition {
184                tool: Box::new(LsTool {
185                    cwd: self.cwd.clone(),
186                    operations: self.ls_operations.clone(),
187                }),
188                snippet: "List directory contents",
189                guidelines: &["Use ls for exploring directory structure"],
190                prepare_arguments: None,
191                before_tool_call: None,
192                after_tool_call: None,
193                renderer: Some(std::sync::Arc::new(ListRenderer::ls())),
194            },
195        ]
196    }
197}
198
199// ── Constants ────────────────────────────────────────────────────
200
201const GREP_DEFAULT_LIMIT: u64 = 100;
202const GREP_MAX_LINE_LENGTH: usize = 500;
203const FIND_DEFAULT_LIMIT: u64 = 1000;
204const LS_DEFAULT_LIMIT: u64 = 500;
205
206// =====================================================================
207// grep tool
208// =====================================================================
209
210struct GrepTool {
211    cwd: PathBuf,
212    operations: Arc<dyn GrepOperations>,
213}
214
215#[async_trait]
216impl yoagent::types::AgentTool for GrepTool {
217    fn name(&self) -> &str {
218        "grep"
219    }
220    fn label(&self) -> &str {
221        "grep"
222    }
223    fn description(&self) -> &str {
224        "Search file contents for a pattern. Returns matching lines with file paths and line numbers. \
225         Respects .gitignore. Output is truncated to 100 matches. \
226         Long lines are truncated to 500 chars."
227    }
228    fn parameters_schema(&self) -> serde_json::Value {
229        serde_json::json!({
230            "type": "object",
231            "required": ["pattern"],
232            "properties": {
233                "pattern": {
234                    "type": "string",
235                    "description": "Search pattern (regex or literal string)"
236                },
237                "path": {
238                    "type": "string",
239                    "description": "Directory or file to search (default: current directory)"
240                },
241                "glob": {
242                    "type": "string",
243                    "description": "Filter files by glob pattern, e.g. '*.rs' or '**/*.spec.rs'"
244                },
245                "ignoreCase": {
246                    "type": "boolean",
247                    "description": "Case-insensitive search (default: false)"
248                },
249                "literal": {
250                    "type": "boolean",
251                    "description": "Treat pattern as literal string instead of regex (default: false)"
252                },
253                "context": {
254                    "type": "number",
255                    "description": "Number of lines to show before and after each match (default: 0)"
256                },
257                "limit": {
258                    "type": "number",
259                    "description": "Maximum number of matches to return (default: 100)"
260                }
261            }
262        })
263    }
264
265    async fn execute(
266        &self,
267        params: serde_json::Value,
268        ctx: yoagent::types::ToolContext,
269    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
270        let pattern = params["pattern"].as_str().ok_or_else(|| {
271            yoagent::types::ToolError::InvalidArgs("Missing 'pattern' argument".into())
272        })?;
273        let search_path = params["path"].as_str().unwrap_or(".");
274        let search_owned = resolve_path(search_path, &self.cwd);
275        let abs_search = &search_owned;
276
277        let glob = params["glob"].as_str();
278        let ignore_case = params["ignoreCase"].as_bool().unwrap_or(false);
279        let literal = params["literal"].as_bool().unwrap_or(false);
280        let context = params["context"].as_u64().unwrap_or(0);
281        let limit = params["limit"].as_u64().unwrap_or(GREP_DEFAULT_LIMIT);
282
283        if !abs_search.exists() {
284            return Err(yoagent::types::ToolError::Failed(format!(
285                "Path not found: {}",
286                abs_search.display()
287            )));
288        }
289
290        if ctx.cancel.is_cancelled() {
291            return Err(yoagent::types::ToolError::Cancelled);
292        }
293
294        // Try ripgrep first, fall back to grep
295        let output = if let Some(rg) = which("rg") {
296            run_rg_with_ops(
297                self.operations.as_ref(),
298                &self.cwd,
299                &rg,
300                pattern,
301                abs_search,
302                glob,
303                ignore_case,
304                literal,
305                context,
306                limit,
307            )
308            .await?
309        } else {
310            run_grep_with_ops(
311                self.operations.as_ref(),
312                &self.cwd,
313                pattern,
314                abs_search,
315                ignore_case,
316                literal,
317                context,
318                limit,
319            )
320            .await?
321        };
322
323        if ctx.cancel.is_cancelled() {
324            return Err(yoagent::types::ToolError::Cancelled);
325        }
326
327        Ok(yoagent::types::ToolResult {
328            content: vec![yoagent::types::Content::Text { text: output }],
329            details: serde_json::Value::Null,
330        })
331    }
332}
333
334/// Build a ripgrep command string and execute via operations.
335#[allow(clippy::too_many_arguments)]
336async fn run_rg_with_ops(
337    ops: &dyn GrepOperations,
338    cwd: &Path,
339    rg: &Path,
340    pattern: &str,
341    search_path: &Path,
342    glob: Option<&str>,
343    ignore_case: bool,
344    literal: bool,
345    context: u64,
346    limit: u64,
347) -> Result<String, yoagent::types::ToolError> {
348    let mut cmd_parts: Vec<String> = vec![
349        rg.to_string_lossy().to_string(),
350        "--json".into(),
351        "--line-number".into(),
352        "--color=never".into(),
353        "--hidden".into(),
354    ];
355    if ignore_case {
356        cmd_parts.push("--ignore-case".into());
357    }
358    if literal {
359        cmd_parts.push("--fixed-strings".into());
360    }
361    if let Some(g) = glob {
362        cmd_parts.push("--glob".into());
363        cmd_parts.push(shell_escape(g));
364    }
365    if context > 0 {
366        cmd_parts.push("-C".into());
367        cmd_parts.push(context.to_string());
368    }
369    cmd_parts.push("--max-count".into());
370    cmd_parts.push(limit.to_string());
371    cmd_parts.push("--".into());
372    cmd_parts.push(shell_escape(pattern));
373    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
374
375    let command = cmd_parts.join(" ");
376    let exec_output = ops
377        .exec(&command, cwd)
378        .await
379        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run rg: {}", e)))?;
380
381    let exit_code = exec_output.exit_code.unwrap_or(-1);
382    if exit_code == 2 {
383        return Err(yoagent::types::ToolError::Failed(format!(
384            "ripgrep error: {}",
385            exec_output.stderr.trim()
386        )));
387    }
388
389    let stdout = &exec_output.stdout;
390    let mut results: Vec<String> = Vec::new();
391    let mut line_count = 0u64;
392
393    for line in stdout.lines() {
394        if line.trim().is_empty() {
395            continue;
396        }
397        if line_count >= limit {
398            break;
399        }
400
401        if let Ok(event) = serde_json::from_str::<serde_json::Value>(line)
402            && event["type"] == "match"
403            && let (Some(file_path), Some(line_number), Some(line_text)) = (
404                event["data"]["path"]["text"].as_str(),
405                event["data"]["line_number"].as_u64(),
406                event["data"]["lines"]["text"].as_str(),
407            )
408        {
409            let relative = relativize_path(file_path, search_path);
410            let sanitized = line_text
411                .replace('\r', "")
412                .trim_end_matches('\n')
413                .to_string();
414            results.push(format!(
415                "{}:{}: {}",
416                relative,
417                line_number,
418                truncate_line(&sanitized, GREP_MAX_LINE_LENGTH)
419            ));
420            line_count += 1;
421        }
422    }
423
424    if results.is_empty() {
425        return Ok("No matches found".to_string());
426    }
427
428    Ok(results.join("\n"))
429}
430
431#[allow(clippy::too_many_arguments)]
432async fn run_grep_with_ops(
433    ops: &dyn GrepOperations,
434    cwd: &Path,
435    pattern: &str,
436    search_path: &Path,
437    ignore_case: bool,
438    literal: bool,
439    context: u64,
440    limit: u64,
441) -> Result<String, yoagent::types::ToolError> {
442    let mut cmd_parts: Vec<String> = vec![
443        "grep".into(),
444        "--line-number".into(),
445        "--color=never".into(),
446        "--binary-files=without-match".into(),
447    ];
448    if ignore_case {
449        cmd_parts.push("-i".into());
450    }
451    if literal {
452        cmd_parts.push("-F".into());
453    }
454    if context > 0 {
455        cmd_parts.push("-C".into());
456        cmd_parts.push(context.to_string());
457    }
458    cmd_parts.push("--max-count".into());
459    cmd_parts.push(limit.to_string());
460    cmd_parts.push("-r".into());
461    cmd_parts.push("--".into());
462    cmd_parts.push(shell_escape(pattern));
463    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
464
465    let command = cmd_parts.join(" ");
466    let exec_output = ops
467        .exec(&command, cwd)
468        .await
469        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run grep: {}", e)))?;
470
471    let exit_code = exec_output.exit_code.unwrap_or(-1);
472    if exit_code == 2 {
473        return Err(yoagent::types::ToolError::Failed(format!(
474            "grep error: {}",
475            exec_output.stderr.trim()
476        )));
477    }
478
479    let trimmed = exec_output.stdout.trim();
480    if trimmed.is_empty() {
481        return Ok("No matches found".to_string());
482    }
483
484    let lines: Vec<&str> = trimmed.lines().collect();
485    let truncated: Vec<String> = lines
486        .iter()
487        .take(limit as usize)
488        .map(|l| truncate_line(l, GREP_MAX_LINE_LENGTH))
489        .collect();
490
491    Ok(truncated.join("\n"))
492}
493
494// =====================================================================
495// find tool
496// =====================================================================
497
498struct FindTool {
499    cwd: PathBuf,
500    operations: Arc<dyn FindOperations>,
501}
502
503#[async_trait]
504impl yoagent::types::AgentTool for FindTool {
505    fn name(&self) -> &str {
506        "find"
507    }
508    fn label(&self) -> &str {
509        "find"
510    }
511    fn description(&self) -> &str {
512        "Search for files by glob pattern. Returns matching file paths relative to the search directory. \
513         Respects .gitignore. Output is truncated to 1000 results."
514    }
515    fn parameters_schema(&self) -> serde_json::Value {
516        serde_json::json!({
517            "type": "object",
518            "required": ["pattern"],
519            "properties": {
520                "pattern": {
521                    "type": "string",
522                    "description": "Glob pattern to match files, e.g. '*.rs', '**/*.json', or 'src/**/*.spec.rs'"
523                },
524                "path": {
525                    "type": "string",
526                    "description": "Directory to search in (default: current directory)"
527                },
528                "limit": {
529                    "type": "number",
530                    "description": "Maximum number of results (default: 1000)"
531                }
532            }
533        })
534    }
535
536    async fn execute(
537        &self,
538        params: serde_json::Value,
539        ctx: yoagent::types::ToolContext,
540    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
541        let pattern = params["pattern"].as_str().ok_or_else(|| {
542            yoagent::types::ToolError::InvalidArgs("Missing 'pattern' argument".into())
543        })?;
544        let search_path = params["path"].as_str().unwrap_or(".");
545        let search_owned = resolve_path(search_path, &self.cwd);
546        let abs_search = &search_owned;
547        let limit = params["limit"].as_u64().unwrap_or(FIND_DEFAULT_LIMIT);
548
549        if !abs_search.exists() {
550            return Err(yoagent::types::ToolError::Failed(format!(
551                "Path not found: {}",
552                abs_search.display()
553            )));
554        }
555
556        if ctx.cancel.is_cancelled() {
557            return Err(yoagent::types::ToolError::Cancelled);
558        }
559
560        let output = if let Some(fd_path) = which("fd") {
561            run_fd_with_ops(
562                self.operations.as_ref(),
563                &self.cwd,
564                &fd_path,
565                pattern,
566                abs_search,
567                limit,
568            )
569            .await?
570        } else {
571            run_find_with_ops(
572                self.operations.as_ref(),
573                &self.cwd,
574                pattern,
575                abs_search,
576                limit,
577            )
578            .await?
579        };
580
581        if ctx.cancel.is_cancelled() {
582            return Err(yoagent::types::ToolError::Cancelled);
583        }
584
585        Ok(yoagent::types::ToolResult {
586            content: vec![yoagent::types::Content::Text { text: output }],
587            details: serde_json::Value::Null,
588        })
589    }
590}
591
592async fn run_fd_with_ops(
593    ops: &dyn FindOperations,
594    cwd: &Path,
595    fd: &Path,
596    pattern: &str,
597    search_path: &Path,
598    limit: u64,
599) -> Result<String, yoagent::types::ToolError> {
600    // Build effective pattern for fd
601    let effective_pattern = if pattern.contains('/') {
602        if !pattern.starts_with('/') && !pattern.starts_with("**/") && pattern != "**" {
603            format!("**/{}", pattern)
604        } else {
605            pattern.to_string()
606        }
607    } else {
608        pattern.to_string()
609    };
610
611    // Build fd command string
612    let mut cmd_parts: Vec<String> = vec![
613        fd.to_string_lossy().to_string(),
614        "--glob".into(),
615        "--color=never".into(),
616        "--hidden".into(),
617        "--no-require-git".into(),
618        "--max-results".into(),
619        limit.to_string(),
620    ];
621    if pattern.contains('/') {
622        cmd_parts.push("--full-path".into());
623    }
624    cmd_parts.push("--".into());
625    cmd_parts.push(shell_escape(&effective_pattern));
626    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
627
628    let command = cmd_parts.join(" ");
629    let exec_output = ops
630        .exec(&command, cwd)
631        .await
632        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run fd: {}", e)))?;
633
634    let exit_code = exec_output.exit_code.unwrap_or(-1);
635    if exit_code != 0 && exit_code != 1 && exec_output.stdout.trim().is_empty() {
636        return Err(yoagent::types::ToolError::Failed(format!(
637            "fd error: {}",
638            exec_output.stderr.trim()
639        )));
640    }
641
642    let results: Vec<String> = exec_output
643        .stdout
644        .lines()
645        .map(|l| l.trim().to_string())
646        .filter(|l| !l.is_empty())
647        .collect();
648
649    if results.is_empty() {
650        return Ok("No files found matching pattern".to_string());
651    }
652
653    let relativized: Vec<String> = results
654        .into_iter()
655        .map(|line| relativize_path(&line, search_path))
656        .collect();
657
658    let mut output = relativized.join("\n");
659    if relativized.len() >= limit as usize {
660        output.push_str(&format!(
661            "\n\n[{} results limit reached. Use limit={} for more, or refine pattern]",
662            limit,
663            limit * 2,
664        ));
665    }
666
667    Ok(output)
668}
669
670async fn run_find_with_ops(
671    ops: &dyn FindOperations,
672    cwd: &Path,
673    pattern: &str,
674    search_path: &Path,
675    limit: u64,
676) -> Result<String, yoagent::types::ToolError> {
677    let name_pattern = pattern.trim_start_matches("**/").trim_start_matches("*/");
678
679    let cmd_parts: Vec<String> = vec![
680        "find".into(),
681        shell_escape(&search_path.to_string_lossy()),
682        "-name".into(),
683        shell_escape(name_pattern),
684        "-not".into(),
685        "-path".into(),
686        "*/node_modules/*".into(),
687        "-not".into(),
688        "-path".into(),
689        "*/.git/*".into(),
690    ];
691
692    let command = cmd_parts.join(" ");
693    let exec_output = ops
694        .exec(&command, cwd)
695        .await
696        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run find: {}", e)))?;
697
698    let exit_code = exec_output.exit_code.unwrap_or(-1);
699    if exit_code != 0 && exit_code != 1 {
700        return Err(yoagent::types::ToolError::Failed(format!(
701            "find error: {}",
702            exec_output.stderr.trim()
703        )));
704    }
705
706    let lines: Vec<String> = exec_output
707        .stdout
708        .lines()
709        .map(|l| l.trim().to_string())
710        .filter(|l| !l.is_empty())
711        .collect();
712
713    if lines.is_empty() {
714        return Ok("No files found matching pattern".to_string());
715    }
716
717    let relativized: Vec<String> = lines
718        .into_iter()
719        .take(limit as usize)
720        .map(|line| relativize_path(&line, search_path))
721        .collect();
722
723    let mut output = relativized.join("\n");
724    if relativized.len() >= limit as usize {
725        output.push_str(&format!(
726            "\n\n[{} results limit reached. Use limit={} for more, or refine pattern]",
727            limit,
728            limit * 2,
729        ));
730    }
731
732    Ok(output)
733}
734
735// =====================================================================
736// ls tool
737// =====================================================================
738
739struct LsTool {
740    cwd: PathBuf,
741    operations: Arc<dyn LsOperations>,
742}
743
744#[async_trait]
745impl yoagent::types::AgentTool for LsTool {
746    fn name(&self) -> &str {
747        "ls"
748    }
749    fn label(&self) -> &str {
750        "ls"
751    }
752    fn description(&self) -> &str {
753        "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. \
754         Includes dotfiles. Output is truncated to 500 entries."
755    }
756    fn parameters_schema(&self) -> serde_json::Value {
757        serde_json::json!({
758            "type": "object",
759            "properties": {
760                "path": {
761                    "type": "string",
762                    "description": "Directory to list (default: current directory)"
763                },
764                "limit": {
765                    "type": "number",
766                    "description": "Maximum number of entries to return (default: 500)"
767                }
768            }
769        })
770    }
771
772    async fn execute(
773        &self,
774        params: serde_json::Value,
775        ctx: yoagent::types::ToolContext,
776    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
777        let search_path = params["path"].as_str().unwrap_or(".");
778        let limit = params["limit"].as_u64().unwrap_or(LS_DEFAULT_LIMIT);
779
780        let abs_path = resolve_path(search_path, &self.cwd);
781
782        if !self.operations.path_exists(&abs_path).unwrap_or(false) {
783            return Err(yoagent::types::ToolError::Failed(format!(
784                "Path not found: {}",
785                abs_path.display()
786            )));
787        }
788        if !self.operations.is_dir(&abs_path).unwrap_or(false) {
789            return Err(yoagent::types::ToolError::Failed(format!(
790                "Not a directory: {}",
791                abs_path.display()
792            )));
793        }
794
795        if ctx.cancel.is_cancelled() {
796            return Err(yoagent::types::ToolError::Cancelled);
797        }
798
799        let entries: Vec<String> = match self.operations.read_dir(&abs_path) {
800            Ok(items) => {
801                let mut items: Vec<(String, bool)> = items
802                    .into_iter()
803                    .map(|entry| (entry.name, entry.is_dir))
804                    .collect();
805                items.sort_by_key(|(a, _)| a.to_lowercase());
806                items
807                    .into_iter()
808                    .take(limit as usize)
809                    .map(
810                        |(name, is_dir)| {
811                            if is_dir { format!("{}/", name) } else { name }
812                        },
813                    )
814                    .collect()
815            }
816            Err(e) => {
817                return Err(yoagent::types::ToolError::Failed(format!(
818                    "Cannot read directory: {}",
819                    e
820                )));
821            }
822        };
823
824        if ctx.cancel.is_cancelled() {
825            return Err(yoagent::types::ToolError::Cancelled);
826        }
827
828        if entries.is_empty() {
829            return Ok(yoagent::types::ToolResult {
830                content: vec![yoagent::types::Content::Text {
831                    text: "(empty directory)".to_string(),
832                }],
833                details: serde_json::Value::Null,
834            });
835        }
836
837        let mut output = entries.join("\n");
838        if entries.len() >= limit as usize {
839            output.push_str(&format!(
840                "\n\n[{} entries limit reached. Use limit={} for more]",
841                limit,
842                limit * 2,
843            ));
844        }
845
846        Ok(yoagent::types::ToolResult {
847            content: vec![yoagent::types::Content::Text { text: output }],
848            details: serde_json::Value::Null,
849        })
850    }
851}
852
853// =====================================================================
854// Helpers
855// =====================================================================
856
857/// Shell-escape a string for safe use in `sh -c`.
858/// Wraps in single quotes and handles embedded single quotes
859/// (POSIX shell: everything inside '' is literal except ',
860/// which is written as '\'' to end the quote, add an escaped
861/// quote, and restart the quote).
862fn shell_escape(s: &str) -> String {
863    let mut result = String::with_capacity(s.len() + 2);
864    result.push('\'');
865    for c in s.chars() {
866        if c == '\'' {
867            result.push_str("'\\''");
868        } else {
869            result.push(c);
870        }
871    }
872    result.push('\'');
873    result
874}
875
876fn which(name: &str) -> Option<PathBuf> {
877    std::process::Command::new("which")
878        .arg(name)
879        .output()
880        .ok()
881        .filter(|o| o.status.success())
882        .map(|_| PathBuf::from(name))
883}
884
885fn resolve_path(path: &str, cwd: &Path) -> PathBuf {
886    if Path::new(path).is_absolute() {
887        Path::new(path).to_path_buf()
888    } else {
889        cwd.join(path)
890    }
891}
892
893fn relativize_path(path: &str, search_root: &Path) -> String {
894    let p = Path::new(path);
895    if let Ok(rel) = p.strip_prefix(search_root) {
896        rel.to_string_lossy().replace('\\', "/")
897    } else {
898        p.file_name()
899            .map(|n| n.to_string_lossy().to_string())
900            .unwrap_or_else(|| path.to_string())
901    }
902}
903
904fn truncate_line(line: &str, max_chars: usize) -> String {
905    if line.len() <= max_chars {
906        line.to_string()
907    } else {
908        format!("{}... [truncated]", &line[..max_chars])
909    }
910}
911
912fn shorten_path_str(path: &str) -> String {
913    if let Ok(home) = std::env::var("HOME") {
914        path.replacen(&home, "~", 1)
915    } else if path == "." || path.is_empty() {
916        ".".to_string()
917    } else {
918        path.to_string()
919    }
920}
921
922// =====================================================================
923// Shared list renderer (used by grep, find, ls)
924// =====================================================================
925
926struct ListRenderer {
927    tool_name: &'static str,
928    /// Format: "{pattern}" or "/{pattern}/" or empty for no pattern.
929    pattern_format: &'static str,
930    no_results_text: &'static str,
931    collapsed_lines: usize,
932    show_glob: bool,
933}
934
935impl ListRenderer {
936    fn grep() -> Self {
937        Self {
938            tool_name: "grep",
939            pattern_format: "/{}/ ",
940            no_results_text: "No matches found",
941            collapsed_lines: 15,
942            show_glob: true,
943        }
944    }
945
946    fn find() -> Self {
947        Self {
948            tool_name: "find",
949            pattern_format: "{} in ",
950            no_results_text: "No files found matching pattern",
951            collapsed_lines: 20,
952            show_glob: false,
953        }
954    }
955
956    fn ls() -> Self {
957        Self {
958            tool_name: "ls",
959            pattern_format: "",
960            no_results_text: "(empty directory)",
961            collapsed_lines: 20,
962            show_glob: false,
963        }
964    }
965}
966
967impl ToolRenderer for ListRenderer {
968    fn render_call(
969        &self,
970        args: &serde_json::Value,
971        _width: usize,
972        theme: &dyn Theme,
973        _ctx: &ToolRenderContext,
974    ) -> Vec<String> {
975        let search_path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
976        let limit = args.get("limit").and_then(|v| v.as_u64());
977        let path_display = shorten_path_str(search_path);
978
979        let mut text = format!(
980            "{} {}{}",
981            theme.fg_key(ThemeKey::ToolTitle, &theme.bold(self.tool_name)),
982            if self.pattern_format.is_empty() {
983                String::new()
984            } else {
985                let p = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
986                theme.fg_key(ThemeKey::Accent, &self.pattern_format.replace("{}", p))
987            },
988            theme.fg_key(ThemeKey::ToolOutput, &path_display),
989        );
990
991        if self.show_glob
992            && let Some(g) = args.get("glob").and_then(|v| v.as_str())
993        {
994            text.push_str(&theme.fg_key(ThemeKey::ToolOutput, &format!(" ({})", g)));
995        }
996        if let Some(l) = limit {
997            text.push_str(&theme.fg_key(ThemeKey::ToolOutput, &format!(" limit {}", l)));
998        }
999
1000        vec![text]
1001    }
1002
1003    fn render_result(
1004        &self,
1005        content: &str,
1006        _width: usize,
1007        theme: &dyn Theme,
1008        ctx: &ToolRenderContext,
1009    ) -> Vec<String> {
1010        if content.is_empty() {
1011            return vec![];
1012        }
1013        if !ctx.expanded && !ctx.is_error {
1014            return vec![];
1015        }
1016
1017        let output = content.trim();
1018        if output.is_empty() || output == self.no_results_text {
1019            return vec![theme.fg_key(ThemeKey::ToolOutput, output)];
1020        }
1021
1022        let lines: Vec<&str> = output.lines().collect();
1023        let max_lines = if ctx.expanded {
1024            usize::MAX
1025        } else {
1026            self.collapsed_lines
1027        };
1028        let display: Vec<&str> = lines.iter().copied().take(max_lines).collect();
1029        let remaining = lines.len().saturating_sub(display.len());
1030
1031        let mut result = vec![String::new()];
1032        for line in &display {
1033            result.push(theme.fg_key(ThemeKey::ToolOutput, line));
1034        }
1035        if remaining > 0 {
1036            let hint = if !ctx.expand_key.is_empty() {
1037                format!(
1038                    "... ({} more lines, {} to expand)",
1039                    remaining, ctx.expand_key
1040                )
1041            } else {
1042                format!("... ({} more lines)", remaining)
1043            };
1044            result.push(theme.fg_key(ThemeKey::Muted, &hint));
1045        }
1046        result
1047    }
1048}