Skip to main content

mixtape_tools/sqlite/database/
list.rs

1//! List databases tool
2
3use crate::prelude::*;
4use crate::sqlite::manager::DATABASE_MANAGER;
5use std::path::PathBuf;
6
7/// Input for listing database files
8#[derive(Debug, Deserialize, JsonSchema)]
9pub struct ListDatabasesInput {
10    /// Directory to search for database files. Defaults to current directory.
11    #[serde(default)]
12    pub directory: Option<PathBuf>,
13
14    /// Whether to search recursively (default: false)
15    #[serde(default)]
16    pub recursive: bool,
17}
18
19/// Database file information
20#[derive(Debug, Serialize, JsonSchema)]
21struct DatabaseFile {
22    path: String,
23    size_bytes: u64,
24    is_open: bool,
25}
26
27/// Tool for discovering SQLite database files in a directory
28///
29/// Searches for files with common SQLite extensions (.db, .sqlite, .sqlite3)
30/// and returns information about each found database.
31pub struct ListDatabasesTool;
32
33impl Tool for ListDatabasesTool {
34    type Input = ListDatabasesInput;
35
36    fn name(&self) -> &str {
37        "sqlite_list_databases"
38    }
39
40    fn description(&self) -> &str {
41        "Discover SQLite database files in a directory. Searches for .db, .sqlite, and .sqlite3 files. Also shows currently open databases."
42    }
43
44    async fn execute(&self, input: Self::Input) -> Result<ToolResult, ToolError> {
45        let directory = input
46            .directory
47            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
48
49        let recursive = input.recursive;
50
51        let result = tokio::task::spawn_blocking(move || {
52            let mut databases = Vec::new();
53            let extensions = ["db", "sqlite", "sqlite3"];
54
55            // Search for database files
56            let search_result = if recursive {
57                search_recursive(&directory, &extensions)
58            } else {
59                search_directory(&directory, &extensions)
60            };
61
62            if let Ok(files) = search_result {
63                for path in files {
64                    if let Ok(metadata) = std::fs::metadata(&path) {
65                        let path_str = path.to_string_lossy().to_string();
66                        databases.push(DatabaseFile {
67                            is_open: DATABASE_MANAGER.is_open(&path_str),
68                            path: path_str,
69                            size_bytes: metadata.len(),
70                        });
71                    }
72                }
73            }
74
75            // Also include currently open databases that might not be in the searched directory
76            for open_db in DATABASE_MANAGER.list_open() {
77                if !databases.iter().any(|d| d.path == open_db) {
78                    if let Ok(metadata) = std::fs::metadata(&open_db) {
79                        databases.push(DatabaseFile {
80                            path: open_db,
81                            size_bytes: metadata.len(),
82                            is_open: true,
83                        });
84                    }
85                }
86            }
87
88            databases
89        })
90        .await
91        .map_err(|e| ToolError::Custom(format!("Task join error: {}", e)))?;
92
93        let response = serde_json::json!({
94            "databases": result,
95            "count": result.len(),
96            "open_count": result.iter().filter(|d| d.is_open).count()
97        });
98
99        Ok(ToolResult::Json(response))
100    }
101}
102
103fn search_directory(dir: &PathBuf, extensions: &[&str]) -> std::io::Result<Vec<PathBuf>> {
104    let mut files = Vec::new();
105
106    for entry in std::fs::read_dir(dir)? {
107        let entry = entry?;
108        let path = entry.path();
109
110        if path.is_file() {
111            if let Some(ext) = path.extension() {
112                if extensions.iter().any(|e| ext == *e) {
113                    files.push(path);
114                }
115            }
116        }
117    }
118
119    Ok(files)
120}
121
122fn search_recursive(dir: &PathBuf, extensions: &[&str]) -> std::io::Result<Vec<PathBuf>> {
123    let mut files = Vec::new();
124
125    fn walk(dir: &PathBuf, extensions: &[&str], files: &mut Vec<PathBuf>) -> std::io::Result<()> {
126        for entry in std::fs::read_dir(dir)? {
127            let entry = entry?;
128            let path = entry.path();
129
130            if path.is_file() {
131                if let Some(ext) = path.extension() {
132                    if extensions.iter().any(|e| ext == *e) {
133                        files.push(path);
134                    }
135                }
136            } else if path.is_dir() {
137                // Skip hidden directories and common non-relevant directories
138                if let Some(name) = path.file_name() {
139                    let name = name.to_string_lossy();
140                    if !name.starts_with('.') && name != "node_modules" && name != "target" {
141                        let _ = walk(&path, extensions, files);
142                    }
143                }
144            }
145        }
146        Ok(())
147    }
148
149    walk(dir, extensions, &mut files)?;
150    Ok(files)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::sqlite::test_utils::TestDatabase;
157    use tempfile::TempDir;
158
159    #[tokio::test]
160    async fn test_list_databases_non_recursive() {
161        let temp_dir = TempDir::new().unwrap();
162
163        // Create test database files with different extensions
164        std::fs::write(temp_dir.path().join("test1.db"), "").unwrap();
165        std::fs::write(temp_dir.path().join("test2.sqlite"), "").unwrap();
166        std::fs::write(temp_dir.path().join("test3.sqlite3"), "").unwrap();
167        std::fs::write(temp_dir.path().join("not_a_db.txt"), "").unwrap();
168
169        let tool = ListDatabasesTool;
170        let input = ListDatabasesInput {
171            directory: Some(temp_dir.path().to_path_buf()),
172            recursive: false,
173        };
174
175        let result = tool.execute(input).await.unwrap();
176        let json = match result {
177            ToolResult::Json(v) => v,
178            _ => panic!("Expected JSON result"),
179        };
180
181        // Check that we found the expected database files
182        let databases = json["databases"].as_array().unwrap();
183        let paths: Vec<&str> = databases
184            .iter()
185            .filter_map(|d| d["path"].as_str())
186            .collect();
187
188        let temp_path = temp_dir.path().to_string_lossy();
189        let local_dbs: Vec<_> = paths
190            .iter()
191            .filter(|p| p.contains(temp_path.as_ref()))
192            .collect();
193        assert_eq!(
194            local_dbs.len(),
195            3,
196            "Should find all three db files in temp dir"
197        );
198    }
199
200    #[tokio::test]
201    async fn test_list_databases_recursive() {
202        let temp_dir = TempDir::new().unwrap();
203
204        // Create nested directory structure
205        let subdir = temp_dir.path().join("subdir");
206        std::fs::create_dir(&subdir).unwrap();
207        let nested = subdir.join("nested");
208        std::fs::create_dir(&nested).unwrap();
209
210        // Create test database files at various levels
211        std::fs::write(temp_dir.path().join("root.db"), "").unwrap();
212        std::fs::write(subdir.join("sub.sqlite"), "").unwrap();
213        std::fs::write(nested.join("deep.sqlite3"), "").unwrap();
214
215        let tool = ListDatabasesTool;
216        let input = ListDatabasesInput {
217            directory: Some(temp_dir.path().to_path_buf()),
218            recursive: true,
219        };
220
221        let result = tool.execute(input).await.unwrap();
222        let json = match result {
223            ToolResult::Json(v) => v,
224            _ => panic!("Expected JSON result"),
225        };
226
227        // Check that we found the expected database files
228        let databases = json["databases"].as_array().unwrap();
229        let paths: Vec<&str> = databases
230            .iter()
231            .filter_map(|d| d["path"].as_str())
232            .collect();
233
234        let temp_path = temp_dir.path().to_string_lossy();
235        let local_dbs: Vec<_> = paths
236            .iter()
237            .filter(|p| p.contains(temp_path.as_ref()))
238            .collect();
239        assert_eq!(
240            local_dbs.len(),
241            3,
242            "Should find all three at different levels"
243        );
244    }
245
246    #[tokio::test]
247    async fn test_list_databases_skips_hidden_dirs() {
248        let temp_dir = TempDir::new().unwrap();
249
250        // Create hidden directory
251        let hidden = temp_dir.path().join(".hidden");
252        std::fs::create_dir(&hidden).unwrap();
253        std::fs::write(hidden.join("hidden.db"), "").unwrap();
254
255        // Create normal file
256        std::fs::write(temp_dir.path().join("visible.db"), "").unwrap();
257
258        let tool = ListDatabasesTool;
259        let input = ListDatabasesInput {
260            directory: Some(temp_dir.path().to_path_buf()),
261            recursive: true,
262        };
263
264        let result = tool.execute(input).await.unwrap();
265        let json = match result {
266            ToolResult::Json(v) => v,
267            _ => panic!("Expected JSON result"),
268        };
269
270        // Check that we found only the visible database (not the one in .hidden)
271        let databases = json["databases"].as_array().unwrap();
272        let paths: Vec<&str> = databases
273            .iter()
274            .filter_map(|d| d["path"].as_str())
275            .collect();
276
277        let temp_path = temp_dir.path().to_string_lossy();
278        let local_dbs: Vec<_> = paths
279            .iter()
280            .filter(|p| p.contains(temp_path.as_ref()))
281            .collect();
282        assert_eq!(local_dbs.len(), 1, "Should only find visible.db");
283        assert!(
284            paths.iter().any(|p| p.contains("visible.db")),
285            "Should find visible.db"
286        );
287        assert!(
288            !paths.iter().any(|p| p.contains(".hidden")),
289            "Should not find hidden.db"
290        );
291    }
292
293    #[tokio::test]
294    async fn test_list_databases_shows_open_databases() {
295        let temp_dir = TempDir::new().unwrap();
296
297        // Create and open a test database
298        let db = TestDatabase::with_name("opened.db").await;
299
300        // Create another file in the temp dir that's not open
301        std::fs::write(temp_dir.path().join("closed.db"), "").unwrap();
302
303        let tool = ListDatabasesTool;
304        let input = ListDatabasesInput {
305            directory: Some(temp_dir.path().to_path_buf()),
306            recursive: false,
307        };
308
309        let result = tool.execute(input).await.unwrap();
310        let json = match result {
311            ToolResult::Json(v) => v,
312            _ => panic!("Expected JSON result"),
313        };
314
315        // The open database from TestDatabase should be included
316        let open_count = json["open_count"].as_i64().unwrap();
317        assert!(open_count >= 1, "Should have at least one open database");
318
319        // Check that the db key is detected
320        drop(db);
321    }
322
323    #[tokio::test]
324    async fn test_list_databases_empty_directory() {
325        let temp_dir = TempDir::new().unwrap();
326
327        let tool = ListDatabasesTool;
328        let input = ListDatabasesInput {
329            directory: Some(temp_dir.path().to_path_buf()),
330            recursive: false,
331        };
332
333        let result = tool.execute(input).await.unwrap();
334        let json = match result {
335            ToolResult::Json(v) => v,
336            _ => panic!("Expected JSON result"),
337        };
338
339        // Should return empty list for directory with no db files
340        // (may include open databases from other tests)
341        assert!(json["databases"].is_array());
342    }
343
344    #[tokio::test]
345    async fn test_list_databases_default_directory() {
346        let tool = ListDatabasesTool;
347        let input = ListDatabasesInput {
348            directory: None, // Use default (current directory)
349            recursive: false,
350        };
351
352        // Should not panic, even if no databases in current directory
353        let result = tool.execute(input).await;
354        assert!(result.is_ok());
355    }
356
357    #[test]
358    fn test_search_directory_helper() {
359        let temp_dir = TempDir::new().unwrap();
360        std::fs::write(temp_dir.path().join("test.db"), "").unwrap();
361        std::fs::write(temp_dir.path().join("test.txt"), "").unwrap();
362
363        let extensions = ["db", "sqlite"];
364        let files = search_directory(&temp_dir.path().to_path_buf(), &extensions).unwrap();
365
366        assert_eq!(files.len(), 1);
367        assert!(files[0].to_string_lossy().contains("test.db"));
368    }
369
370    #[test]
371    fn test_search_recursive_helper() {
372        let temp_dir = TempDir::new().unwrap();
373        let subdir = temp_dir.path().join("sub");
374        std::fs::create_dir(&subdir).unwrap();
375
376        std::fs::write(temp_dir.path().join("root.db"), "").unwrap();
377        std::fs::write(subdir.join("nested.db"), "").unwrap();
378
379        let extensions = ["db"];
380        let files = search_recursive(&temp_dir.path().to_path_buf(), &extensions).unwrap();
381
382        assert_eq!(files.len(), 2);
383    }
384
385    #[test]
386    fn test_tool_metadata() {
387        let tool = ListDatabasesTool;
388        assert_eq!(tool.name(), "sqlite_list_databases");
389        assert!(!tool.description().is_empty());
390    }
391}