steer_tools/tools/
ls.rs

1use ignore::WalkBuilder;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use steer_macros::tool;
6use thiserror::Error;
7use tokio::task;
8
9use crate::result::{FileEntry, FileListResult};
10use crate::{ExecutionContext, ToolError};
11
12#[derive(Debug, Error)]
13pub enum LsError {
14    #[error("Path is not a directory: {path}")]
15    NotADirectory { path: String },
16
17    #[error("Operation was cancelled")]
18    Cancelled,
19
20    #[error("Task join error: {source}")]
21    TaskJoinError {
22        #[from]
23        #[source]
24        source: tokio::task::JoinError,
25    },
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct LsParams {
30    /// The absolute path to the directory to list (must be absolute, not relative)
31    pub path: String,
32    /// Optional list of glob patterns to ignore
33    pub ignore: Option<Vec<String>>,
34}
35
36tool! {
37    LsTool {
38        params: LsParams,
39        output: FileListResult,
40        variant: FileList,
41        description: "Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You should generally prefer the Glob and Grep tools, if you know which directories to search.",
42        name: "ls",
43        require_approval: false
44    }
45
46    async fn run(
47        _tool: &LsTool,
48        params: LsParams,
49        context: &ExecutionContext,
50    ) -> Result<FileListResult, ToolError> {
51        if context.is_cancelled() {
52            return Err(ToolError::Cancelled(LS_TOOL_NAME.to_string()));
53        }
54
55        let target_path = if Path::new(&params.path).is_absolute() {
56            params.path.clone()
57        } else {
58            context.working_directory.join(&params.path)
59                .to_string_lossy()
60                .to_string()
61        };
62
63        let ignore_patterns = params.ignore.unwrap_or_default();
64        let cancellation_token = context.cancellation_token.clone();
65
66        // Run the blocking directory listing in a separate task
67        let result = task::spawn_blocking(move || {
68            list_directory_internal(&target_path, &ignore_patterns, &cancellation_token)
69        }).await;
70
71        match result {
72            Ok(listing_result) => listing_result.map_err(|e| ToolError::execution(LS_TOOL_NAME, e.to_string())),
73            Err(join_error) => {
74                let ls_error = LsError::TaskJoinError { source: join_error };
75                Err(ToolError::execution(LS_TOOL_NAME, ls_error.to_string()))
76            }
77        }
78    }
79}
80
81fn list_directory_internal(
82    path_str: &str,
83    ignore_patterns: &[String],
84    cancellation_token: &tokio_util::sync::CancellationToken,
85) -> Result<FileListResult, LsError> {
86    let path = Path::new(path_str);
87    if !path.is_dir() {
88        return Err(LsError::NotADirectory {
89            path: path_str.to_string(),
90        });
91    }
92
93    if cancellation_token.is_cancelled() {
94        return Err(LsError::Cancelled);
95    }
96
97    let mut walk_builder = WalkBuilder::new(path);
98    walk_builder.max_depth(Some(1)); // Only list immediate children
99    walk_builder.git_ignore(true);
100    walk_builder.ignore(true);
101    walk_builder.hidden(false); // Show hidden files unless explicitly ignored
102
103    // Add custom ignore patterns
104    for pattern in ignore_patterns {
105        walk_builder.add_ignore(pattern);
106    }
107
108    let walker = walk_builder.build();
109    let mut entries = Vec::new();
110
111    for result in walker.skip(1) {
112        // Skip the root directory itself
113        if cancellation_token.is_cancelled() {
114            return Err(LsError::Cancelled);
115        }
116
117        match result {
118            Ok(entry) => {
119                let file_path = entry.path();
120                let file_name = file_path.file_name().unwrap_or_default().to_string_lossy();
121
122                // Create FileEntry
123                let metadata = file_path.metadata().ok();
124                let size = if file_path.is_dir() {
125                    None
126                } else {
127                    metadata.as_ref().map(|m| m.len())
128                };
129
130                entries.push(FileEntry {
131                    path: file_name.to_string(),
132                    is_directory: file_path.is_dir(),
133                    size,
134                    permissions: None, // Could add if needed
135                });
136            }
137            Err(e) => {
138                // Log errors but don't include in the output
139                eprintln!("Error accessing entry: {e}");
140            }
141        }
142    }
143
144    // Sort entries by name
145    entries.sort_by(|a, b| {
146        // Directories first, then files
147        match (a.is_directory, b.is_directory) {
148            (true, false) => std::cmp::Ordering::Less,
149            (false, true) => std::cmp::Ordering::Greater,
150            _ => a.path.cmp(&b.path),
151        }
152    });
153
154    Ok(FileListResult {
155        entries,
156        base_path: path_str.to_string(),
157    })
158}