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 pub path: String,
32 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(¶ms.path).is_absolute() {
56 params.path.clone()
57 } else {
58 context.working_directory.join(¶ms.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 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)); walk_builder.git_ignore(true);
100 walk_builder.ignore(true);
101 walk_builder.hidden(false); 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 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 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, });
136 }
137 Err(e) => {
138 eprintln!("Error accessing entry: {e}");
140 }
141 }
142 }
143
144 entries.sort_by(|a, b| {
146 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}