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 {
142 let parts: Vec<&str> = if rel.contains('/') {
143 rel.split('/')
144 } else {
145 rel.split(std::path::MAIN_SEPARATOR)
146 }
147 .collect();
148
149 let mut result = PathBuf::new();
150 for part in parts {
151 let sanitized = sanitize_path_component(part);
152 if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
153 continue;
154 }
155 result.push(sanitized);
156 }
157 result
158}
159
160fn album_date_for_track(
162 db: &Database,
163 track: &TrackRow,
164) -> Result<Option<String>, rusqlite::Error> {
165 let Some(album_id) = track.album_id else {
166 return Ok(None);
167 };
168 db.conn
169 .query_row(
170 "SELECT date FROM albums WHERE id = ?1",
171 params![album_id],
172 |row| row.get::<_, Option<String>>(0),
173 )
174 .or(Ok(None))
175}
176
177fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
179 let mut files = Vec::new();
180 let Ok(entries) = std::fs::read_dir(track_dir) else {
181 return files;
182 };
183
184 for entry in entries.flatten() {
185 let path = entry.path();
186 if !path.is_file() {
187 continue;
188 }
189 let name = path
190 .file_name()
191 .and_then(|n| n.to_str())
192 .unwrap_or_default()
193 .to_lowercase();
194
195 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
197 files.push(path);
198 continue;
199 }
200 if let Some(ext) = path.extension().and_then(|e| e.to_str())
202 && ANCILLARY_EXTENSIONS
203 .iter()
204 .any(|e| ext.eq_ignore_ascii_case(e))
205 {
206 files.push(path);
207 }
208 }
209 files
210}
211
212fn plan_single_move(
217 source: &Path,
218 track_id: i64,
219 metadata: &TrackMetadata,
220 pattern: &str,
221 base_dir: &Path,
222 planned_ancillary: &mut std::collections::HashSet<PathBuf>,
223) -> Result<Option<FileMove>, String> {
224 let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
225
226 if relative.is_empty() {
227 return Err("format string produced empty path".into());
228 }
229
230 let sanitized = sanitize_relative_path(&relative);
231
232 if sanitized.as_os_str().is_empty() {
233 return Err("format string produced empty path after sanitization".into());
234 }
235
236 let ext = source
240 .extension()
241 .and_then(|e| e.to_str())
242 .unwrap_or("flac");
243 let mut dest = base_dir.join(sanitized);
244 let stem = dest.as_os_str().to_os_string();
245 dest = PathBuf::from(format!("{}.{}", stem.to_string_lossy(), ext));
246
247 if !dest.starts_with(base_dir) {
249 return Err(format!(
250 "path traversal blocked: destination {} escapes base dir {}",
251 dest.display(),
252 base_dir.display()
253 ));
254 }
255
256 if paths_equal(source, &dest) {
257 return Ok(None);
258 }
259
260 let source_dir = source.parent().unwrap_or(Path::new("."));
262 let dest_dir = dest.parent().unwrap_or(Path::new("."));
263 let mut ancillary = Vec::new();
264
265 if source_dir != dest_dir {
266 for anc_path in find_ancillary_files(source_dir) {
267 if planned_ancillary.contains(&anc_path) {
268 continue;
269 }
270 let Some(anc_name) = anc_path.file_name() else {
271 continue;
272 };
273 let anc_dest = dest_dir.join(anc_name);
274 planned_ancillary.insert(anc_path.clone());
275 ancillary.push((anc_path, anc_dest));
276 }
277 }
278
279 Ok(Some(FileMove {
280 track_id,
281 from: source.to_path_buf(),
282 to: dest,
283 ancillary,
284 }))
285}
286
287fn plan_moves(
289 db: &Database,
290 pattern: &str,
291 base_dir: &Path,
292 track_ids: Option<&[i64]>,
293) -> Result<OrganizeResult, OrganizeError> {
294 let tracks = match track_ids {
295 Some(ids) => {
296 let mut tracks = Vec::with_capacity(ids.len());
297 for &id in ids {
298 if let Some(row) = queries::get_track_row(&db.conn, id)? {
299 tracks.push(row);
300 }
301 }
302 tracks
303 }
304 None => queries::all_tracks(&db.conn)?,
305 };
306 let mut moves = Vec::new();
307 let mut errors = Vec::new();
308 let mut skipped = 0;
309
310 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
312 std::collections::HashSet::new();
313
314 for track in &tracks {
315 let Some(ref path_str) = track.path else {
316 continue; };
318
319 let source = PathBuf::from(path_str);
320 if !source.exists() {
321 continue; }
323
324 let album_date = match album_date_for_track(db, track) {
325 Ok(d) => d,
326 Err(e) => {
327 errors.push((source, format!("failed to get album date: {e}")));
328 continue;
329 }
330 };
331
332 let metadata = TrackMetadata::from_track_row(track, album_date.as_deref());
333
334 match plan_single_move(
335 &source,
336 track.id,
337 &metadata,
338 pattern,
339 base_dir,
340 &mut planned_ancillary,
341 ) {
342 Ok(Some(file_move)) => moves.push(file_move),
343 Ok(None) => skipped += 1,
344 Err(msg) => errors.push((source, msg)),
345 }
346 }
347
348 Ok(OrganizeResult {
349 moves,
350 errors,
351 skipped,
352 })
353}
354
355fn plan_moves_from_paths(
358 paths: &[PathBuf],
359 pattern: &str,
360 base_dir: &Path,
361) -> Result<OrganizeResult, OrganizeError> {
362 let mut moves = Vec::new();
363 let mut errors = Vec::new();
364 let mut skipped = 0;
365
366 let mut planned_ancillary: std::collections::HashSet<PathBuf> =
367 std::collections::HashSet::new();
368
369 for source in paths {
370 if !source.exists() {
371 errors.push((source.clone(), "file not found".into()));
372 continue;
373 }
374
375 let meta = match crate::index::metadata::read_metadata(source) {
376 Ok(m) => m,
377 Err(e) => {
378 errors.push((source.clone(), format!("metadata error: {e}")));
379 continue;
380 }
381 };
382
383 let metadata = TrackMetadata::from_file_meta(&meta);
384
385 match plan_single_move(
386 source,
387 0,
388 &metadata,
389 pattern,
390 base_dir,
391 &mut planned_ancillary,
392 ) {
393 Ok(Some(file_move)) => moves.push(file_move),
394 Ok(None) => skipped += 1,
395 Err(msg) => errors.push((source.clone(), msg)),
396 }
397 }
398
399 Ok(OrganizeResult {
400 moves,
401 errors,
402 skipped,
403 })
404}
405
406pub fn preview_for_paths(
408 paths: &[PathBuf],
409 pattern: &str,
410 base_dir: Option<&Path>,
411) -> Result<OrganizeResult, OrganizeError> {
412 let base = resolve_base_dir(base_dir)?;
413 plan_moves_from_paths(paths, pattern, &base)
414}
415
416pub fn execute_for_paths(
419 paths: &[PathBuf],
420 pattern: &str,
421 base_dir: Option<&Path>,
422) -> Result<OrganizeResult, OrganizeError> {
423 let base = resolve_base_dir(base_dir)?;
424 let mut result = plan_moves_from_paths(paths, pattern, &base)?;
425
426 if result.moves.is_empty() {
427 return Ok(result);
428 }
429
430 let mut completed_moves = Vec::new();
431 let mut new_errors = Vec::new();
432
433 for file_move in result.moves.drain(..) {
434 match execute_single_move_no_db(&file_move) {
435 Ok(()) => match verify_move(&file_move) {
436 Ok(()) => completed_moves.push(file_move),
437 Err(msg) => new_errors.push((file_move.from, msg)),
438 },
439 Err(e) => {
440 new_errors.push((file_move.from, e.to_string()));
441 }
442 }
443 }
444
445 result.moves = completed_moves;
446 result.errors.extend(new_errors);
447 Ok(result)
448}
449
450fn verify_move(file_move: &FileMove) -> Result<(), String> {
452 if !file_move.to.exists() {
453 return Err(format!(
454 "destination not found after move: {}",
455 file_move.to.display()
456 ));
457 }
458 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
459 return Err(format!(
460 "source still exists after move: {}",
461 file_move.from.display()
462 ));
463 }
464 Ok(())
465}
466
467fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
469 if let Some(parent) = file_move.to.parent() {
470 std::fs::create_dir_all(parent)?;
471 }
472
473 move_file(&file_move.from, &file_move.to)?;
474
475 for (anc_from, anc_to) in &file_move.ancillary {
477 if let Some(parent) = anc_to.parent() {
478 std::fs::create_dir_all(parent).ok();
479 }
480 if let Err(e) = move_file(anc_from, anc_to) {
481 log::warn!(
482 "failed to move ancillary file {}: {}",
483 anc_from.display(),
484 e
485 );
486 }
487 }
488
489 if let Some(source_dir) = file_move.from.parent() {
490 remove_empty_dirs(source_dir);
491 }
492
493 Ok(())
494}
495
496pub fn preview(
498 db: &Database,
499 pattern: &str,
500 base_dir: Option<&Path>,
501) -> Result<OrganizeResult, OrganizeError> {
502 let base = resolve_base_dir(base_dir)?;
503 plan_moves(db, pattern, &base, None)
504}
505
506pub fn execute(
508 db: &Database,
509 pattern: &str,
510 base_dir: Option<&Path>,
511) -> Result<OrganizeResult, OrganizeError> {
512 let base = resolve_base_dir(base_dir)?;
513 let mut result = plan_moves(db, pattern, &base, None)?;
514
515 if result.moves.is_empty() {
516 return Ok(result);
517 }
518
519 let batch_id = chrono_batch_id();
521
522 let mut completed_moves = Vec::new();
523 let mut new_errors = Vec::new();
524
525 for file_move in result.moves.drain(..) {
526 match execute_single_move(db, &file_move, &batch_id) {
527 Ok(()) => match verify_move(&file_move) {
528 Ok(()) => completed_moves.push(file_move),
529 Err(msg) => new_errors.push((file_move.from, msg)),
530 },
531 Err(e) => {
532 new_errors.push((file_move.from, e.to_string()));
533 }
534 }
535 }
536
537 result.moves = completed_moves;
538 result.errors.extend(new_errors);
539 Ok(result)
540}
541
542pub fn preview_for_tracks(
544 db: &Database,
545 track_ids: &[i64],
546 pattern: &str,
547 base_dir: Option<&Path>,
548) -> Result<OrganizeResult, OrganizeError> {
549 let base = resolve_base_dir(base_dir)?;
550 plan_moves(db, pattern, &base, Some(track_ids))
551}
552
553pub fn execute_for_tracks(
555 db: &Database,
556 track_ids: &[i64],
557 pattern: &str,
558 base_dir: Option<&Path>,
559) -> Result<OrganizeResult, OrganizeError> {
560 let base = resolve_base_dir(base_dir)?;
561 let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
562
563 if result.moves.is_empty() {
564 return Ok(result);
565 }
566
567 let batch_id = chrono_batch_id();
568 let mut completed_moves = Vec::new();
569 let mut new_errors = Vec::new();
570
571 for file_move in result.moves.drain(..) {
572 match execute_single_move(db, &file_move, &batch_id) {
573 Ok(()) => match verify_move(&file_move) {
574 Ok(()) => completed_moves.push(file_move),
575 Err(msg) => new_errors.push((file_move.from, msg)),
576 },
577 Err(e) => {
578 new_errors.push((file_move.from, e.to_string()));
579 }
580 }
581 }
582
583 result.moves = completed_moves;
584 result.errors.extend(new_errors);
585 Ok(result)
586}
587
588fn execute_single_move(
590 db: &Database,
591 file_move: &FileMove,
592 batch_id: &str,
593) -> Result<(), OrganizeError> {
594 if let Some(parent) = file_move.to.parent() {
596 std::fs::create_dir_all(parent)?;
597 }
598
599 move_file(&file_move.from, &file_move.to)?;
601
602 db.conn.execute(
604 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
605 params![
606 batch_id,
607 file_move.track_id,
608 file_move.from.to_string_lossy().as_ref(),
609 file_move.to.to_string_lossy().as_ref(),
610 ],
611 )?;
612
613 db.conn.execute(
615 "UPDATE tracks SET path = ?1 WHERE id = ?2",
616 params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
617 )?;
618
619 db.conn.execute(
621 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
622 params![
623 file_move.to.to_string_lossy().as_ref(),
624 file_move.from.to_string_lossy().as_ref(),
625 ],
626 )?;
627
628 for (anc_from, anc_to) in &file_move.ancillary {
630 if let Some(parent) = anc_to.parent() {
631 std::fs::create_dir_all(parent)?;
632 }
633 if move_file(anc_from, anc_to).is_ok() {
635 db.conn.execute(
636 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
637 params![
638 batch_id,
639 anc_from.to_string_lossy().as_ref(),
640 anc_to.to_string_lossy().as_ref(),
641 ],
642 )?;
643 }
644 }
645
646 if let Some(source_dir) = file_move.from.parent() {
648 remove_empty_dirs(source_dir);
649 }
650
651 Ok(())
652}
653
654pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
656 let batch_id: String = db
658 .conn
659 .query_row(
660 "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
661 [],
662 |row| row.get(0),
663 )
664 .map_err(|_| OrganizeError::NothingToUndo)?;
665
666 let mut stmt = db.conn.prepare(
668 "SELECT id, track_id, from_path, to_path FROM organize_log
669 WHERE batch_id = ?1 ORDER BY id DESC",
670 )?;
671
672 let entries: Vec<(i64, Option<i64>, String, String)> = stmt
673 .query_map(params![batch_id], |row| {
674 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
675 })?
676 .collect::<Result<Vec<_>, _>>()?;
677
678 let mut count = 0;
679
680 for (log_id, track_id, from_path, to_path) in &entries {
681 let to = Path::new(to_path);
682 let from = Path::new(from_path);
683
684 if !to.exists() {
685 db.conn
687 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
688 continue;
689 }
690
691 if let Some(parent) = from.parent() {
693 std::fs::create_dir_all(parent)?;
694 }
695
696 move_file(to, from)?;
698
699 if let Some(tid) = track_id {
701 db.conn.execute(
702 "UPDATE tracks SET path = ?1 WHERE id = ?2",
703 params![from_path, tid],
704 )?;
705 db.conn.execute(
706 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
707 params![from_path, to_path],
708 )?;
709 }
710
711 if let Some(parent) = to.parent() {
713 remove_empty_dirs(parent);
714 }
715
716 db.conn
717 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
718
719 count += 1;
720 }
721
722 Ok(count)
723}
724
725fn remove_empty_dirs(dir: &Path) {
727 let mut current = dir.to_path_buf();
728 loop {
729 if std::fs::read_dir(¤t)
730 .map(|mut d| d.next().is_none())
731 .unwrap_or(false)
732 {
733 if std::fs::remove_dir(¤t).is_err() {
734 break;
735 }
736 match current.parent() {
737 Some(p) => current = p.to_path_buf(),
738 None => break,
739 }
740 } else {
741 break;
742 }
743 }
744}
745
746fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
748 match std::fs::rename(from, to) {
749 Ok(()) => Ok(()),
750 Err(e) if e.raw_os_error() == Some(18) => {
751 std::fs::copy(from, to)?;
753 std::fs::remove_file(from)?;
754 Ok(())
755 }
756 Err(e) => Err(e.into()),
757 }
758}
759
760fn paths_equal(a: &Path, b: &Path) -> bool {
762 if a == b {
763 return true;
764 }
765 if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
767 return ca == cb;
768 }
769 false
770}
771
772fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
773 if let Some(dir) = base_dir {
774 return Ok(dir.to_path_buf());
775 }
776
777 let config = crate::config::Config::load()
779 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
780
781 config.library.folders.into_iter().next().ok_or_else(|| {
782 OrganizeError::Io(std::io::Error::other(
783 "no library folders configured; use --base-dir",
784 ))
785 })
786}
787
788fn chrono_batch_id() -> String {
789 let now = std::time::SystemTime::now()
790 .duration_since(std::time::UNIX_EPOCH)
791 .unwrap_or_default();
792 format!("batch-{}", now.as_nanos())
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::db::connection::Database;
799 use crate::db::queries::TrackMeta;
800 use crate::db::schema;
801
802 fn test_db() -> Database {
803 let conn = rusqlite::Connection::open_in_memory().unwrap();
804 conn.pragma_update(None, "foreign_keys", "on").unwrap();
805 schema::create_tables(&conn).unwrap();
806 Database { conn }
807 }
808
809 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
810 TrackMeta {
811 title: title.into(),
812 artist: artist.into(),
813 album_artist: Some(artist.into()),
814 album: album.into(),
815 date: Some("1997-06-16".into()),
816 disc: Some(1),
817 track_number: Some(1),
818 genre: Some("Rock".into()),
819 label: None,
820 duration_ms: Some(240_000),
821 codec: Some("FLAC".into()),
822 sample_rate: Some(44100),
823 bit_depth: Some(16),
824 channels: Some(2),
825 bitrate: Some(1000),
826 size_bytes: Some(30_000_000),
827 mtime: Some(1700000000),
828 path: None,
829 source: "local".into(),
830 remote_id: None,
831 remote_url: None,
832 }
833 }
834
835 #[test]
836 fn track_metadata_provider_fields() {
837 let track = TrackRow {
838 id: 1,
839 album_id: Some(1),
840 artist_id: Some(1),
841 artist_name: "Radiohead".into(),
842 album_artist_name: "Radiohead".into(),
843 album_title: "OK Computer".into(),
844 disc: Some(1),
845 track_number: Some(3),
846 title: "Subterranean Homesick Alien".into(),
847 duration_ms: Some(240_000),
848 path: Some("/music/ok_computer/03.flac".into()),
849 codec: Some("FLAC".into()),
850 sample_rate: Some(44100),
851 bit_depth: Some(16),
852 channels: Some(2),
853 bitrate: Some(1000),
854 genre: Some("Alternative".into()),
855 source: "local".into(),
856 remote_id: None,
857 cached_path: None,
858 };
859
860 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
861 assert_eq!(
862 meta.get_field("title").as_deref(),
863 Some("Subterranean Homesick Alien")
864 );
865 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
866 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
867 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
868 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
869 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
870 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
871 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
872 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
873 assert_eq!(meta.get_field("nonexistent"), None);
874 }
875
876 #[test]
877 fn sanitize_replaces_illegal_chars() {
878 assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
879 assert_eq!(sanitize_path_component("What?"), "What_");
880 assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
881 assert_eq!(sanitize_path_component("normal"), "normal");
882 }
883
884 #[test]
885 fn sanitize_relative_path_splits() {
886 let result = sanitize_relative_path("Artist/Album/Track");
887 assert_eq!(result, PathBuf::from("Artist/Album/Track"));
888
889 let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
890 assert_eq!(
891 result,
892 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
893 );
894 }
895
896 #[test]
897 fn sanitize_relative_path_rejects_traversal() {
898 let result = sanitize_relative_path("../../../../etc/passwd");
900 assert_eq!(result, PathBuf::from("etc/passwd"));
901
902 let result = sanitize_relative_path("Artist/../../../outside");
903 assert_eq!(result, PathBuf::from("Artist/outside"));
904
905 let result = sanitize_relative_path("./Artist/./Album");
907 assert_eq!(result, PathBuf::from("Artist/Album"));
908
909 let result = sanitize_relative_path("Artist/Album/Track");
911 assert_eq!(result, PathBuf::from("Artist/Album/Track"));
912 }
913
914 #[test]
915 fn acdc_artist_name_sanitized() {
916 let track = TrackRow {
918 id: 1,
919 album_id: Some(1),
920 artist_id: Some(1),
921 artist_name: "AC/DC".into(),
922 album_artist_name: "AC/DC".into(),
923 album_title: "Highway to Hell".into(),
924 disc: Some(1),
925 track_number: Some(1),
926 title: "Highway to Hell".into(),
927 duration_ms: None,
928 path: Some("/music/test.flac".into()),
929 codec: None,
930 sample_rate: None,
931 bit_depth: None,
932 channels: None,
933 bitrate: None,
934 genre: None,
935 source: "local".into(),
936 remote_id: None,
937 cached_path: None,
938 };
939 let meta = TrackMetadata::from_track_row(&track, Some("1979"));
940 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
941 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
942 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
943 }
944
945 #[test]
946 fn format_string_evaluation() {
947 let track = TrackRow {
948 id: 1,
949 album_id: Some(1),
950 artist_id: Some(1),
951 artist_name: "Radiohead".into(),
952 album_artist_name: "Radiohead".into(),
953 album_title: "OK Computer".into(),
954 disc: Some(1),
955 track_number: Some(1),
956 title: "Airbag".into(),
957 duration_ms: Some(240_000),
958 path: Some("/music/test.flac".into()),
959 codec: Some("FLAC".into()),
960 sample_rate: Some(44100),
961 bit_depth: Some(16),
962 channels: Some(2),
963 bitrate: Some(1000),
964 genre: None,
965 source: "local".into(),
966 remote_id: None,
967 cached_path: None,
968 };
969
970 let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
971 let pattern =
972 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
973 let result = format::format(pattern, &meta).unwrap();
974 assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
975 }
976
977 #[test]
978 fn preview_does_not_move_files() {
979 let db = test_db();
980 let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
981 std::fs::create_dir_all(&tmp).unwrap();
982
983 let test_file = tmp.join("test.flac");
985 std::fs::write(&test_file, b"fake audio data").unwrap();
986
987 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
988 meta.path = Some(test_file.to_string_lossy().into());
989 queries::upsert_track(&db.conn, &meta).unwrap();
990
991 let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
992 assert!(test_file.exists());
994 assert!(!result.moves.is_empty());
995
996 std::fs::remove_dir_all(&tmp).ok();
998 }
999
1000 #[test]
1001 fn execute_moves_files_and_undo_reverts() {
1002 let db = test_db();
1003 let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
1004 let src_dir = tmp.join("src");
1005 std::fs::create_dir_all(&src_dir).unwrap();
1006
1007 let test_file = src_dir.join("test.flac");
1009 std::fs::write(&test_file, b"fake audio data").unwrap();
1010
1011 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1012 meta.path = Some(test_file.to_string_lossy().into());
1013 queries::upsert_track(&db.conn, &meta).unwrap();
1014
1015 let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1017 assert_eq!(result.moves.len(), 1);
1018 assert!(!test_file.exists()); let dest = &result.moves[0].to;
1020 assert!(dest.exists()); let undone = undo(&db).unwrap();
1024 assert_eq!(undone, 1);
1025 assert!(test_file.exists()); assert!(!dest.exists()); std::fs::remove_dir_all(&tmp).ok();
1030 }
1031
1032 #[test]
1033 fn ancillary_file_detection() {
1034 let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1035 std::fs::create_dir_all(&tmp).unwrap();
1036
1037 std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1038 std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1039 std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1040 std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1041 std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); let found = find_ancillary_files(&tmp);
1044 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1045 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1046 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1047 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1048 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1049
1050 std::fs::remove_dir_all(&tmp).ok();
1051 }
1052
1053 #[test]
1054 fn extension_not_clobbered_by_dots_in_title() {
1055 let db = test_db();
1058 let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1059 let src_dir = tmp.join("src");
1060 std::fs::create_dir_all(&src_dir).unwrap();
1061
1062 let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1064 std::fs::write(&test_file, b"fake").unwrap();
1065
1066 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1067 meta.track_number = Some(10);
1068 meta.disc = Some(1);
1069 meta.date = Some("2025-11-21".into());
1070 meta.codec = Some("FLAC".into());
1071 meta.album_artist = Some("Bicep".into());
1072 meta.path = Some(test_file.to_string_lossy().into());
1073 queries::upsert_track(&db.conn, &meta).unwrap();
1074
1075 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1076 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1077 assert_eq!(result.moves.len(), 1);
1078
1079 let dest_name = result.moves[0]
1080 .to
1081 .file_name()
1082 .unwrap()
1083 .to_string_lossy()
1084 .to_string();
1085 assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1087
1088 std::fs::remove_dir_all(&tmp).ok();
1089 }
1090
1091 #[test]
1092 fn extension_preserved_for_tracknumber_dot() {
1093 let db = test_db();
1095 let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1096 let src_dir = tmp.join("src");
1097 std::fs::create_dir_all(&src_dir).unwrap();
1098
1099 let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1100 std::fs::write(&test_file, b"fake").unwrap();
1101
1102 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1103 meta.track_number = Some(11);
1104 meta.disc = Some(1);
1105 meta.date = Some("2025-11-21".into());
1106 meta.codec = Some("FLAC".into());
1107 meta.album_artist = Some("Bicep".into());
1108 meta.path = Some(test_file.to_string_lossy().into());
1109 queries::upsert_track(&db.conn, &meta).unwrap();
1110
1111 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1112 let result = preview(&db, pattern, Some(&tmp)).unwrap();
1113 assert_eq!(result.moves.len(), 1);
1114
1115 let dest_name = result.moves[0]
1116 .to
1117 .file_name()
1118 .unwrap()
1119 .to_string_lossy()
1120 .to_string();
1121 assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1122
1123 std::fs::remove_dir_all(&tmp).ok();
1124 }
1125}