Skip to main content

max / audiofiles

38.4 KB · 1129 lines History Blame Raw
1 //! Virtual filesystem: manages VFS roots, directory trees, and sample links backed by SQLite.
2
3 use crate::db::Database;
4 use crate::error::{unix_now, CoreError, Result};
5 use crate::id_types::{NodeId, SampleHash, VfsId};
6 use tracing::instrument;
7
8 /// A virtual filesystem root (e.g. "Library", "Project A").
9 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10 pub struct Vfs {
11 pub id: VfsId,
12 pub name: String,
13 pub created_at: i64,
14 pub modified_at: i64,
15 /// Whether audio file blobs for this VFS should be synced to cloud (metadata always syncs).
16 pub sync_files: bool,
17 }
18
19 /// The two kinds of VFS node: directories and sample links.
20 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21 pub enum NodeType {
22 Directory,
23 Sample,
24 }
25
26 impl NodeType {
27 /// Return the SQLite-stored string representation (`"directory"` or `"sample"`).
28 pub fn as_str(&self) -> &'static str {
29 match self {
30 NodeType::Directory => "directory",
31 NodeType::Sample => "sample",
32 }
33 }
34
35 /// Parse a stored string back into a `NodeType`. Defaults to `Directory` for unknown values.
36 pub fn parse(s: &str) -> Self {
37 match s {
38 "sample" => NodeType::Sample,
39 // Default to Directory rather than returning an error because the
40 // schema only stores two values and a directory is the safe
41 // fallback: it has no sample_hash, so the worst case is an empty
42 // folder rather than a broken or missing node.
43 _ => NodeType::Directory,
44 }
45 }
46 }
47
48 /// A single node in a VFS tree (directory or sample link).
49 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50 pub struct VfsNode {
51 pub id: NodeId,
52 pub vfs_id: VfsId,
53 pub parent_id: Option<NodeId>,
54 pub name: String,
55 pub node_type: NodeType,
56 pub sample_hash: Option<SampleHash>,
57 pub created_at: i64,
58 }
59
60 // --- VFS roots ---
61
62 /// Create a new VFS root with the given name. Returns the new VFS ID.
63 #[instrument(skip_all)]
64 pub fn create_vfs(db: &Database, name: &str) -> Result<VfsId> {
65 let now = unix_now();
66 db.conn().execute(
67 "INSERT INTO vfs (name, created_at, modified_at) VALUES (?1, ?2, ?3)",
68 rusqlite::params![name, now, now],
69 )?;
70 Ok(VfsId::from(db.conn().last_insert_rowid()))
71 }
72
73 /// List all VFS roots, ordered alphabetically by name.
74 #[instrument(skip_all)]
75 pub fn list_vfs(db: &Database) -> Result<Vec<Vfs>> {
76 let mut stmt = db
77 .conn()
78 .prepare("SELECT id, name, created_at, modified_at, sync_files FROM vfs ORDER BY name")?;
79 let rows = stmt.query_map([], |row| {
80 Ok(Vfs {
81 id: row.get(0)?,
82 name: row.get(1)?,
83 created_at: row.get(2)?,
84 modified_at: row.get(3)?,
85 sync_files: row.get::<_, i32>(4)? != 0,
86 })
87 })?;
88 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
89 }
90
91 /// Rename a VFS root. Returns `VfsNotFound` if the ID doesn't exist.
92 #[instrument(skip_all)]
93 pub fn rename_vfs(db: &Database, id: VfsId, new_name: &str) -> Result<()> {
94 let now = unix_now();
95 let changed = db.conn().execute(
96 "UPDATE vfs SET name = ?1, modified_at = ?2 WHERE id = ?3",
97 rusqlite::params![new_name, now, id],
98 )?;
99 if changed == 0 {
100 return Err(CoreError::VfsNotFound(id));
101 }
102 Ok(())
103 }
104
105 /// Set whether a VFS root should sync its audio file blobs to cloud.
106 #[instrument(skip_all)]
107 pub fn set_vfs_sync_files(db: &Database, id: VfsId, enabled: bool) -> Result<()> {
108 let changed = db.conn().execute(
109 "UPDATE vfs SET sync_files = ?1 WHERE id = ?2",
110 rusqlite::params![enabled as i32, id],
111 )?;
112 if changed == 0 {
113 return Err(CoreError::VfsNotFound(id));
114 }
115 Ok(())
116 }
117
118 /// Get whether a VFS root has audio file blob syncing enabled.
119 #[instrument(skip_all)]
120 pub fn get_vfs_sync_files(db: &Database, id: VfsId) -> Result<bool> {
121 db.conn()
122 .query_row(
123 "SELECT sync_files FROM vfs WHERE id = ?1",
124 [id],
125 |row| row.get::<_, i32>(0),
126 )
127 .map(|v| v != 0)
128 .map_err(|e| match e {
129 rusqlite::Error::QueryReturnedNoRows => CoreError::VfsNotFound(id),
130 other => CoreError::Db(other),
131 })
132 }
133
134 /// Delete a VFS root and all its nodes (cascaded by foreign key). Returns `VfsNotFound` if missing.
135 #[instrument(skip_all)]
136 pub fn delete_vfs(db: &Database, id: VfsId) -> Result<()> {
137 let changed = db
138 .conn()
139 .execute("DELETE FROM vfs WHERE id = ?1", [id])?;
140 if changed == 0 {
141 return Err(CoreError::VfsNotFound(id));
142 }
143 Ok(())
144 }
145
146 // --- Nodes ---
147
148 /// Validate a VFS node name. Rejects empty names, path separators, reserved
149 /// names (`.` and `..`), and null bytes.
150 fn validate_node_name(name: &str) -> Result<()> {
151 if name.is_empty() {
152 return Err(CoreError::InvalidNodeName("name must not be empty".to_string()));
153 }
154 if name == "." || name == ".." {
155 return Err(CoreError::InvalidNodeName(format!(
156 "reserved name: {name}"
157 )));
158 }
159 if name.contains('/') || name.contains('\\') {
160 return Err(CoreError::InvalidNodeName(
161 "name must not contain path separators (/ or \\)".to_string(),
162 ));
163 }
164 if name.contains('\0') {
165 return Err(CoreError::InvalidNodeName(
166 "name must not contain null bytes".to_string(),
167 ));
168 }
169 Ok(())
170 }
171
172 /// Check for name conflicts at root level (parent_id IS NULL) since SQLite
173 /// UNIQUE treats each NULL as distinct.
174 fn check_root_name_conflict(
175 db: &Database,
176 vfs_id: VfsId,
177 parent_id: Option<NodeId>,
178 name: &str,
179 ) -> Result<()> {
180 if parent_id.is_none() {
181 let count: i64 = db.conn().query_row(
182 "SELECT COUNT(*) FROM vfs_nodes WHERE vfs_id = ?1 AND parent_id IS NULL AND name = ?2",
183 rusqlite::params![vfs_id, name],
184 |row| row.get(0),
185 )?;
186 if count > 0 {
187 return Err(CoreError::NameConflict(name.to_string()));
188 }
189 }
190 Ok(())
191 }
192
193 /// Check for sibling name conflicts, handling both root (NULL parent) and
194 /// non-root cases. Excludes `exclude_id` so a node doesn't conflict with itself
195 /// (needed for rename).
196 fn check_sibling_name_conflict(
197 db: &Database,
198 vfs_id: VfsId,
199 parent_id: Option<NodeId>,
200 name: &str,
201 exclude_id: NodeId,
202 ) -> Result<()> {
203 let count: i64 = match parent_id {
204 None => db.conn().query_row(
205 "SELECT COUNT(*) FROM vfs_nodes \
206 WHERE vfs_id = ?1 AND parent_id IS NULL AND name = ?2 AND id != ?3",
207 rusqlite::params![vfs_id, name, exclude_id],
208 |row| row.get(0),
209 )?,
210 Some(pid) => db.conn().query_row(
211 "SELECT COUNT(*) FROM vfs_nodes \
212 WHERE parent_id = ?1 AND name = ?2 AND id != ?3",
213 rusqlite::params![pid, name, exclude_id],
214 |row| row.get(0),
215 )?,
216 };
217 if count > 0 {
218 return Err(CoreError::NameConflict(name.to_string()));
219 }
220 Ok(())
221 }
222
223 /// Create a directory node under the given parent (or at root if `None`). Returns the new node ID.
224 #[instrument(skip_all)]
225 pub fn create_directory(
226 db: &Database,
227 vfs_id: VfsId,
228 parent_id: Option<NodeId>,
229 name: &str,
230 ) -> Result<NodeId> {
231 validate_node_name(name)?;
232 check_root_name_conflict(db, vfs_id, parent_id, name)?;
233 let now = unix_now();
234 db.conn().execute(
235 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at)
236 VALUES (?1, ?2, ?3, 'directory', ?4)",
237 rusqlite::params![vfs_id, parent_id, name, now],
238 )?;
239 Ok(NodeId::from(db.conn().last_insert_rowid()))
240 }
241
242 /// Create a sample link node pointing to a content-addressed hash. Returns the new node ID.
243 #[instrument(skip_all)]
244 pub fn create_sample_link(
245 db: &Database,
246 vfs_id: VfsId,
247 parent_id: Option<NodeId>,
248 name: &str,
249 sample_hash: &str,
250 ) -> Result<NodeId> {
251 validate_node_name(name)?;
252 check_root_name_conflict(db, vfs_id, parent_id, name)?;
253 let now = unix_now();
254 db.conn().execute(
255 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at)
256 VALUES (?1, ?2, ?3, 'sample', ?4, ?5)",
257 rusqlite::params![vfs_id, parent_id, name, sample_hash, now],
258 )?;
259 Ok(NodeId::from(db.conn().last_insert_rowid()))
260 }
261
262 /// List direct children of a directory (or root if `parent_id` is `None`), sorted directories-first then by name.
263 #[instrument(skip_all)]
264 pub fn list_children(
265 db: &Database,
266 vfs_id: VfsId,
267 parent_id: Option<NodeId>,
268 ) -> Result<Vec<VfsNode>> {
269 let mut stmt = db.conn().prepare(
270 "SELECT id, vfs_id, parent_id, name, node_type, sample_hash, created_at
271 FROM vfs_nodes
272 WHERE vfs_id = ?1 AND parent_id IS ?2
273 ORDER BY node_type ASC, name ASC",
274 )?;
275 let rows = stmt.query_map(rusqlite::params![vfs_id, parent_id], |row| {
276 let nt: String = row.get(4)?;
277 Ok(VfsNode {
278 id: row.get(0)?,
279 vfs_id: row.get(1)?,
280 parent_id: row.get(2)?,
281 name: row.get(3)?,
282 node_type: NodeType::parse(&nt),
283 sample_hash: row.get(5)?,
284 created_at: row.get(6)?,
285 })
286 })?;
287 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
288 }
289
290 /// Fetch a single VFS node by ID. Returns `NodeNotFound` if absent.
291 #[instrument(skip_all)]
292 pub fn get_node(db: &Database, id: NodeId) -> Result<VfsNode> {
293 db.conn()
294 .query_row(
295 "SELECT id, vfs_id, parent_id, name, node_type, sample_hash, created_at
296 FROM vfs_nodes WHERE id = ?1",
297 [id],
298 |row| {
299 let nt: String = row.get(4)?;
300 Ok(VfsNode {
301 id: row.get(0)?,
302 vfs_id: row.get(1)?,
303 parent_id: row.get(2)?,
304 name: row.get(3)?,
305 node_type: NodeType::parse(&nt),
306 sample_hash: row.get(5)?,
307 created_at: row.get(6)?,
308 })
309 },
310 )
311 .map_err(|e| match e {
312 rusqlite::Error::QueryReturnedNoRows => CoreError::NodeNotFound(id),
313 other => CoreError::Db(other),
314 })
315 }
316
317 /// Rename a VFS node. Returns `NodeNotFound` if the ID doesn't exist,
318 /// `NameConflict` if a sibling with the same name already exists.
319 #[instrument(skip_all)]
320 pub fn rename_node(db: &Database, id: NodeId, new_name: &str) -> Result<()> {
321 validate_node_name(new_name)?;
322 let node = get_node(db, id)?;
323 check_sibling_name_conflict(db, node.vfs_id, node.parent_id, new_name, id)?;
324 let changed = db.conn().execute(
325 "UPDATE vfs_nodes SET name = ?1 WHERE id = ?2",
326 rusqlite::params![new_name, id],
327 )?;
328 if changed == 0 {
329 return Err(CoreError::NodeNotFound(id));
330 }
331 Ok(())
332 }
333
334 /// Move a VFS node to a new parent directory (or root if `None`).
335 ///
336 /// Returns an error if the move would create a circular parent reference,
337 /// cross a VFS boundary, or conflict with an existing sibling name.
338 #[instrument(skip_all)]
339 pub fn move_node(db: &Database, id: NodeId, new_parent_id: Option<NodeId>) -> Result<()> {
340 let node = get_node(db, id)?;
341
342 // Reject cross-VFS moves.
343 if let Some(parent) = new_parent_id {
344 let parent_node = get_node(db, parent)?;
345 if parent_node.vfs_id != node.vfs_id {
346 return Err(CoreError::Internal(
347 "cannot move a node to a different VFS".to_string(),
348 ));
349 }
350 }
351
352 // Check for circular reference: walk from new_parent_id up to root.
353 // If we encounter `id` along the way, the move would create a cycle.
354 if let Some(parent) = new_parent_id {
355 let mut current = Some(parent);
356 while let Some(cur_id) = current {
357 if cur_id == id {
358 return Err(CoreError::Internal(
359 "move would create a circular parent reference".to_string(),
360 ));
361 }
362 let cur_node = get_node(db, cur_id)?;
363 current = cur_node.parent_id;
364 }
365 }
366
367 // Check for name conflicts at the destination.
368 check_sibling_name_conflict(db, node.vfs_id, new_parent_id, &node.name, id)?;
369
370 let changed = db.conn().execute(
371 "UPDATE vfs_nodes SET parent_id = ?1 WHERE id = ?2",
372 rusqlite::params![new_parent_id, id],
373 )?;
374 if changed == 0 {
375 return Err(CoreError::NodeNotFound(id));
376 }
377 Ok(())
378 }
379
380 /// Delete a VFS node. Child nodes are removed by `ON DELETE CASCADE`.
381 #[instrument(skip_all)]
382 pub fn delete_node(db: &Database, id: NodeId) -> Result<()> {
383 let changed = db
384 .conn()
385 .execute("DELETE FROM vfs_nodes WHERE id = ?1", [id])?;
386 if changed == 0 {
387 return Err(CoreError::NodeNotFound(id));
388 }
389 Ok(())
390 }
391
392 /// A VFS node enriched with analysis data for display in the file list.
393 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
394 pub struct VfsNodeWithAnalysis {
395 pub node: VfsNode,
396 pub bpm: Option<f64>,
397 pub musical_key: Option<String>,
398 pub duration: Option<f64>,
399 pub classification: Option<String>,
400 pub peak_db: Option<f64>,
401 pub is_loop: Option<bool>,
402 pub cloud_only: bool,
403 pub tags: Vec<String>,
404 }
405
406 /// Map a SQLite row from the standard enriched query
407 /// (vfs_nodes LEFT JOIN audio_analysis LEFT JOIN samples).
408 /// Expected column order:
409 /// 0: id, 1: vfs_id, 2: parent_id, 3: name, 4: node_type,
410 /// 5: sample_hash, 6: created_at, 7: bpm, 8: musical_key,
411 /// 9: duration, 10: classification, 11: peak_db, 12: is_loop,
412 /// 13: cloud_only.
413 pub fn map_enriched_row(row: &rusqlite::Row) -> rusqlite::Result<VfsNodeWithAnalysis> {
414 let nt: String = row.get(4)?;
415 let cloud_only_raw: Option<i32> = row.get(13)?;
416 Ok(VfsNodeWithAnalysis {
417 node: VfsNode {
418 id: row.get(0)?,
419 vfs_id: row.get(1)?,
420 parent_id: row.get(2)?,
421 name: row.get(3)?,
422 node_type: NodeType::parse(&nt),
423 sample_hash: row.get(5)?,
424 created_at: row.get(6)?,
425 },
426 bpm: row.get(7)?,
427 musical_key: row.get(8)?,
428 duration: row.get(9)?,
429 classification: row.get(10)?,
430 peak_db: row.get(11)?,
431 is_loop: row.get(12)?,
432 cloud_only: cloud_only_raw.unwrap_or(0) != 0,
433 tags: Vec::new(), // filled separately if needed
434 })
435 }
436
437 /// List children with analysis data joined in.
438 #[instrument(skip_all)]
439 pub fn list_children_enriched(
440 db: &Database,
441 vfs_id: VfsId,
442 parent_id: Option<NodeId>,
443 ) -> Result<Vec<VfsNodeWithAnalysis>> {
444 let mut stmt = db.conn().prepare(
445 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
446 a.bpm, a.musical_key, COALESCE(a.duration, s.duration), a.classification, a.peak_db, a.is_loop,
447 s.cloud_only
448 FROM vfs_nodes n
449 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
450 LEFT JOIN samples s ON n.sample_hash = s.hash
451 WHERE n.vfs_id = ?1 AND n.parent_id IS ?2
452 ORDER BY n.node_type ASC, n.name ASC",
453 )?;
454 let rows = stmt.query_map(rusqlite::params![vfs_id, parent_id], map_enriched_row)?;
455 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
456 }
457
458 /// Recursively collect a node and all its descendants (for undo snapshot before delete).
459 #[instrument(skip_all)]
460 pub fn collect_subtree(db: &Database, node_id: NodeId) -> Result<Vec<VfsNode>> {
461 let mut stmt = db.conn().prepare(
462 "WITH RECURSIVE subtree(id) AS (
463 SELECT ?1
464 UNION ALL
465 SELECT n.id FROM vfs_nodes n JOIN subtree s ON n.parent_id = s.id
466 )
467 SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at
468 FROM vfs_nodes n
469 JOIN subtree s ON n.id = s.id",
470 )?;
471 let rows = stmt.query_map([node_id], |row| {
472 let nt: String = row.get(4)?;
473 Ok(VfsNode {
474 id: row.get(0)?,
475 vfs_id: row.get(1)?,
476 parent_id: row.get(2)?,
477 name: row.get(3)?,
478 node_type: NodeType::parse(&nt),
479 sample_hash: row.get(5)?,
480 created_at: row.get(6)?,
481 })
482 })?;
483 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
484 }
485
486 /// List all directories in a VFS with full paths (for folder picker).
487 #[instrument(skip_all)]
488 pub fn list_all_directories(db: &Database, vfs_id: VfsId) -> Result<Vec<(NodeId, String)>> {
489 let mut stmt = db.conn().prepare(
490 "WITH RECURSIVE dir_paths(id, path) AS (
491 SELECT id, name FROM vfs_nodes
492 WHERE vfs_id = ?1 AND parent_id IS NULL AND node_type = 'directory'
493 UNION ALL
494 SELECT n.id, dp.path || '/' || n.name
495 FROM vfs_nodes n
496 JOIN dir_paths dp ON n.parent_id = dp.id
497 WHERE n.node_type = 'directory'
498 )
499 SELECT id, path FROM dir_paths ORDER BY path",
500 )?;
501 let rows = stmt.query_map([vfs_id], |row| Ok((row.get(0)?, row.get(1)?)))?;
502 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
503 }
504
505 /// A flattened VFS node with its full path from root, used by the mirror sync.
506 #[derive(Debug, Clone)]
507 pub struct FullTreeNode {
508 /// Full path from VFS root, e.g. `"Library/Drums/Kicks/808 Deep.wav"`.
509 pub path: String,
510 pub node_type: NodeType,
511 pub sample_hash: Option<SampleHash>,
512 }
513
514 /// List every node across all VFS roots with full reconstructed paths.
515 ///
516 /// Returns a flat list sorted by path. Each path is prefixed with the VFS name
517 /// (e.g. `"Library/Drums/kick.wav"`). Used by the VFS mirror to build symlink trees.
518 #[instrument(skip_all)]
519 pub fn list_full_tree(db: &Database) -> Result<Vec<FullTreeNode>> {
520 let mut stmt = db.conn().prepare(
521 "WITH RECURSIVE tree(id, path, node_type, sample_hash) AS (
522 SELECT n.id, v.name || '/' || n.name,
523 n.node_type, n.sample_hash
524 FROM vfs_nodes n JOIN vfs v ON n.vfs_id = v.id
525 WHERE n.parent_id IS NULL
526 UNION ALL
527 SELECT n.id, t.path || '/' || n.name,
528 n.node_type, n.sample_hash
529 FROM vfs_nodes n JOIN tree t ON n.parent_id = t.id
530 )
531 SELECT path, node_type, sample_hash FROM tree ORDER BY path",
532 )?;
533 let rows = stmt.query_map([], |row| {
534 let nt: String = row.get(1)?;
535 Ok(FullTreeNode {
536 path: row.get(0)?,
537 node_type: NodeType::parse(&nt),
538 sample_hash: row.get(2)?,
539 })
540 })?;
541 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
542 }
543
544 /// Re-insert a previously deleted node (for undo). Preserves original ID.
545 #[instrument(skip_all)]
546 pub fn restore_node(db: &Database, node: &VfsNode) -> Result<()> {
547 match node.node_type {
548 NodeType::Directory => {
549 db.conn().execute(
550 "INSERT OR IGNORE INTO vfs_nodes (id, vfs_id, parent_id, name, node_type, created_at)
551 VALUES (?1, ?2, ?3, ?4, 'directory', ?5)",
552 rusqlite::params![node.id, node.vfs_id, node.parent_id, node.name, node.created_at],
553 )?;
554 }
555 NodeType::Sample => {
556 db.conn().execute(
557 "INSERT OR IGNORE INTO vfs_nodes (id, vfs_id, parent_id, name, node_type, sample_hash, created_at)
558 VALUES (?1, ?2, ?3, ?4, 'sample', ?5, ?6)",
559 rusqlite::params![node.id, node.vfs_id, node.parent_id, node.name, node.sample_hash, node.created_at],
560 )?;
561 }
562 }
563 Ok(())
564 }
565
566 /// Walk from a node up to the VFS root, returning the path root→node.
567 #[instrument(skip_all)]
568 pub fn get_breadcrumb(db: &Database, node_id: NodeId) -> Result<Vec<VfsNode>> {
569 let mut path = Vec::new();
570 let mut current_id = Some(node_id);
571
572 while let Some(id) = current_id {
573 let node = get_node(db, id)?;
574 current_id = node.parent_id;
575 path.push(node);
576 }
577
578 path.reverse();
579 Ok(path)
580 }
581
582 /// Find VFS nodes by sample hashes within a specific VFS, preserving the input order.
583 /// Returns one node per hash (the first match if duplicates exist).
584 #[instrument(skip_all)]
585 pub fn find_nodes_by_hashes(
586 db: &Database,
587 vfs_id: VfsId,
588 hashes: &[&str],
589 ) -> Result<Vec<VfsNodeWithAnalysis>> {
590 if hashes.is_empty() {
591 return Ok(Vec::new());
592 }
593 let placeholders: Vec<String> = hashes
594 .iter()
595 .enumerate()
596 .map(|(i, _)| format!("?{}", i + 2))
597 .collect();
598 let sql = format!(
599 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
600 a.bpm, a.musical_key, COALESCE(a.duration, s.duration), a.classification, a.peak_db, a.is_loop,
601 s.cloud_only
602 FROM vfs_nodes n
603 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
604 LEFT JOIN samples s ON n.sample_hash = s.hash
605 WHERE n.vfs_id = ?1 AND n.sample_hash IN ({})
606 GROUP BY n.sample_hash",
607 placeholders.join(", ")
608 );
609 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::with_capacity(hashes.len() + 1);
610 params.push(Box::new(vfs_id));
611 for h in hashes {
612 params.push(Box::new(h.to_string()));
613 }
614 let mut stmt = db.conn().prepare(&sql)?;
615 let rows = stmt.query_map(
616 params.iter().map(|p| p.as_ref()).collect::<Vec<_>>().as_slice(),
617 map_enriched_row,
618 )?;
619 let found: Vec<VfsNodeWithAnalysis> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
620
621 // Reorder to match input hash order
622 let mut by_hash: std::collections::HashMap<SampleHash, VfsNodeWithAnalysis> =
623 std::collections::HashMap::with_capacity(found.len());
624 for node in found {
625 if let Some(ref h) = node.node.sample_hash {
626 by_hash.entry(h.clone()).or_insert(node);
627 }
628 }
629 let mut ordered = Vec::with_capacity(hashes.len());
630 for h in hashes {
631 if let Some(node) = by_hash.remove(*h) {
632 ordered.push(node);
633 }
634 }
635 Ok(ordered)
636 }
637
638 #[cfg(test)]
639 mod tests {
640 use super::*;
641 use crate::test_helpers::insert_fake_sample;
642
643 fn setup() -> Database {
644 Database::open_in_memory().unwrap()
645 }
646
647 #[test]
648 fn create_and_list_vfs() {
649 let db = setup();
650 let id = create_vfs(&db, "Library").unwrap();
651 let list = list_vfs(&db).unwrap();
652 assert_eq!(list.len(), 1);
653 assert_eq!(list[0].id, id);
654 assert_eq!(list[0].name, "Library");
655 }
656
657 #[test]
658 fn rename_vfs_works() {
659 let db = setup();
660 let id = create_vfs(&db, "Old").unwrap();
661 rename_vfs(&db, id, "New").unwrap();
662 let list = list_vfs(&db).unwrap();
663 assert_eq!(list[0].name, "New");
664 }
665
666 #[test]
667 fn delete_vfs_cascades_nodes() {
668 let db = setup();
669 let vfs_id = create_vfs(&db, "Test").unwrap();
670 create_directory(&db, vfs_id, None, "folder").unwrap();
671
672 delete_vfs(&db, vfs_id).unwrap();
673
674 let count: i64 = db
675 .conn()
676 .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0))
677 .unwrap();
678 assert_eq!(count, 0);
679 }
680
681 #[test]
682 fn directory_tree_crud() {
683 let db = setup();
684 let vfs_id = create_vfs(&db, "Lib").unwrap();
685
686 let dir_id = create_directory(&db, vfs_id, None, "Drums").unwrap();
687 let sub_id = create_directory(&db, vfs_id, Some(dir_id), "Kicks").unwrap();
688
689 let root_children = list_children(&db, vfs_id, None).unwrap();
690 assert_eq!(root_children.len(), 1);
691 assert_eq!(root_children[0].name, "Drums");
692
693 let sub_children = list_children(&db, vfs_id, Some(dir_id)).unwrap();
694 assert_eq!(sub_children.len(), 1);
695 assert_eq!(sub_children[0].name, "Kicks");
696 assert_eq!(sub_children[0].id, sub_id);
697 }
698
699 #[test]
700 fn sample_links() {
701 let db = setup();
702 insert_fake_sample(&db, "abc123");
703 let vfs_id = create_vfs(&db, "Lib").unwrap();
704
705 let node_id =
706 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
707 let node = get_node(&db, node_id).unwrap();
708 assert_eq!(node.node_type, NodeType::Sample);
709 assert_eq!(node.sample_hash.as_deref(), Some("abc123"));
710 }
711
712 #[test]
713 fn rename_and_move_node() {
714 let db = setup();
715 let vfs_id = create_vfs(&db, "Lib").unwrap();
716 let dir_a = create_directory(&db, vfs_id, None, "A").unwrap();
717 let dir_b = create_directory(&db, vfs_id, None, "B").unwrap();
718 let child = create_directory(&db, vfs_id, Some(dir_a), "Child").unwrap();
719
720 rename_node(&db, child, "Renamed").unwrap();
721 let node = get_node(&db, child).unwrap();
722 assert_eq!(node.name, "Renamed");
723
724 move_node(&db, child, Some(dir_b)).unwrap();
725 let node = get_node(&db, child).unwrap();
726 assert_eq!(node.parent_id, Some(dir_b));
727 }
728
729 #[test]
730 fn delete_node_cascades() {
731 let db = setup();
732 let vfs_id = create_vfs(&db, "Lib").unwrap();
733 let parent = create_directory(&db, vfs_id, None, "Parent").unwrap();
734 create_directory(&db, vfs_id, Some(parent), "Child").unwrap();
735
736 delete_node(&db, parent).unwrap();
737
738 let count: i64 = db
739 .conn()
740 .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0))
741 .unwrap();
742 assert_eq!(count, 0);
743 }
744
745 #[test]
746 fn root_level_name_conflict() {
747 let db = setup();
748 let vfs_id = create_vfs(&db, "Lib").unwrap();
749 create_directory(&db, vfs_id, None, "Drums").unwrap();
750
751 let result = create_directory(&db, vfs_id, None, "Drums");
752 assert!(matches!(result, Err(CoreError::NameConflict(_))));
753 }
754
755 #[test]
756 fn breadcrumb_trail() {
757 let db = setup();
758 let vfs_id = create_vfs(&db, "Lib").unwrap();
759 let a = create_directory(&db, vfs_id, None, "A").unwrap();
760 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
761 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
762
763 let crumbs = get_breadcrumb(&db, c).unwrap();
764 assert_eq!(crumbs.len(), 3);
765 assert_eq!(crumbs[0].name, "A");
766 assert_eq!(crumbs[1].name, "B");
767 assert_eq!(crumbs[2].name, "C");
768 }
769
770 #[test]
771 fn list_children_sorts_dirs_first() {
772 let db = setup();
773 insert_fake_sample(&db, "sample1");
774 let vfs_id = create_vfs(&db, "Lib").unwrap();
775
776 create_sample_link(&db, vfs_id, None, "zzz.wav", "sample1").unwrap();
777 create_directory(&db, vfs_id, None, "AAA").unwrap();
778
779 let children = list_children(&db, vfs_id, None).unwrap();
780 assert_eq!(children[0].node_type, NodeType::Directory);
781 assert_eq!(children[1].node_type, NodeType::Sample);
782 }
783
784 #[test]
785 fn collect_subtree_flat() {
786 let db = setup();
787 let vfs_id = create_vfs(&db, "Lib").unwrap();
788 insert_fake_sample(&db, "s1");
789 let dir = create_directory(&db, vfs_id, None, "Dir").unwrap();
790 create_sample_link(&db, vfs_id, Some(dir), "s1.wav", "s1").unwrap();
791
792 let subtree = collect_subtree(&db, dir).unwrap();
793 assert_eq!(subtree.len(), 2); // dir + sample
794 assert!(subtree.iter().any(|n| n.name == "Dir"));
795 assert!(subtree.iter().any(|n| n.name == "s1.wav"));
796 }
797
798 #[test]
799 fn collect_subtree_nested() {
800 let db = setup();
801 let vfs_id = create_vfs(&db, "Lib").unwrap();
802 let a = create_directory(&db, vfs_id, None, "A").unwrap();
803 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
804 create_directory(&db, vfs_id, Some(b), "C").unwrap();
805
806 let subtree = collect_subtree(&db, a).unwrap();
807 assert_eq!(subtree.len(), 3);
808 let names: Vec<&str> = subtree.iter().map(|n| n.name.as_str()).collect();
809 assert!(names.contains(&"A"));
810 assert!(names.contains(&"B"));
811 assert!(names.contains(&"C"));
812 }
813
814 #[test]
815 fn collect_subtree_empty_dir() {
816 let db = setup();
817 let vfs_id = create_vfs(&db, "Lib").unwrap();
818 let dir = create_directory(&db, vfs_id, None, "Empty").unwrap();
819
820 let subtree = collect_subtree(&db, dir).unwrap();
821 assert_eq!(subtree.len(), 1);
822 assert_eq!(subtree[0].name, "Empty");
823 }
824
825 #[test]
826 fn list_all_directories_works() {
827 let db = setup();
828 let vfs_id = create_vfs(&db, "Lib").unwrap();
829 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
830 create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
831 create_directory(&db, vfs_id, Some(drums), "Snares").unwrap();
832 create_directory(&db, vfs_id, None, "Vocals").unwrap();
833
834 let dirs = list_all_directories(&db, vfs_id).unwrap();
835 let paths: Vec<&str> = dirs.iter().map(|(_, p)| p.as_str()).collect();
836 assert_eq!(
837 paths,
838 vec!["Drums", "Drums/Kicks", "Drums/Snares", "Vocals"]
839 );
840 }
841
842 #[test]
843 fn list_all_directories_empty_vfs() {
844 let db = setup();
845 let vfs_id = create_vfs(&db, "Lib").unwrap();
846 let dirs = list_all_directories(&db, vfs_id).unwrap();
847 assert!(dirs.is_empty());
848 }
849
850 #[test]
851 fn enriched_query_includes_cloud_only_false() {
852 let db = setup();
853 let vfs_id = create_vfs(&db, "Lib").unwrap();
854 crate::test_helpers::insert_fake_sample(&db, "hash1");
855 create_sample_link(&db, vfs_id, None, "kick.wav", "hash1").unwrap();
856
857 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
858 assert_eq!(nodes.len(), 1);
859 assert!(!nodes[0].cloud_only);
860 }
861
862 #[test]
863 fn enriched_query_includes_cloud_only_true() {
864 let db = setup();
865 let vfs_id = create_vfs(&db, "Lib").unwrap();
866 crate::test_helpers::insert_fake_sample(&db, "hash1");
867 create_sample_link(&db, vfs_id, None, "kick.wav", "hash1").unwrap();
868
869 // Set cloud_only=1 directly
870 db.conn()
871 .execute("UPDATE samples SET cloud_only = 1 WHERE hash = 'hash1'", [])
872 .unwrap();
873
874 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
875 assert_eq!(nodes.len(), 1);
876 assert!(nodes[0].cloud_only);
877 }
878
879 #[test]
880 fn enriched_query_directory_cloud_only_false() {
881 let db = setup();
882 let vfs_id = create_vfs(&db, "Lib").unwrap();
883 create_directory(&db, vfs_id, None, "Drums").unwrap();
884
885 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
886 assert_eq!(nodes.len(), 1);
887 // Directories have no sample_hash so cloud_only defaults to false
888 assert!(!nodes[0].cloud_only);
889 }
890
891 #[test]
892 fn find_nodes_by_hashes_returns_correct_results() {
893 let db = setup();
894 let vfs_id = create_vfs(&db, "Lib").unwrap();
895 insert_fake_sample(&db, "hash_a");
896 insert_fake_sample(&db, "hash_b");
897 insert_fake_sample(&db, "hash_c");
898 create_sample_link(&db, vfs_id, None, "a.wav", "hash_a").unwrap();
899 create_sample_link(&db, vfs_id, None, "b.wav", "hash_b").unwrap();
900 create_sample_link(&db, vfs_id, None, "c.wav", "hash_c").unwrap();
901
902 let results =
903 find_nodes_by_hashes(&db, vfs_id, &["hash_b", "hash_a"]).unwrap();
904
905 // Returns results in input order
906 assert_eq!(results.len(), 2);
907 assert_eq!(results[0].node.sample_hash.as_deref(), Some("hash_b"));
908 assert_eq!(results[1].node.sample_hash.as_deref(), Some("hash_a"));
909 }
910
911 #[test]
912 fn find_nodes_by_hashes_empty_input() {
913 let db = setup();
914 let vfs_id = create_vfs(&db, "Lib").unwrap();
915
916 let results = find_nodes_by_hashes(&db, vfs_id, &[]).unwrap();
917 assert!(results.is_empty());
918 }
919
920 #[test]
921 fn find_nodes_by_hashes_nonexistent_hash() {
922 let db = setup();
923 let vfs_id = create_vfs(&db, "Lib").unwrap();
924
925 let results =
926 find_nodes_by_hashes(&db, vfs_id, &["nonexistent"]).unwrap();
927 assert!(results.is_empty());
928 }
929
930 #[test]
931 fn find_nodes_by_hashes_includes_cloud_only() {
932 let db = setup();
933 let vfs_id = create_vfs(&db, "Lib").unwrap();
934 insert_fake_sample(&db, "hash_cloud");
935 create_sample_link(&db, vfs_id, None, "cloud.wav", "hash_cloud").unwrap();
936
937 db.conn()
938 .execute(
939 "UPDATE samples SET cloud_only = 1 WHERE hash = 'hash_cloud'",
940 [],
941 )
942 .unwrap();
943
944 let results =
945 find_nodes_by_hashes(&db, vfs_id, &["hash_cloud"]).unwrap();
946 assert_eq!(results.len(), 1);
947 assert!(results[0].cloud_only);
948 }
949
950 #[test]
951 fn move_node_rejects_circular_parent_to_child() {
952 let db = setup();
953 let vfs_id = create_vfs(&db, "Lib").unwrap();
954 let a = create_directory(&db, vfs_id, None, "A").unwrap();
955 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
956 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
957
958 // Moving A under C would create A -> B -> C -> A cycle
959 let result = move_node(&db, a, Some(c));
960 assert!(result.is_err());
961 let err_msg = format!("{}", result.unwrap_err());
962 assert!(err_msg.contains("circular"), "expected circular error, got: {err_msg}");
963 }
964
965 #[test]
966 fn move_node_allows_valid_reparent() {
967 let db = setup();
968 let vfs_id = create_vfs(&db, "Lib").unwrap();
969 let a = create_directory(&db, vfs_id, None, "A").unwrap();
970 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
971 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
972
973 // Moving C under A (skipping B) is valid — no cycle
974 move_node(&db, c, Some(a)).unwrap();
975 let node = get_node(&db, c).unwrap();
976 assert_eq!(node.parent_id, Some(a));
977 }
978
979 #[test]
980 fn move_node_to_root_succeeds() {
981 let db = setup();
982 let vfs_id = create_vfs(&db, "Lib").unwrap();
983 let a = create_directory(&db, vfs_id, None, "A").unwrap();
984 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
985
986 // Moving A to root is always valid
987 move_node(&db, a, None).unwrap();
988 let node = get_node(&db, a).unwrap();
989 assert_eq!(node.parent_id, None);
990
991 // Moving B to root is also valid
992 move_node(&db, b, None).unwrap();
993 let node = get_node(&db, b).unwrap();
994 assert_eq!(node.parent_id, None);
995 }
996
997 #[test]
998 fn move_node_rejects_self_as_parent() {
999 let db = setup();
1000 let vfs_id = create_vfs(&db, "Lib").unwrap();
1001 let a = create_directory(&db, vfs_id, None, "A").unwrap();
1002
1003 // Moving A under itself creates a trivial cycle
1004 let result = move_node(&db, a, Some(a));
1005 assert!(result.is_err());
1006 }
1007
1008 // --- Node name validation tests ---
1009
1010 #[test]
1011 fn validate_node_name_accepts_valid_names() {
1012 assert!(validate_node_name("kick.wav").is_ok());
1013 assert!(validate_node_name("My Folder").is_ok());
1014 assert!(validate_node_name("drums-2024").is_ok());
1015 assert!(validate_node_name("a").is_ok());
1016 assert!(validate_node_name("...").is_ok()); // three dots is fine
1017 }
1018
1019 #[test]
1020 fn validate_node_name_rejects_empty() {
1021 let result = validate_node_name("");
1022 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1023 }
1024
1025 #[test]
1026 fn validate_node_name_rejects_dot() {
1027 let result = validate_node_name(".");
1028 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1029 }
1030
1031 #[test]
1032 fn validate_node_name_rejects_dotdot() {
1033 let result = validate_node_name("..");
1034 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1035 }
1036
1037 #[test]
1038 fn validate_node_name_rejects_forward_slash() {
1039 let result = validate_node_name("foo/bar");
1040 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1041 }
1042
1043 #[test]
1044 fn validate_node_name_rejects_backslash() {
1045 let result = validate_node_name("foo\\bar");
1046 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1047 }
1048
1049 #[test]
1050 fn validate_node_name_rejects_null_byte() {
1051 let result = validate_node_name("foo\0bar");
1052 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1053 }
1054
1055 #[test]
1056 fn create_directory_rejects_invalid_name() {
1057 let db = setup();
1058 let vfs_id = create_vfs(&db, "Lib").unwrap();
1059 let result = create_directory(&db, vfs_id, None, "..");
1060 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1061 }
1062
1063 #[test]
1064 fn create_sample_link_rejects_invalid_name() {
1065 let db = setup();
1066 insert_fake_sample(&db, "hash1");
1067 let vfs_id = create_vfs(&db, "Lib").unwrap();
1068 let result = create_sample_link(&db, vfs_id, None, "foo/bar.wav", "hash1");
1069 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1070 }
1071
1072 #[test]
1073 fn rename_node_rejects_invalid_name() {
1074 let db = setup();
1075 let vfs_id = create_vfs(&db, "Lib").unwrap();
1076 let dir = create_directory(&db, vfs_id, None, "Valid").unwrap();
1077 let result = rename_node(&db, dir, "");
1078 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1079 }
1080
1081 // --- list_full_tree tests ---
1082
1083 #[test]
1084 fn list_full_tree_empty_vfs() {
1085 let db = setup();
1086 create_vfs(&db, "Lib").unwrap();
1087 let tree = list_full_tree(&db).unwrap();
1088 assert!(tree.is_empty());
1089 }
1090
1091 #[test]
1092 fn list_full_tree_builds_paths() {
1093 let db = setup();
1094 insert_fake_sample(&db, "s1");
1095 let vfs_id = create_vfs(&db, "Library").unwrap();
1096 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
1097 let kicks = create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
1098 create_sample_link(&db, vfs_id, Some(kicks), "808.wav", "s1").unwrap();
1099
1100 let tree = list_full_tree(&db).unwrap();
1101 let paths: Vec<&str> = tree.iter().map(|n| n.path.as_str()).collect();
1102 assert_eq!(
1103 paths,
1104 vec![
1105 "Library/Drums",
1106 "Library/Drums/Kicks",
1107 "Library/Drums/Kicks/808.wav",
1108 ]
1109 );
1110 // Last node is a sample with the correct hash
1111 assert_eq!(tree[2].node_type, NodeType::Sample);
1112 assert_eq!(tree[2].sample_hash.as_deref(), Some("s1"));
1113 }
1114
1115 #[test]
1116 fn list_full_tree_multiple_vfs() {
1117 let db = setup();
1118 insert_fake_sample(&db, "s1");
1119 let v1 = create_vfs(&db, "Alpha").unwrap();
1120 let v2 = create_vfs(&db, "Beta").unwrap();
1121 create_directory(&db, v1, None, "Dir1").unwrap();
1122 create_sample_link(&db, v2, None, "sample.wav", "s1").unwrap();
1123
1124 let tree = list_full_tree(&db).unwrap();
1125 let paths: Vec<&str> = tree.iter().map(|n| n.path.as_str()).collect();
1126 assert_eq!(paths, vec!["Alpha/Dir1", "Beta/sample.wav"]);
1127 }
1128 }
1129