mcp_servers/coding/tools/list_files/
mod.rs1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::time::SystemTime;
4
5use crate::coding::error::ListFilesError;
6use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta, basename};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "camelCase")]
10pub struct ListFilesArgs {
11 pub path: Option<String>,
13 #[serde(alias = "include_hidden")]
15 pub include_hidden: Option<bool>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19pub enum FileType {
20 File,
21 Directory,
22 Symlink,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct FileInfo {
28 pub name: String,
29 pub path: String,
30 pub file_type: FileType,
31 pub size: u64,
32 pub permissions: String,
33 pub modified: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(rename_all = "camelCase")]
38pub struct ListFilesResult {
39 pub status: String,
40 pub directory: String,
41 pub files: Vec<FileInfo>,
42 pub total_count: usize,
43 #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
45 #[schemars(skip)]
46 pub meta: Option<ToolResultMeta>,
47}
48
49pub async fn list_files(args: ListFilesArgs) -> Result<ListFilesResult, ListFilesError> {
50 use std::os::unix::fs::PermissionsExt;
51
52 let target_path = args.path.as_deref().filter(|p| !p.is_empty()).unwrap_or(".");
53 let include_hidden = args.include_hidden.unwrap_or(false);
54
55 let mut files = Vec::new();
56
57 let mut entries =
59 tokio::fs::read_dir(target_path).await.map_err(|e| ListFilesError::ReadDirFailed(e.to_string()))?;
60
61 while let Some(entry) = entries.next_entry().await.map_err(|e| ListFilesError::ReadEntryFailed(e.to_string()))? {
62 let path = entry.path();
63 let entry_file_type = entry.file_type().await.map_err(|e| ListFilesError::MetadataFailed(e.to_string()))?;
64 let metadata = entry.metadata().await.map_err(|e| ListFilesError::MetadataFailed(e.to_string()))?;
65
66 let name = entry.file_name().to_string_lossy().to_string();
67
68 if !include_hidden && name.starts_with('.') {
70 continue;
71 }
72
73 let file_type = if entry_file_type.is_symlink() {
74 FileType::Symlink
75 } else if metadata.is_dir() {
76 FileType::Directory
77 } else {
78 FileType::File
79 };
80
81 let permissions = format!("{:o}", metadata.permissions().mode() & 0o777);
82
83 let modified =
84 metadata.modified().ok().and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok()).and_then(
85 |duration| {
86 let secs = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
87 chrono::DateTime::from_timestamp(secs, 0).map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
88 },
89 );
90
91 files.push(FileInfo {
92 name,
93 path: path.to_string_lossy().to_string(),
94 file_type,
95 size: metadata.len(),
96 permissions,
97 modified,
98 });
99 }
100
101 files.sort_by(|a, b| a.name.cmp(&b.name));
103
104 let total_count = files.len();
105
106 let display_meta = ToolDisplayMeta::new("List files", format!("{} ({total_count} items)", basename(target_path)));
107
108 Ok(ListFilesResult {
109 status: "success".to_string(),
110 directory: target_path.to_string(),
111 files,
112 total_count,
113 meta: Some(display_meta.into()),
114 })
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use std::fs;
121 use std::os::unix::fs::symlink;
122 use tempfile::TempDir;
123
124 #[tokio::test]
125 async fn list_files_reports_symlink_for_directory_symlink() {
126 let temp_dir = TempDir::new().unwrap();
127 let sub_dir = temp_dir.path().join("real_dir");
128 fs::create_dir(&sub_dir).unwrap();
129 let link_path = temp_dir.path().join("link_dir");
130 symlink(&sub_dir, &link_path).unwrap();
131
132 let result = list_files(ListFilesArgs {
133 path: Some(temp_dir.path().to_string_lossy().to_string()),
134 include_hidden: Some(true),
135 })
136 .await
137 .unwrap();
138
139 let file_info =
140 result.files.iter().find(|file| file.name == "link_dir").expect("directory symlink should be returned");
141 assert!(matches!(file_info.file_type, FileType::Symlink));
142 }
143
144 #[tokio::test]
145 async fn list_files_reports_symlink_type() {
146 let temp_dir = TempDir::new().unwrap();
147 let target_path = temp_dir.path().join("target.txt");
148 let link_path = temp_dir.path().join("link.txt");
149 fs::write(&target_path, "hello").unwrap();
150 symlink(&target_path, &link_path).unwrap();
151
152 let result = list_files(ListFilesArgs {
153 path: Some(temp_dir.path().to_string_lossy().to_string()),
154 include_hidden: Some(true),
155 })
156 .await
157 .unwrap();
158
159 let file_info = result.files.iter().find(|file| file.name == "link.txt").expect("symlink should be returned");
160 assert!(matches!(file_info.file_type, FileType::Symlink));
161 }
162
163 #[test]
164 fn list_files_args_accepts_snake_case_include_hidden() {
165 let args: ListFilesArgs = serde_json::from_value(serde_json::json!({
166 "path": "/tmp",
167 "include_hidden": true
168 }))
169 .unwrap();
170
171 assert_eq!(args.path, Some("/tmp".to_string()));
172 assert_eq!(args.include_hidden, Some(true));
173 }
174
175 #[tokio::test]
176 async fn list_files_handles_empty_path_as_current_directory() {
177 let result = list_files(ListFilesArgs { path: Some(String::new()), include_hidden: None }).await.unwrap();
178 assert_eq!(result.directory, ".");
179 }
180}