Skip to main content

mcp_servers/coding/tools/list_files/
mod.rs

1use 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    /// Directory path to list (defaults to current directory if not provided)
12    pub path: Option<String>,
13    /// Include hidden files (starting with .)
14    #[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    /// Display metadata for human-friendly rendering
44    #[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    // Read directory entries
58    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        // Skip hidden files unless requested
69        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    // Sort by name for consistent output
102    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}