1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rusqlite::params;
5
6use crate::db::connection::{Database, DbError};
7use crate::db::queries::{self, TrackRow};
8use crate::format::{self, FormatError, MetadataProvider};
9
10const ANCILLARY_PATTERNS: &[&str] = &[
12 "cover.jpg",
13 "cover.png",
14 "cover.webp",
15 "folder.jpg",
16 "folder.png",
17 "front.jpg",
18 "front.png",
19];
20
21const ANCILLARY_EXTENSIONS: &[&str] = &["cue", "log", "m3u", "m3u8"];
22
23#[derive(Debug, thiserror::Error)]
24pub enum OrganizeError {
25 #[error("database error: {0}")]
26 Db(#[from] DbError),
27 #[error("sqlite error: {0}")]
28 Sqlite(#[from] rusqlite::Error),
29 #[error("format error: {0}")]
30 Format(#[from] FormatError),
31 #[error("io error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("no tracks with local paths found")]
34 NoLocalTracks,
35 #[error("no organize batches to undo")]
36 NothingToUndo,
37}
38
39#[derive(Debug)]
40pub struct OrganizeResult {
41 pub moves: Vec<FileMove>,
42 pub errors: Vec<(PathBuf, String)>,
43 pub skipped: usize,
44}
45
46#[derive(Debug)]
47pub struct FileMove {
48 pub track_id: i64,
49 pub from: PathBuf,
50 pub to: PathBuf,
51 pub ancillary: Vec<(PathBuf, PathBuf)>,
52}
53
54struct TrackMetadata {
56 fields: HashMap<String, String>,
57}
58
59impl TrackMetadata {
60 fn from_track_row(track: &TrackRow, album_date: Option<&str>) -> Self {
61 let mut fields = HashMap::new();
62 let s = sanitize_path_component;
64 fields.insert("title".into(), s(&track.title));
65 fields.insert("artist".into(), s(&track.artist_name));
66 fields.insert("album artist".into(), s(&track.album_artist_name));
67 fields.insert("album".into(), s(&track.album_title));
68 if let Some(n) = track.track_number {
69 fields.insert("tracknumber".into(), format!("{n:02}"));
70 }
71 if let Some(d) = track.disc {
72 fields.insert("discnumber".into(), d.to_string());
73 }
74 if let Some(date) = album_date {
75 fields.insert("date".into(), date.to_string());
77 }
78 if let Some(ref codec) = track.codec {
79 fields.insert("codec".into(), codec.clone());
80 }
81 if let Some(ref genre) = track.genre {
82 fields.insert("genre".into(), s(genre));
83 }
84 Self { fields }
85 }
86
87 fn from_file_meta(meta: &queries::TrackMeta) -> Self {
89 let mut fields = HashMap::new();
90 let s = sanitize_path_component;
91 fields.insert("title".into(), s(&meta.title));
92 fields.insert("artist".into(), s(&meta.artist));
93 fields.insert(
94 "album artist".into(),
95 s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
96 );
97 fields.insert("album".into(), s(&meta.album));
98 if let Some(n) = meta.track_number {
99 fields.insert("tracknumber".into(), format!("{n:02}"));
100 }
101 if let Some(d) = meta.disc {
102 fields.insert("discnumber".into(), d.to_string());
103 }
104 if let Some(ref date) = meta.date {
105 fields.insert("date".into(), date.clone());
106 }
107 if let Some(ref codec) = meta.codec {
108 fields.insert("codec".into(), codec.clone());
109 }
110 if let Some(ref genre) = meta.genre {
111 fields.insert("genre".into(), s(genre));
112 }
113 if let Some(ref label) = meta.label {
114 fields.insert("label".into(), s(label));
115 }
116 Self { fields }
117 }
118}
119
120impl MetadataProvider for TrackMetadata {
121 fn get_field(&self, name: &str) -> Option<String> {
122 self.fields.get(name).cloned()
123 }
124}
125
126fn sanitize_path_component(s: &str) -> String {
128 s.chars()
129 .map(|c| match c {
130 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
131 _ => c,
132 })
133 .collect::<String>()
134 .trim()
135 .to_string()
136}
137
138fn sanitize_relative_path(rel: &str) -> PathBuf {
140 let parts: Vec<&str> = if rel.contains('/') {
141 rel.split('/')
142 } else {
143 rel.split(std::path::MAIN_SEPARATOR)
144 }
145 .collect();
146
147 let mut result = PathBuf::new();
148 for part in parts {
149 let sanitized = sanitize_path_component(part);
150 if !sanitized.is_empty() {
151 result.push(sanitized);
152 }
153 }
154 result
155}
156
157fn album_date_for_track(
159 db: &Database,
160 track: &TrackRow,
161) -> Result<Option<String>, rusqlite::Error> {
162 let Some(album_id) = track.album_id else {
163 return Ok(None);
164 };
165 db.conn
166 .query_row(
167 "SELECT date FROM albums WHERE id = ?1",
168 params![album_id],
169 |row| row.get::<_, Option<String>>(0),
170 )
171 .or(Ok(None))
172}
173
174fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
176 let mut files = Vec::new();
177 let Ok(entries) = std::fs::read_dir(track_dir) else {
178 return files;
179 };
180
181 for entry in entries.flatten() {
182 let path = entry.path();
183 if !path.is_file() {
184 continue;
185 }
186 let name = path
187 .file_name()
188 .and_then(|n| n.to_str())
189 .unwrap_or_default()
190 .to_lowercase();
191
192 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
194 files.push(path);
195 continue;
196 }
197 if let Some(ext) = path.extension().and_then(|e| e.to_str())
199 && ANCILLARY_EXTENSIONS
200 .iter()
201 .any(|e| ext.eq_ignore_ascii_case(e))
202 {
203 files.push(path);
204 }
205 }
206 files
207}
208
209fn plan_single_move(
214 source: &Path,
215 track_id: i64,
216 metadata: &TrackMetadata,
217 pattern: &str,
218 base_dir: &Path,
219 planned_ancillary: &mut std::collections::HashSet<PathBuf>,
220) -> Result<Option<FileMove>, String> {
221 let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
222
223 if relative.is_empty() {
224 return Err("format string produced empty path".into());
225 }
226
227 let sanitized = sanitize_relative_path(&relative);
228
229 let ext = source
233 .extension()
234 .and_then(|e| e.to_str())
235 .unwrap_or("flac");
236 let mut dest = base_dir.join(sanitized);
237 let stem = dest.as_os_str().to_os_string();
238 dest = PathBuf::from(format!("{}.{}", stem.to_string_lossy(), ext));
239
240 if paths_equal(source, &dest) {
241 return Ok(None);
242 }
243
244 let source_dir = source.parent().unwrap_or(Path::new("."));
246 let dest_dir = dest.parent().unwrap_or(Path::new("."));
247 let mut ancillary = Vec::new();
248
249 if source_dir != dest_dir {
250 for anc_path in find_ancillary_files(source_dir) {
251 if planned_ancillary.contains(&anc_path) {
252 continue;
253 }
254 let Some(anc_name) = anc_path.file_name() else {
255 continue;
256 };
257 let anc_dest = dest_dir.join(anc_name);
258 planned_ancillary.insert(anc_path.clone());
259 ancillary.push((anc_path, anc_dest));
260 }
261 }
262
263 Ok(Some(FileMove {
264 track_id,
265 from: source.to_path_buf(),
266 to: dest,
267 ancillary,
268 }))
269}
270
271fn plan_moves(
273 db: &Database,
274 pattern: &str,
275 base_dir: &Path,
276 track_ids: Option<&[i64]>,
277) -> Result<OrganizeResult, OrganizeError> {
278 let tracks = match track_ids {
279 Some(ids) => {
280 let mut tracks = Vec::with_capacity(ids.len());
281 for &id in ids {
282 if let Some(row) = queries::get_track_row(&db.conn, id)? {
283 tracks.push(row);
284 }
285 }
286 tracks
287 }
288 None => queries::all_tracks(&db.conn)?,
289 };
290 let mut moves = Vec::new();
291 let mut errors = Vec::new();
292 let mut skipped = 0;
293
294 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
296 std::collections::HashSet::new();
297
298 for track in &tracks {
299 let Some(ref path_str) = track.path else {
300 continue; };
302
303 let source = PathBuf::from(path_str);
304 if !source.exists() {
305 continue; }
307
308 let album_date = match album_date_for_track(db, track) {
309 Ok(d) => d,
310 Err(e) => {
311 errors.push((source, format!("failed to get album date: {e}")));
312 continue;
313 }
314 };
315
316 let metadata = TrackMetadata::from_track_row(track, album_date.as_deref());
317
318 match plan_single_move(
319 &source,
320 track.id,
321 &metadata,
322 pattern,
323 base_dir,
324 &mut planned_ancillary,
325 ) {
326 Ok(Some(file_move)) => moves.push(file_move),
327 Ok(None) => skipped += 1,
328 Err(msg) => errors.push((source, msg)),
329 }
330 }
331
332 Ok(OrganizeResult {
333 moves,
334 errors,
335 skipped,
336 })
337}
338
339fn plan_moves_from_paths(
342 paths: &[PathBuf],
343 pattern: &str,
344 base_dir: &Path,
345) -> Result<OrganizeResult, OrganizeError> {
346 let mut moves = Vec::new();
347 let mut errors = Vec::new();
348 let mut skipped = 0;
349
350 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
351 std::collections::HashSet::new();
352
353 for source in paths {
354 if !source.exists() {
355 errors.push((source.clone(), "file not found".into()));
356 continue;
357 }
358
359 let meta = match crate::index::metadata::read_metadata(source) {
360 Ok(m) => m,
361 Err(e) => {
362 errors.push((source.clone(), format!("metadata error: {e}")));
363 continue;
364 }
365 };
366
367 let metadata = TrackMetadata::from_file_meta(&meta);
368
369 match plan_single_move(
370 source,
371 0,
372 &metadata,
373 pattern,
374 base_dir,
375 &mut planned_ancillary,
376 ) {
377 Ok(Some(file_move)) => moves.push(file_move),
378 Ok(None) => skipped += 1,
379 Err(msg) => errors.push((source.clone(), msg)),
380 }
381 }
382
383 Ok(OrganizeResult {
384 moves,
385 errors,
386 skipped,
387 })
388}
389
390pub fn preview_for_paths(
392 paths: &[PathBuf],
393 pattern: &str,
394 base_dir: Option<&Path>,
395) -> Result<OrganizeResult, OrganizeError> {
396 let base = resolve_base_dir(base_dir)?;
397 plan_moves_from_paths(paths, pattern, &base)
398}
399
400pub fn execute_for_paths(
403 paths: &[PathBuf],
404 pattern: &str,
405 base_dir: Option<&Path>,
406) -> Result<OrganizeResult, OrganizeError> {
407 let base = resolve_base_dir(base_dir)?;
408 let mut result = plan_moves_from_paths(paths, pattern, &base)?;
409
410 if result.moves.is_empty() {
411 return Ok(result);
412 }
413
414 let mut completed_moves = Vec::new();
415 let mut new_errors = Vec::new();
416
417 for file_move in result.moves.drain(..) {
418 match execute_single_move_no_db(&file_move) {
419 Ok(()) => match verify_move(&file_move) {
420 Ok(()) => completed_moves.push(file_move),
421 Err(msg) => new_errors.push((file_move.from, msg)),
422 },
423 Err(e) => {
424 new_errors.push((file_move.from, e.to_string()));
425 }
426 }
427 }
428
429 result.moves = completed_moves;
430 result.errors.extend(new_errors);
431 Ok(result)
432}
433
434fn verify_move(file_move: &FileMove) -> Result<(), String> {
436 if !file_move.to.exists() {
437 return Err(format!(
438 "destination not found after move: {}",
439 file_move.to.display()
440 ));
441 }
442 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
443 return Err(format!(
444 "source still exists after move: {}",
445 file_move.from.display()
446 ));
447 }
448 Ok(())
449}
450
451fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
453 if let Some(parent) = file_move.to.parent() {
454 std::fs::create_dir_all(parent)?;
455 }
456
457 move_file(&file_move.from, &file_move.to)?;
458
459 for (anc_from, anc_to) in &file_move.ancillary {
461 if let Some(parent) = anc_to.parent() {
462 std::fs::create_dir_all(parent).ok();
463 }
464 if let Err(e) = move_file(anc_from, anc_to) {
465 log::warn!(
466 "failed to move ancillary file {}: {}",
467 anc_from.display(),
468 e
469 );
470 }
471 }
472
473 if let Some(source_dir) = file_move.from.parent() {
474 remove_empty_dirs(source_dir);
475 }
476
477 Ok(())
478}
479
480pub fn preview(
482 db: &Database,
483 pattern: &str,
484 base_dir: Option<&Path>,
485) -> Result<OrganizeResult, OrganizeError> {
486 let base = resolve_base_dir(base_dir)?;
487 plan_moves(db, pattern, &base, None)
488}
489
490pub fn execute(
492 db: &Database,
493 pattern: &str,
494 base_dir: Option<&Path>,
495) -> Result<OrganizeResult, OrganizeError> {
496 let base = resolve_base_dir(base_dir)?;
497 let mut result = plan_moves(db, pattern, &base, None)?;
498
499 if result.moves.is_empty() {
500 return Ok(result);
501 }
502
503 let batch_id = chrono_batch_id();
505
506 let mut completed_moves = Vec::new();
507 let mut new_errors = Vec::new();
508
509 for file_move in result.moves.drain(..) {
510 match execute_single_move(db, &file_move, &batch_id) {
511 Ok(()) => match verify_move(&file_move) {
512 Ok(()) => completed_moves.push(file_move),
513 Err(msg) => new_errors.push((file_move.from, msg)),
514 },
515 Err(e) => {
516 new_errors.push((file_move.from, e.to_string()));
517 }
518 }
519 }
520
521 result.moves = completed_moves;
522 result.errors.extend(new_errors);
523 Ok(result)
524}
525
526pub fn preview_for_tracks(
528 db: &Database,
529 track_ids: &[i64],
530 pattern: &str,
531 base_dir: Option<&Path>,
532) -> Result<OrganizeResult, OrganizeError> {
533 let base = resolve_base_dir(base_dir)?;
534 plan_moves(db, pattern, &base, Some(track_ids))
535}
536
537pub fn execute_for_tracks(
539 db: &Database,
540 track_ids: &[i64],
541 pattern: &str,
542 base_dir: Option<&Path>,
543) -> Result<OrganizeResult, OrganizeError> {
544 let base = resolve_base_dir(base_dir)?;
545 let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
546
547 if result.moves.is_empty() {
548 return Ok(result);
549 }
550
551 let batch_id = chrono_batch_id();
552 let mut completed_moves = Vec::new();
553 let mut new_errors = Vec::new();
554
555 for file_move in result.moves.drain(..) {
556 match execute_single_move(db, &file_move, &batch_id) {
557 Ok(()) => match verify_move(&file_move) {
558 Ok(()) => completed_moves.push(file_move),
559 Err(msg) => new_errors.push((file_move.from, msg)),
560 },
561 Err(e) => {
562 new_errors.push((file_move.from, e.to_string()));
563 }
564 }
565 }
566
567 result.moves = completed_moves;
568 result.errors.extend(new_errors);
569 Ok(result)
570}
571
572fn execute_single_move(
574 db: &Database,
575 file_move: &FileMove,
576 batch_id: &str,
577) -> Result<(), OrganizeError> {
578 if let Some(parent) = file_move.to.parent() {
580 std::fs::create_dir_all(parent)?;
581 }
582
583 move_file(&file_move.from, &file_move.to)?;
585
586 db.conn.execute(
588 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
589 params![
590 batch_id,
591 file_move.track_id,
592 file_move.from.to_string_lossy().as_ref(),
593 file_move.to.to_string_lossy().as_ref(),
594 ],
595 )?;
596
597 db.conn.execute(
599 "UPDATE tracks SET path = ?1 WHERE id = ?2",
600 params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
601 )?;
602
603 db.conn.execute(
605 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
606 params![
607 file_move.to.to_string_lossy().as_ref(),
608 file_move.from.to_string_lossy().as_ref(),
609 ],
610 )?;
611
612 for (anc_from, anc_to) in &file_move.ancillary {
614 if let Some(parent) = anc_to.parent() {
615 std::fs::create_dir_all(parent)?;
616 }
617 if move_file(anc_from, anc_to).is_ok() {
619 db.conn.execute(
620 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
621 params![
622 batch_id,
623 anc_from.to_string_lossy().as_ref(),
624 anc_to.to_string_lossy().as_ref(),
625 ],
626 )?;
627 }
628 }
629
630 if let Some(source_dir) = file_move.from.parent() {
632 remove_empty_dirs(source_dir);
633 }
634
635 Ok(())
636}
637
638pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
640 let batch_id: String = db
642 .conn
643 .query_row(
644 "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
645 [],
646 |row| row.get(0),
647 )
648 .map_err(|_| OrganizeError::NothingToUndo)?;
649
650 let mut stmt = db.conn.prepare(
652 "SELECT id, track_id, from_path, to_path FROM organize_log
653 WHERE batch_id = ?1 ORDER BY id DESC",
654 )?;
655
656 let entries: Vec<(i64, Option<i64>, String, String)> = stmt
657 .query_map(params![batch_id], |row| {
658 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
659 })?
660 .collect::<Result<Vec<_>, _>>()?;
661
662 let mut count = 0;
663
664 for (log_id, track_id, from_path, to_path) in &entries {
665 let to = Path::new(to_path);
666 let from = Path::new(from_path);
667
668 if !to.exists() {
669 db.conn
671 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
672 continue;
673 }
674
675 if let Some(parent) = from.parent() {
677 std::fs::create_dir_all(parent)?;
678 }
679
680 move_file(to, from)?;
682
683 if let Some(tid) = track_id {
685 db.conn.execute(
686 "UPDATE tracks SET path = ?1 WHERE id = ?2",
687 params![from_path, tid],
688 )?;
689 db.conn.execute(
690 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
691 params![from_path, to_path],
692 )?;
693 }
694
695 if let Some(parent) = to.parent() {
697 remove_empty_dirs(parent);
698 }
699
700 db.conn
701 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
702
703 count += 1;
704 }
705
706 Ok(count)
707}
708
709fn remove_empty_dirs(dir: &Path) {
711 let mut current = dir.to_path_buf();
712 loop {
713 if std::fs::read_dir(¤t)
714 .map(|mut d| d.next().is_none())
715 .unwrap_or(false)
716 {
717 if std::fs::remove_dir(¤t).is_err() {
718 break;
719 }
720 match current.parent() {
721 Some(p) => current = p.to_path_buf(),
722 None => break,
723 }
724 } else {
725 break;
726 }
727 }
728}
729
730fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
732 match std::fs::rename(from, to) {
733 Ok(()) => Ok(()),
734 Err(e) if e.raw_os_error() == Some(18) => {
735 std::fs::copy(from, to)?;
737 std::fs::remove_file(from)?;
738 Ok(())
739 }
740 Err(e) => Err(e.into()),
741 }
742}
743
744fn paths_equal(a: &Path, b: &Path) -> bool {
746 if a == b {
747 return true;
748 }
749 if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
751 return ca == cb;
752 }
753 false
754}
755
756fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
757 if let Some(dir) = base_dir {
758 return Ok(dir.to_path_buf());
759 }
760
761 let config = crate::config::Config::load()
763 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
764
765 config.library.folders.into_iter().next().ok_or_else(|| {
766 OrganizeError::Io(std::io::Error::other(
767 "no library folders configured; use --base-dir",
768 ))
769 })
770}
771
772fn chrono_batch_id() -> String {
773 let now = std::time::SystemTime::now()
774 .duration_since(std::time::UNIX_EPOCH)
775 .unwrap_or_default();
776 format!("batch-{}", now.as_nanos())
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::db::connection::Database;
783 use crate::db::queries::TrackMeta;
784 use crate::db::schema;
785
786 fn test_db() -> Database {
787 let conn = rusqlite::Connection::open_in_memory().unwrap();
788 conn.pragma_update(None, "foreign_keys", "on").unwrap();
789 schema::create_tables(&conn).unwrap();
790 Database { conn }
791 }
792
793 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
794 TrackMeta {
795 title: title.into(),
796 artist: artist.into(),
797 album_artist: Some(artist.into()),
798 album: album.into(),
799 date: Some("1997-06-16".into()),
800 disc: Some(1),
801 track_number: Some(1),
802 genre: Some("Rock".into()),
803 label: None,
804 duration_ms: Some(240_000),
805 codec: Some("FLAC".into()),
806 sample_rate: Some(44100),
807 bit_depth: Some(16),
808 channels: Some(2),
809 bitrate: Some(1000),
810 size_bytes: Some(30_000_000),
811 mtime: Some(1700000000),
812 path: None,
813 source: "local".into(),
814 remote_id: None,
815 remote_url: None,
816 }
817 }
818
819 #[test]
820 fn track_metadata_provider_fields() {
821 let track = TrackRow {
822 id: 1,
823 album_id: Some(1),
824 artist_id: Some(1),
825 artist_name: "Radiohead".into(),
826 album_artist_name: "Radiohead".into(),
827 album_title: "OK Computer".into(),
828 disc: Some(1),
829 track_number: Some(3),
830 title: "Subterranean Homesick Alien".into(),
831 duration_ms: Some(240_000),
832 path: Some("/music/ok_computer/03.flac".into()),
833 codec: Some("FLAC".into()),
834 sample_rate: Some(44100),
835 bit_depth: Some(16),
836 channels: Some(2),
837 bitrate: Some(1000),
838 genre: Some("Alternative".into()),
839 source: "local".into(),
840 remote_id: None,
841 cached_path: None,
842 };
843
844 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
845 assert_eq!(
846 meta.get_field("title").as_deref(),
847 Some("Subterranean Homesick Alien")
848 );
849 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
850 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
851 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
852 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
853 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
854 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
855 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
856 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
857 assert_eq!(meta.get_field("nonexistent"), None);
858 }
859
860 #[test]
861 fn sanitize_replaces_illegal_chars() {
862 assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
863 assert_eq!(sanitize_path_component("What?"), "What_");
864 assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
865 assert_eq!(sanitize_path_component("normal"), "normal");
866 }
867
868 #[test]
869 fn sanitize_relative_path_splits() {
870 let result = sanitize_relative_path("Artist/Album/Track");
871 assert_eq!(result, PathBuf::from("Artist/Album/Track"));
872
873 let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
874 assert_eq!(
875 result,
876 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
877 );
878 }
879
880 #[test]
881 fn acdc_artist_name_sanitized() {
882 let track = TrackRow {
884 id: 1,
885 album_id: Some(1),
886 artist_id: Some(1),
887 artist_name: "AC/DC".into(),
888 album_artist_name: "AC/DC".into(),
889 album_title: "Highway to Hell".into(),
890 disc: Some(1),
891 track_number: Some(1),
892 title: "Highway to Hell".into(),
893 duration_ms: None,
894 path: Some("/music/test.flac".into()),
895 codec: None,
896 sample_rate: None,
897 bit_depth: None,
898 channels: None,
899 bitrate: None,
900 genre: None,
901 source: "local".into(),
902 remote_id: None,
903 cached_path: None,
904 };
905 let meta = TrackMetadata::from_track_row(&track, Some("1979"));
906 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
907 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
908 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
909 }
910
911 #[test]
912 fn format_string_evaluation() {
913 let track = TrackRow {
914 id: 1,
915 album_id: Some(1),
916 artist_id: Some(1),
917 artist_name: "Radiohead".into(),
918 album_artist_name: "Radiohead".into(),
919 album_title: "OK Computer".into(),
920 disc: Some(1),
921 track_number: Some(1),
922 title: "Airbag".into(),
923 duration_ms: Some(240_000),
924 path: Some("/music/test.flac".into()),
925 codec: Some("FLAC".into()),
926 sample_rate: Some(44100),
927 bit_depth: Some(16),
928 channels: Some(2),
929 bitrate: Some(1000),
930 genre: None,
931 source: "local".into(),
932 remote_id: None,
933 cached_path: None,
934 };
935
936 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
937 let pattern =
938 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
939 let result = format::format(pattern, &meta).unwrap();
940 assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
941 }
942
943 #[test]
944 fn preview_does_not_move_files() {
945 let db = test_db();
946 let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
947 std::fs::create_dir_all(&tmp).unwrap();
948
949 let test_file = tmp.join("test.flac");
951 std::fs::write(&test_file, b"fake audio data").unwrap();
952
953 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
954 meta.path = Some(test_file.to_string_lossy().into());
955 queries::upsert_track(&db.conn, &meta).unwrap();
956
957 let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
958 assert!(test_file.exists());
960 assert!(!result.moves.is_empty());
961
962 std::fs::remove_dir_all(&tmp).ok();
964 }
965
966 #[test]
967 fn execute_moves_files_and_undo_reverts() {
968 let db = test_db();
969 let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
970 let src_dir = tmp.join("src");
971 std::fs::create_dir_all(&src_dir).unwrap();
972
973 let test_file = src_dir.join("test.flac");
975 std::fs::write(&test_file, b"fake audio data").unwrap();
976
977 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
978 meta.path = Some(test_file.to_string_lossy().into());
979 queries::upsert_track(&db.conn, &meta).unwrap();
980
981 let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
983 assert_eq!(result.moves.len(), 1);
984 assert!(!test_file.exists()); let dest = &result.moves[0].to;
986 assert!(dest.exists()); let undone = undo(&db).unwrap();
990 assert_eq!(undone, 1);
991 assert!(test_file.exists()); assert!(!dest.exists()); std::fs::remove_dir_all(&tmp).ok();
996 }
997
998 #[test]
999 fn ancillary_file_detection() {
1000 let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1001 std::fs::create_dir_all(&tmp).unwrap();
1002
1003 std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1004 std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1005 std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1006 std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1007 std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); let found = find_ancillary_files(&tmp);
1010 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1011 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1012 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1013 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1014 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1015
1016 std::fs::remove_dir_all(&tmp).ok();
1017 }
1018
1019 #[test]
1020 fn extension_not_clobbered_by_dots_in_title() {
1021 let db = test_db();
1024 let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1025 let src_dir = tmp.join("src");
1026 std::fs::create_dir_all(&src_dir).unwrap();
1027
1028 let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1030 std::fs::write(&test_file, b"fake").unwrap();
1031
1032 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1033 meta.track_number = Some(10);
1034 meta.disc = Some(1);
1035 meta.date = Some("2025-11-21".into());
1036 meta.codec = Some("FLAC".into());
1037 meta.album_artist = Some("Bicep".into());
1038 meta.path = Some(test_file.to_string_lossy().into());
1039 queries::upsert_track(&db.conn, &meta).unwrap();
1040
1041 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1042 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1043 assert_eq!(result.moves.len(), 1);
1044
1045 let dest_name = result.moves[0]
1046 .to
1047 .file_name()
1048 .unwrap()
1049 .to_string_lossy()
1050 .to_string();
1051 assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1053
1054 std::fs::remove_dir_all(&tmp).ok();
1055 }
1056
1057 #[test]
1058 fn extension_preserved_for_tracknumber_dot() {
1059 let db = test_db();
1061 let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1062 let src_dir = tmp.join("src");
1063 std::fs::create_dir_all(&src_dir).unwrap();
1064
1065 let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1066 std::fs::write(&test_file, b"fake").unwrap();
1067
1068 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1069 meta.track_number = Some(11);
1070 meta.disc = Some(1);
1071 meta.date = Some("2025-11-21".into());
1072 meta.codec = Some("FLAC".into());
1073 meta.album_artist = Some("Bicep".into());
1074 meta.path = Some(test_file.to_string_lossy().into());
1075 queries::upsert_track(&db.conn, &meta).unwrap();
1076
1077 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1078 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1079 assert_eq!(result.moves.len(), 1);
1080
1081 let dest_name = result.moves[0]
1082 .to
1083 .file_name()
1084 .unwrap()
1085 .to_string_lossy()
1086 .to_string();
1087 assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1088
1089 std::fs::remove_dir_all(&tmp).ok();
1090 }
1091}