Skip to main content

max / audiofiles

38.5 KB · 1131 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 AND s.deleted_at IS NULL
453 ORDER BY n.node_type ASC, n.name ASC",
454 )?;
455 let rows = stmt.query_map(rusqlite::params![vfs_id, parent_id], map_enriched_row)?;
456 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
457 }
458
459 /// Recursively collect a node and all its descendants (for undo snapshot before delete).
460 #[instrument(skip_all)]
461 pub fn collect_subtree(db: &Database, node_id: NodeId) -> Result<Vec<VfsNode>> {
462 let mut stmt = db.conn().prepare(
463 "WITH RECURSIVE subtree(id) AS (
464 SELECT ?1
465 UNION ALL
466 SELECT n.id FROM vfs_nodes n JOIN subtree s ON n.parent_id = s.id
467 )
468 SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at
469 FROM vfs_nodes n
470 JOIN subtree s ON n.id = s.id",
471 )?;
472 let rows = stmt.query_map([node_id], |row| {
473 let nt: String = row.get(4)?;
474 Ok(VfsNode {
475 id: row.get(0)?,
476 vfs_id: row.get(1)?,
477 parent_id: row.get(2)?,
478 name: row.get(3)?,
479 node_type: NodeType::parse(&nt),
480 sample_hash: row.get(5)?,
481 created_at: row.get(6)?,
482 })
483 })?;
484 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
485 }
486
487 /// List all directories in a VFS with full paths (for folder picker).
488 #[instrument(skip_all)]
489 pub fn list_all_directories(db: &Database, vfs_id: VfsId) -> Result<Vec<(NodeId, String)>> {
490 let mut stmt = db.conn().prepare(
491 "WITH RECURSIVE dir_paths(id, path) AS (
492 SELECT id, name FROM vfs_nodes
493 WHERE vfs_id = ?1 AND parent_id IS NULL AND node_type = 'directory'
494 UNION ALL
495 SELECT n.id, dp.path || '/' || n.name
496 FROM vfs_nodes n
497 JOIN dir_paths dp ON n.parent_id = dp.id
498 WHERE n.node_type = 'directory'
499 )
500 SELECT id, path FROM dir_paths ORDER BY path",
501 )?;
502 let rows = stmt.query_map([vfs_id], |row| Ok((row.get(0)?, row.get(1)?)))?;
503 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
504 }
505
506 /// A flattened VFS node with its full path from root, used by the mirror sync.
507 #[derive(Debug, Clone)]
508 pub struct FullTreeNode {
509 /// Full path from VFS root, e.g. `"Library/Drums/Kicks/808 Deep.wav"`.
510 pub path: String,
511 pub node_type: NodeType,
512 pub sample_hash: Option<SampleHash>,
513 }
514
515 /// List every node across all VFS roots with full reconstructed paths.
516 ///
517 /// Returns a flat list sorted by path. Each path is prefixed with the VFS name
518 /// (e.g. `"Library/Drums/kick.wav"`). Used by the VFS mirror to build symlink trees.
519 #[instrument(skip_all)]
520 pub fn list_full_tree(db: &Database) -> Result<Vec<FullTreeNode>> {
521 let mut stmt = db.conn().prepare(
522 "WITH RECURSIVE tree(id, path, node_type, sample_hash) AS (
523 SELECT n.id, v.name || '/' || n.name,
524 n.node_type, n.sample_hash
525 FROM vfs_nodes n JOIN vfs v ON n.vfs_id = v.id
526 WHERE n.parent_id IS NULL
527 UNION ALL
528 SELECT n.id, t.path || '/' || n.name,
529 n.node_type, n.sample_hash
530 FROM vfs_nodes n JOIN tree t ON n.parent_id = t.id
531 )
532 SELECT path, node_type, sample_hash FROM tree ORDER BY path",
533 )?;
534 let rows = stmt.query_map([], |row| {
535 let nt: String = row.get(1)?;
536 Ok(FullTreeNode {
537 path: row.get(0)?,
538 node_type: NodeType::parse(&nt),
539 sample_hash: row.get(2)?,
540 })
541 })?;
542 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
543 }
544
545 /// Re-insert a previously deleted node (for undo). Preserves original ID.
546 #[instrument(skip_all)]
547 pub fn restore_node(db: &Database, node: &VfsNode) -> Result<()> {
548 match node.node_type {
549 NodeType::Directory => {
550 db.conn().execute(
551 "INSERT OR IGNORE INTO vfs_nodes (id, vfs_id, parent_id, name, node_type, created_at)
552 VALUES (?1, ?2, ?3, ?4, 'directory', ?5)",
553 rusqlite::params![node.id, node.vfs_id, node.parent_id, node.name, node.created_at],
554 )?;
555 }
556 NodeType::Sample => {
557 db.conn().execute(
558 "INSERT OR IGNORE INTO vfs_nodes (id, vfs_id, parent_id, name, node_type, sample_hash, created_at)
559 VALUES (?1, ?2, ?3, ?4, 'sample', ?5, ?6)",
560 rusqlite::params![node.id, node.vfs_id, node.parent_id, node.name, node.sample_hash, node.created_at],
561 )?;
562 }
563 }
564 Ok(())
565 }
566
567 /// Walk from a node up to the VFS root, returning the path root→node.
568 #[instrument(skip_all)]
569 pub fn get_breadcrumb(db: &Database, node_id: NodeId) -> Result<Vec<VfsNode>> {
570 let mut path = Vec::new();
571 let mut current_id = Some(node_id);
572
573 while let Some(id) = current_id {
574 let node = get_node(db, id)?;
575 current_id = node.parent_id;
576 path.push(node);
577 }
578
579 path.reverse();
580 Ok(path)
581 }
582
583 /// Find VFS nodes by sample hashes within a specific VFS, preserving the input order.
584 /// Returns one node per hash (the first match if duplicates exist).
585 #[instrument(skip_all)]
586 pub fn find_nodes_by_hashes(
587 db: &Database,
588 vfs_id: VfsId,
589 hashes: &[&str],
590 ) -> Result<Vec<VfsNodeWithAnalysis>> {
591 if hashes.is_empty() {
592 return Ok(Vec::new());
593 }
594 let placeholders: Vec<String> = hashes
595 .iter()
596 .enumerate()
597 .map(|(i, _)| format!("?{}", i + 2))
598 .collect();
599 let sql = format!(
600 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
601 a.bpm, a.musical_key, COALESCE(a.duration, s.duration), a.classification, a.peak_db, a.is_loop,
602 s.cloud_only
603 FROM vfs_nodes n
604 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
605 LEFT JOIN samples s ON n.sample_hash = s.hash
606 WHERE n.vfs_id = ?1 AND n.sample_hash IN ({})
607 AND s.deleted_at IS NULL
608 GROUP BY n.sample_hash",
609 placeholders.join(", ")
610 );
611 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::with_capacity(hashes.len() + 1);
612 params.push(Box::new(vfs_id));
613 for h in hashes {
614 params.push(Box::new(h.to_string()));
615 }
616 let mut stmt = db.conn().prepare(&sql)?;
617 let rows = stmt.query_map(
618 params.iter().map(|p| p.as_ref()).collect::<Vec<_>>().as_slice(),
619 map_enriched_row,
620 )?;
621 let found: Vec<VfsNodeWithAnalysis> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
622
623 // Reorder to match input hash order
624 let mut by_hash: std::collections::HashMap<SampleHash, VfsNodeWithAnalysis> =
625 std::collections::HashMap::with_capacity(found.len());
626 for node in found {
627 if let Some(ref h) = node.node.sample_hash {
628 by_hash.entry(h.clone()).or_insert(node);
629 }
630 }
631 let mut ordered = Vec::with_capacity(hashes.len());
632 for h in hashes {
633 if let Some(node) = by_hash.remove(*h) {
634 ordered.push(node);
635 }
636 }
637 Ok(ordered)
638 }
639
640 #[cfg(test)]
641 mod tests {
642 use super::*;
643 use crate::test_helpers::insert_fake_sample;
644
645 fn setup() -> Database {
646 Database::open_in_memory().unwrap()
647 }
648
649 #[test]
650 fn create_and_list_vfs() {
651 let db = setup();
652 let id = create_vfs(&db, "Library").unwrap();
653 let list = list_vfs(&db).unwrap();
654 assert_eq!(list.len(), 1);
655 assert_eq!(list[0].id, id);
656 assert_eq!(list[0].name, "Library");
657 }
658
659 #[test]
660 fn rename_vfs_works() {
661 let db = setup();
662 let id = create_vfs(&db, "Old").unwrap();
663 rename_vfs(&db, id, "New").unwrap();
664 let list = list_vfs(&db).unwrap();
665 assert_eq!(list[0].name, "New");
666 }
667
668 #[test]
669 fn delete_vfs_cascades_nodes() {
670 let db = setup();
671 let vfs_id = create_vfs(&db, "Test").unwrap();
672 create_directory(&db, vfs_id, None, "folder").unwrap();
673
674 delete_vfs(&db, vfs_id).unwrap();
675
676 let count: i64 = db
677 .conn()
678 .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0))
679 .unwrap();
680 assert_eq!(count, 0);
681 }
682
683 #[test]
684 fn directory_tree_crud() {
685 let db = setup();
686 let vfs_id = create_vfs(&db, "Lib").unwrap();
687
688 let dir_id = create_directory(&db, vfs_id, None, "Drums").unwrap();
689 let sub_id = create_directory(&db, vfs_id, Some(dir_id), "Kicks").unwrap();
690
691 let root_children = list_children(&db, vfs_id, None).unwrap();
692 assert_eq!(root_children.len(), 1);
693 assert_eq!(root_children[0].name, "Drums");
694
695 let sub_children = list_children(&db, vfs_id, Some(dir_id)).unwrap();
696 assert_eq!(sub_children.len(), 1);
697 assert_eq!(sub_children[0].name, "Kicks");
698 assert_eq!(sub_children[0].id, sub_id);
699 }
700
701 #[test]
702 fn sample_links() {
703 let db = setup();
704 insert_fake_sample(&db, "abc123");
705 let vfs_id = create_vfs(&db, "Lib").unwrap();
706
707 let node_id =
708 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
709 let node = get_node(&db, node_id).unwrap();
710 assert_eq!(node.node_type, NodeType::Sample);
711 assert_eq!(node.sample_hash.as_deref(), Some("abc123"));
712 }
713
714 #[test]
715 fn rename_and_move_node() {
716 let db = setup();
717 let vfs_id = create_vfs(&db, "Lib").unwrap();
718 let dir_a = create_directory(&db, vfs_id, None, "A").unwrap();
719 let dir_b = create_directory(&db, vfs_id, None, "B").unwrap();
720 let child = create_directory(&db, vfs_id, Some(dir_a), "Child").unwrap();
721
722 rename_node(&db, child, "Renamed").unwrap();
723 let node = get_node(&db, child).unwrap();
724 assert_eq!(node.name, "Renamed");
725
726 move_node(&db, child, Some(dir_b)).unwrap();
727 let node = get_node(&db, child).unwrap();
728 assert_eq!(node.parent_id, Some(dir_b));
729 }
730
731 #[test]
732 fn delete_node_cascades() {
733 let db = setup();
734 let vfs_id = create_vfs(&db, "Lib").unwrap();
735 let parent = create_directory(&db, vfs_id, None, "Parent").unwrap();
736 create_directory(&db, vfs_id, Some(parent), "Child").unwrap();
737
738 delete_node(&db, parent).unwrap();
739
740 let count: i64 = db
741 .conn()
742 .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0))
743 .unwrap();
744 assert_eq!(count, 0);
745 }
746
747 #[test]
748 fn root_level_name_conflict() {
749 let db = setup();
750 let vfs_id = create_vfs(&db, "Lib").unwrap();
751 create_directory(&db, vfs_id, None, "Drums").unwrap();
752
753 let result = create_directory(&db, vfs_id, None, "Drums");
754 assert!(matches!(result, Err(CoreError::NameConflict(_))));
755 }
756
757 #[test]
758 fn breadcrumb_trail() {
759 let db = setup();
760 let vfs_id = create_vfs(&db, "Lib").unwrap();
761 let a = create_directory(&db, vfs_id, None, "A").unwrap();
762 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
763 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
764
765 let crumbs = get_breadcrumb(&db, c).unwrap();
766 assert_eq!(crumbs.len(), 3);
767 assert_eq!(crumbs[0].name, "A");
768 assert_eq!(crumbs[1].name, "B");
769 assert_eq!(crumbs[2].name, "C");
770 }
771
772 #[test]
773 fn list_children_sorts_dirs_first() {
774 let db = setup();
775 insert_fake_sample(&db, "sample1");
776 let vfs_id = create_vfs(&db, "Lib").unwrap();
777
778 create_sample_link(&db, vfs_id, None, "zzz.wav", "sample1").unwrap();
779 create_directory(&db, vfs_id, None, "AAA").unwrap();
780
781 let children = list_children(&db, vfs_id, None).unwrap();
782 assert_eq!(children[0].node_type, NodeType::Directory);
783 assert_eq!(children[1].node_type, NodeType::Sample);
784 }
785
786 #[test]
787 fn collect_subtree_flat() {
788 let db = setup();
789 let vfs_id = create_vfs(&db, "Lib").unwrap();
790 insert_fake_sample(&db, "s1");
791 let dir = create_directory(&db, vfs_id, None, "Dir").unwrap();
792 create_sample_link(&db, vfs_id, Some(dir), "s1.wav", "s1").unwrap();
793
794 let subtree = collect_subtree(&db, dir).unwrap();
795 assert_eq!(subtree.len(), 2); // dir + sample
796 assert!(subtree.iter().any(|n| n.name == "Dir"));
797 assert!(subtree.iter().any(|n| n.name == "s1.wav"));
798 }
799
800 #[test]
801 fn collect_subtree_nested() {
802 let db = setup();
803 let vfs_id = create_vfs(&db, "Lib").unwrap();
804 let a = create_directory(&db, vfs_id, None, "A").unwrap();
805 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
806 create_directory(&db, vfs_id, Some(b), "C").unwrap();
807
808 let subtree = collect_subtree(&db, a).unwrap();
809 assert_eq!(subtree.len(), 3);
810 let names: Vec<&str> = subtree.iter().map(|n| n.name.as_str()).collect();
811 assert!(names.contains(&"A"));
812 assert!(names.contains(&"B"));
813 assert!(names.contains(&"C"));
814 }
815
816 #[test]
817 fn collect_subtree_empty_dir() {
818 let db = setup();
819 let vfs_id = create_vfs(&db, "Lib").unwrap();
820 let dir = create_directory(&db, vfs_id, None, "Empty").unwrap();
821
822 let subtree = collect_subtree(&db, dir).unwrap();
823 assert_eq!(subtree.len(), 1);
824 assert_eq!(subtree[0].name, "Empty");
825 }
826
827 #[test]
828 fn list_all_directories_works() {
829 let db = setup();
830 let vfs_id = create_vfs(&db, "Lib").unwrap();
831 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
832 create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
833 create_directory(&db, vfs_id, Some(drums), "Snares").unwrap();
834 create_directory(&db, vfs_id, None, "Vocals").unwrap();
835
836 let dirs = list_all_directories(&db, vfs_id).unwrap();
837 let paths: Vec<&str> = dirs.iter().map(|(_, p)| p.as_str()).collect();
838 assert_eq!(
839 paths,
840 vec!["Drums", "Drums/Kicks", "Drums/Snares", "Vocals"]
841 );
842 }
843
844 #[test]
845 fn list_all_directories_empty_vfs() {
846 let db = setup();
847 let vfs_id = create_vfs(&db, "Lib").unwrap();
848 let dirs = list_all_directories(&db, vfs_id).unwrap();
849 assert!(dirs.is_empty());
850 }
851
852 #[test]
853 fn enriched_query_includes_cloud_only_false() {
854 let db = setup();
855 let vfs_id = create_vfs(&db, "Lib").unwrap();
856 crate::test_helpers::insert_fake_sample(&db, "hash1");
857 create_sample_link(&db, vfs_id, None, "kick.wav", "hash1").unwrap();
858
859 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
860 assert_eq!(nodes.len(), 1);
861 assert!(!nodes[0].cloud_only);
862 }
863
864 #[test]
865 fn enriched_query_includes_cloud_only_true() {
866 let db = setup();
867 let vfs_id = create_vfs(&db, "Lib").unwrap();
868 crate::test_helpers::insert_fake_sample(&db, "hash1");
869 create_sample_link(&db, vfs_id, None, "kick.wav", "hash1").unwrap();
870
871 // Set cloud_only=1 directly
872 db.conn()
873 .execute("UPDATE samples SET cloud_only = 1 WHERE hash = 'hash1'", [])
874 .unwrap();
875
876 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
877 assert_eq!(nodes.len(), 1);
878 assert!(nodes[0].cloud_only);
879 }
880
881 #[test]
882 fn enriched_query_directory_cloud_only_false() {
883 let db = setup();
884 let vfs_id = create_vfs(&db, "Lib").unwrap();
885 create_directory(&db, vfs_id, None, "Drums").unwrap();
886
887 let nodes = list_children_enriched(&db, vfs_id, None).unwrap();
888 assert_eq!(nodes.len(), 1);
889 // Directories have no sample_hash so cloud_only defaults to false
890 assert!(!nodes[0].cloud_only);
891 }
892
893 #[test]
894 fn find_nodes_by_hashes_returns_correct_results() {
895 let db = setup();
896 let vfs_id = create_vfs(&db, "Lib").unwrap();
897 insert_fake_sample(&db, "hash_a");
898 insert_fake_sample(&db, "hash_b");
899 insert_fake_sample(&db, "hash_c");
900 create_sample_link(&db, vfs_id, None, "a.wav", "hash_a").unwrap();
901 create_sample_link(&db, vfs_id, None, "b.wav", "hash_b").unwrap();
902 create_sample_link(&db, vfs_id, None, "c.wav", "hash_c").unwrap();
903
904 let results =
905 find_nodes_by_hashes(&db, vfs_id, &["hash_b", "hash_a"]).unwrap();
906
907 // Returns results in input order
908 assert_eq!(results.len(), 2);
909 assert_eq!(results[0].node.sample_hash.as_deref(), Some("hash_b"));
910 assert_eq!(results[1].node.sample_hash.as_deref(), Some("hash_a"));
911 }
912
913 #[test]
914 fn find_nodes_by_hashes_empty_input() {
915 let db = setup();
916 let vfs_id = create_vfs(&db, "Lib").unwrap();
917
918 let results = find_nodes_by_hashes(&db, vfs_id, &[]).unwrap();
919 assert!(results.is_empty());
920 }
921
922 #[test]
923 fn find_nodes_by_hashes_nonexistent_hash() {
924 let db = setup();
925 let vfs_id = create_vfs(&db, "Lib").unwrap();
926
927 let results =
928 find_nodes_by_hashes(&db, vfs_id, &["nonexistent"]).unwrap();
929 assert!(results.is_empty());
930 }
931
932 #[test]
933 fn find_nodes_by_hashes_includes_cloud_only() {
934 let db = setup();
935 let vfs_id = create_vfs(&db, "Lib").unwrap();
936 insert_fake_sample(&db, "hash_cloud");
937 create_sample_link(&db, vfs_id, None, "cloud.wav", "hash_cloud").unwrap();
938
939 db.conn()
940 .execute(
941 "UPDATE samples SET cloud_only = 1 WHERE hash = 'hash_cloud'",
942 [],
943 )
944 .unwrap();
945
946 let results =
947 find_nodes_by_hashes(&db, vfs_id, &["hash_cloud"]).unwrap();
948 assert_eq!(results.len(), 1);
949 assert!(results[0].cloud_only);
950 }
951
952 #[test]
953 fn move_node_rejects_circular_parent_to_child() {
954 let db = setup();
955 let vfs_id = create_vfs(&db, "Lib").unwrap();
956 let a = create_directory(&db, vfs_id, None, "A").unwrap();
957 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
958 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
959
960 // Moving A under C would create A -> B -> C -> A cycle
961 let result = move_node(&db, a, Some(c));
962 assert!(result.is_err());
963 let err_msg = format!("{}", result.unwrap_err());
964 assert!(err_msg.contains("circular"), "expected circular error, got: {err_msg}");
965 }
966
967 #[test]
968 fn move_node_allows_valid_reparent() {
969 let db = setup();
970 let vfs_id = create_vfs(&db, "Lib").unwrap();
971 let a = create_directory(&db, vfs_id, None, "A").unwrap();
972 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
973 let c = create_directory(&db, vfs_id, Some(b), "C").unwrap();
974
975 // Moving C under A (skipping B) is valid — no cycle
976 move_node(&db, c, Some(a)).unwrap();
977 let node = get_node(&db, c).unwrap();
978 assert_eq!(node.parent_id, Some(a));
979 }
980
981 #[test]
982 fn move_node_to_root_succeeds() {
983 let db = setup();
984 let vfs_id = create_vfs(&db, "Lib").unwrap();
985 let a = create_directory(&db, vfs_id, None, "A").unwrap();
986 let b = create_directory(&db, vfs_id, Some(a), "B").unwrap();
987
988 // Moving A to root is always valid
989 move_node(&db, a, None).unwrap();
990 let node = get_node(&db, a).unwrap();
991 assert_eq!(node.parent_id, None);
992
993 // Moving B to root is also valid
994 move_node(&db, b, None).unwrap();
995 let node = get_node(&db, b).unwrap();
996 assert_eq!(node.parent_id, None);
997 }
998
999 #[test]
1000 fn move_node_rejects_self_as_parent() {
1001 let db = setup();
1002 let vfs_id = create_vfs(&db, "Lib").unwrap();
1003 let a = create_directory(&db, vfs_id, None, "A").unwrap();
1004
1005 // Moving A under itself creates a trivial cycle
1006 let result = move_node(&db, a, Some(a));
1007 assert!(result.is_err());
1008 }
1009
1010 // --- Node name validation tests ---
1011
1012 #[test]
1013 fn validate_node_name_accepts_valid_names() {
1014 assert!(validate_node_name("kick.wav").is_ok());
1015 assert!(validate_node_name("My Folder").is_ok());
1016 assert!(validate_node_name("drums-2024").is_ok());
1017 assert!(validate_node_name("a").is_ok());
1018 assert!(validate_node_name("...").is_ok()); // three dots is fine
1019 }
1020
1021 #[test]
1022 fn validate_node_name_rejects_empty() {
1023 let result = validate_node_name("");
1024 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1025 }
1026
1027 #[test]
1028 fn validate_node_name_rejects_dot() {
1029 let result = validate_node_name(".");
1030 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1031 }
1032
1033 #[test]
1034 fn validate_node_name_rejects_dotdot() {
1035 let result = validate_node_name("..");
1036 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1037 }
1038
1039 #[test]
1040 fn validate_node_name_rejects_forward_slash() {
1041 let result = validate_node_name("foo/bar");
1042 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1043 }
1044
1045 #[test]
1046 fn validate_node_name_rejects_backslash() {
1047 let result = validate_node_name("foo\\bar");
1048 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1049 }
1050
1051 #[test]
1052 fn validate_node_name_rejects_null_byte() {
1053 let result = validate_node_name("foo\0bar");
1054 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1055 }
1056
1057 #[test]
1058 fn create_directory_rejects_invalid_name() {
1059 let db = setup();
1060 let vfs_id = create_vfs(&db, "Lib").unwrap();
1061 let result = create_directory(&db, vfs_id, None, "..");
1062 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1063 }
1064
1065 #[test]
1066 fn create_sample_link_rejects_invalid_name() {
1067 let db = setup();
1068 insert_fake_sample(&db, "hash1");
1069 let vfs_id = create_vfs(&db, "Lib").unwrap();
1070 let result = create_sample_link(&db, vfs_id, None, "foo/bar.wav", "hash1");
1071 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1072 }
1073
1074 #[test]
1075 fn rename_node_rejects_invalid_name() {
1076 let db = setup();
1077 let vfs_id = create_vfs(&db, "Lib").unwrap();
1078 let dir = create_directory(&db, vfs_id, None, "Valid").unwrap();
1079 let result = rename_node(&db, dir, "");
1080 assert!(matches!(result, Err(CoreError::InvalidNodeName(_))));
1081 }
1082
1083 // --- list_full_tree tests ---
1084
1085 #[test]
1086 fn list_full_tree_empty_vfs() {
1087 let db = setup();
1088 create_vfs(&db, "Lib").unwrap();
1089 let tree = list_full_tree(&db).unwrap();
1090 assert!(tree.is_empty());
1091 }
1092
1093 #[test]
1094 fn list_full_tree_builds_paths() {
1095 let db = setup();
1096 insert_fake_sample(&db, "s1");
1097 let vfs_id = create_vfs(&db, "Library").unwrap();
1098 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
1099 let kicks = create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
1100 create_sample_link(&db, vfs_id, Some(kicks), "808.wav", "s1").unwrap();
1101
1102 let tree = list_full_tree(&db).unwrap();
1103 let paths: Vec<&str> = tree.iter().map(|n| n.path.as_str()).collect();
1104 assert_eq!(
1105 paths,
1106 vec![
1107 "Library/Drums",
1108 "Library/Drums/Kicks",
1109 "Library/Drums/Kicks/808.wav",
1110 ]
1111 );
1112 // Last node is a sample with the correct hash
1113 assert_eq!(tree[2].node_type, NodeType::Sample);
1114 assert_eq!(tree[2].sample_hash.as_deref(), Some("s1"));
1115 }
1116
1117 #[test]
1118 fn list_full_tree_multiple_vfs() {
1119 let db = setup();
1120 insert_fake_sample(&db, "s1");
1121 let v1 = create_vfs(&db, "Alpha").unwrap();
1122 let v2 = create_vfs(&db, "Beta").unwrap();
1123 create_directory(&db, v1, None, "Dir1").unwrap();
1124 create_sample_link(&db, v2, None, "sample.wav", "s1").unwrap();
1125
1126 let tree = list_full_tree(&db).unwrap();
1127 let paths: Vec<&str> = tree.iter().map(|n| n.path.as_str()).collect();
1128 assert_eq!(paths, vec!["Alpha/Dir1", "Beta/sample.wav"]);
1129 }
1130 }
1131