mixtape_tools/sqlite/database/
list.rs1use crate::prelude::*;
4use crate::sqlite::manager::DATABASE_MANAGER;
5use std::path::PathBuf;
6
7#[derive(Debug, Deserialize, JsonSchema)]
9pub struct ListDatabasesInput {
10 #[serde(default)]
12 pub directory: Option<PathBuf>,
13
14 #[serde(default)]
16 pub recursive: bool,
17}
18
19#[derive(Debug, Serialize, JsonSchema)]
21struct DatabaseFile {
22 path: String,
23 size_bytes: u64,
24 is_open: bool,
25}
26
27pub 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 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 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 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 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 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 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 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 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 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 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 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 let db = TestDatabase::with_name("opened.db").await;
299
300 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 let open_count = json["open_count"].as_i64().unwrap();
317 assert!(open_count >= 1, "Should have at least one open database");
318
319 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 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, recursive: false,
350 };
351
352 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}