1use std::collections::{HashMap, HashSet};
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use rusqlite::{Connection, params};
7
8use crate::db::connection::{Database, DbError};
9use crate::db::queries::{self, PersistedQueueItem, TrackRow};
10use crate::format::{self, FormatError, MetadataProvider};
11use crate::helpers::{sanitise_filename, truncate_bytes};
12
13const ANCILLARY_PATTERNS: &[&str] = &[
15 "cover.jpg",
16 "cover.png",
17 "cover.webp",
18 "folder.jpg",
19 "folder.png",
20 "front.jpg",
21 "front.png",
22];
23
24const ANCILLARY_EXTENSIONS: &[&str] = &["cue", "log", "m3u", "m3u8"];
25
26const MAX_FILE_NAME_BYTES: usize = 250;
29
30#[derive(Debug, thiserror::Error)]
31pub enum OrganizeError {
32 #[error("database error: {0}")]
33 Db(#[from] DbError),
34 #[error("sqlite error: {0}")]
35 Sqlite(#[from] rusqlite::Error),
36 #[error("format error: {0}")]
37 Format(#[from] FormatError),
38 #[error("io error: {0}")]
39 Io(#[from] std::io::Error),
40 #[error("no tracks with local paths found")]
41 NoLocalTracks,
42 #[error("no organize batches to undo")]
43 NothingToUndo,
44 #[error("destination already exists: {0}")]
45 DestinationExists(PathBuf),
46 #[error("copied {copied} of {expected} bytes from {path}")]
47 ShortCopy {
48 path: PathBuf,
49 expected: u64,
50 copied: u64,
51 },
52 #[error("not enough free space: {needed} bytes needed, {available} available")]
53 NotEnoughSpace { needed: u64, available: u64 },
54}
55
56#[derive(Debug)]
57pub struct OrganizeResult {
58 pub moves: Vec<FileMove>,
59 pub errors: Vec<(PathBuf, String)>,
60 pub skipped: usize,
61}
62
63#[derive(Debug)]
64pub struct FileMove {
65 pub track_id: Option<i64>,
68 pub from: PathBuf,
69 pub to: PathBuf,
70 pub ancillary: Vec<(PathBuf, PathBuf)>,
71}
72
73type UndoEntry = (i64, String, String, Option<i64>, Option<i64>);
76
77#[derive(Debug, Default)]
78pub struct UndoResult {
79 pub restored: usize,
80 pub errors: Vec<(PathBuf, String)>,
81}
82
83enum Selection<'a> {
85 All,
86 TrackIds(&'a [i64]),
87 Paths(&'a [PathBuf]),
88}
89
90#[derive(Default, Clone)]
92struct AlbumFacts {
93 date: Option<String>,
94 label: Option<String>,
95}
96
97struct ResolvedTrack {
99 source: PathBuf,
100 track_id: Option<i64>,
101 metadata: Result<TrackMetadata, String>,
102}
103
104struct TrackMetadata {
106 fields: HashMap<String, String>,
107}
108
109impl TrackMetadata {
110 fn from_track_row(track: &TrackRow, album: &AlbumFacts) -> Self {
111 let mut fields = HashMap::new();
112 let s = sanitise_filename;
114 fields.insert("title".into(), s(&track.title));
115 fields.insert("artist".into(), s(&track.artist_name));
116 fields.insert("album artist".into(), s(&track.album_artist_name));
117 fields.insert("album".into(), s(&track.album_title));
118 if let Some(n) = track.track_number {
119 fields.insert("tracknumber".into(), format!("{n:02}"));
120 }
121 if let Some(d) = track.disc {
122 fields.insert("discnumber".into(), d.to_string());
123 }
124 if let Some(ref date) = album.date {
125 fields.insert("date".into(), s(date));
126 }
127 if let Some(ref label) = album.label {
128 fields.insert("label".into(), s(label));
129 }
130 if let Some(ref codec) = track.codec {
131 fields.insert("codec".into(), s(codec));
132 }
133 if let Some(ref genre) = track.genre {
134 fields.insert("genre".into(), s(genre));
135 }
136 Self { fields }
137 }
138
139 fn from_file_meta(meta: &queries::TrackMeta) -> Self {
143 let mut fields = HashMap::new();
144 let s = sanitise_filename;
145 fields.insert("title".into(), s(&meta.title));
146 fields.insert("artist".into(), s(&meta.artist));
147 fields.insert(
148 "album artist".into(),
149 s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
150 );
151 fields.insert("album".into(), s(&meta.album));
152 if let Some(n) = meta.track_number {
153 fields.insert("tracknumber".into(), format!("{n:02}"));
154 }
155 if let Some(d) = meta.disc {
156 fields.insert("discnumber".into(), d.to_string());
157 }
158 if let Some(ref date) = meta.date {
159 fields.insert("date".into(), s(date));
160 }
161 if let Some(ref label) = meta.label {
162 fields.insert("label".into(), s(label));
163 }
164 if let Some(ref codec) = meta.codec {
165 fields.insert("codec".into(), s(codec));
166 }
167 if let Some(ref genre) = meta.genre {
168 fields.insert("genre".into(), s(genre));
169 }
170 Self { fields }
171 }
172}
173
174impl MetadataProvider for TrackMetadata {
175 fn get_field(&self, name: &str) -> Option<String> {
176 self.fields.get(name).cloned()
177 }
178}
179
180fn sanitize_relative_path(rel: &str) -> Result<PathBuf, String> {
186 let mut result = PathBuf::new();
187 for part in rel.split(['/', std::path::MAIN_SEPARATOR]) {
188 let sanitized = sanitise_filename(part);
189 if sanitized.is_empty() {
190 return Err(format!(
191 "format string produced an empty path component: {rel:?}"
192 ));
193 }
194 if sanitized == "." || sanitized == ".." {
195 return Err(format!(
196 "format string produced a relative path component: {rel:?}"
197 ));
198 }
199 result.push(sanitized);
200 }
201 if result.as_os_str().is_empty() {
202 return Err("format string produced an empty path".into());
203 }
204 Ok(result)
205}
206
207fn load_album_facts(conn: &Connection) -> Result<HashMap<i64, AlbumFacts>, OrganizeError> {
210 let mut stmt = conn.prepare("SELECT id, date, label FROM albums")?;
211 let rows = stmt.query_map([], |row| {
212 Ok((
213 row.get::<_, i64>(0)?,
214 AlbumFacts {
215 date: row.get(1)?,
216 label: row.get(2)?,
217 },
218 ))
219 })?;
220 let mut map = HashMap::new();
221 for row in rows {
222 let (id, facts) = row?;
223 map.insert(id, facts);
224 }
225 Ok(map)
226}
227
228fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
230 let mut files = Vec::new();
231 let Ok(entries) = std::fs::read_dir(track_dir) else {
232 return files;
233 };
234
235 for entry in entries.flatten() {
236 let path = entry.path();
237 if !path.is_file() {
238 continue;
239 }
240 let name = path
241 .file_name()
242 .and_then(|n| n.to_str())
243 .unwrap_or_default()
244 .to_lowercase();
245
246 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
248 files.push(path);
249 continue;
250 }
251 if let Some(ext) = path.extension().and_then(|e| e.to_str())
253 && ANCILLARY_EXTENSIONS
254 .iter()
255 .any(|e| ext.eq_ignore_ascii_case(e))
256 {
257 files.push(path);
258 }
259 }
260 files.sort();
261 files
262}
263
264#[derive(Default)]
267struct DestinationLedger {
268 taken: HashSet<String>,
269}
270
271impl DestinationLedger {
272 fn key(path: &Path) -> String {
275 let key = path.to_string_lossy().into_owned();
276 if cfg!(any(target_os = "macos", target_os = "windows")) {
277 key.to_lowercase()
278 } else {
279 key
280 }
281 }
282
283 fn claim(&mut self, path: &Path) -> bool {
285 self.taken.insert(Self::key(path))
286 }
287}
288
289fn plan_single_move(
294 source: &Path,
295 track_id: Option<i64>,
296 metadata: &TrackMetadata,
297 pattern: &str,
298 base_dir: &Path,
299 dests: &mut DestinationLedger,
300 planned_ancillary: &mut HashSet<PathBuf>,
301) -> Result<Option<FileMove>, String> {
302 let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
303
304 if relative.is_empty() {
305 return Err("format string produced empty path".into());
306 }
307
308 let sanitized = sanitize_relative_path(&relative)?;
309
310 let ext = source
314 .extension()
315 .and_then(|e| e.to_str())
316 .unwrap_or("flac");
317 let stem = sanitized
318 .file_name()
319 .and_then(|n| n.to_str())
320 .ok_or_else(|| "format string produced an unusable file name".to_string())?;
321 let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
324 if stem.is_empty() {
325 return Err("format string produced an empty file name".into());
326 }
327 let mut dest = base_dir.to_path_buf();
328 if let Some(parent) = sanitized.parent() {
329 dest.push(parent);
330 }
331 dest.push(format!("{stem}.{ext}"));
332
333 if !dest.starts_with(base_dir) {
335 return Err(format!(
336 "path traversal blocked: destination {} escapes base dir {}",
337 dest.display(),
338 base_dir.display()
339 ));
340 }
341
342 if source == dest {
343 dests.claim(&dest);
345 return Ok(None);
346 }
347
348 if !dests.claim(&dest) {
349 return Err(format!(
350 "two files resolve to the same destination: {}",
351 dest.display()
352 ));
353 }
354
355 if dest.exists() && !paths_equal(source, &dest) {
358 return Err(format!(
359 "destination already exists: {} (moving here would overwrite it)",
360 dest.display()
361 ));
362 }
363
364 let source_dir = source.parent().unwrap_or(Path::new("."));
366 let dest_dir = dest.parent().unwrap_or(Path::new("."));
367 let mut ancillary = Vec::new();
368
369 if source_dir != dest_dir {
370 for anc_path in find_ancillary_files(source_dir) {
371 if planned_ancillary.contains(&anc_path) {
372 continue;
373 }
374 let Some(anc_name) = anc_path.file_name() else {
375 continue;
376 };
377 let anc_dest = dest_dir.join(anc_name);
378 if anc_dest.exists() || !dests.claim(&anc_dest) {
381 continue;
382 }
383 planned_ancillary.insert(anc_path.clone());
384 ancillary.push((anc_path, anc_dest));
385 }
386 }
387
388 Ok(Some(FileMove {
389 track_id,
390 from: source.to_path_buf(),
391 to: dest,
392 ancillary,
393 }))
394}
395
396fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
397 let fallback = AlbumFacts::default();
398 rows.into_iter()
399 .filter_map(|track| {
400 let source = PathBuf::from(track.path.as_ref()?);
401 if !source.exists() {
402 return None; }
404 let facts = track
405 .album_id
406 .and_then(|id| albums.get(&id))
407 .unwrap_or(&fallback);
408 Some(ResolvedTrack {
409 source,
410 track_id: Some(track.id),
411 metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
412 })
413 })
414 .collect()
415}
416
417fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
418 if !source.exists() {
419 return Err("file not found".to_string());
420 }
421 crate::index::metadata::read_metadata(source)
422 .map(|m| TrackMetadata::from_file_meta(&m))
423 .map_err(|e| format!("metadata error: {e}"))
424}
425
426fn resolve_from_paths(
429 db: &Database,
430 paths: &[PathBuf],
431 albums: &HashMap<i64, AlbumFacts>,
432) -> Result<Vec<ResolvedTrack>, OrganizeError> {
433 use rayon::prelude::*;
434
435 let path_strings: Vec<String> = paths
436 .iter()
437 .map(|p| p.to_string_lossy().into_owned())
438 .collect();
439 let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
440
441 let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
443 .par_iter()
444 .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
445 .map(|p| (p.clone(), read_tag_metadata(p)))
446 .collect();
447
448 let fallback = AlbumFacts::default();
449 let mut resolved = Vec::with_capacity(paths.len());
450 for (path, path_str) in paths.iter().zip(&path_strings) {
451 let entry = match known.get(path_str) {
452 Some(track) => {
453 let facts = track
454 .album_id
455 .and_then(|id| albums.get(&id))
456 .unwrap_or(&fallback);
457 ResolvedTrack {
458 source: path.clone(),
459 track_id: Some(track.id),
460 metadata: Ok(TrackMetadata::from_track_row(track, facts)),
461 }
462 }
463 None => ResolvedTrack {
464 source: path.clone(),
465 track_id: None,
466 metadata: tagged
467 .remove(path)
468 .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
469 },
470 };
471 resolved.push(entry);
472 }
473 Ok(resolved)
474}
475
476fn plan(
479 db: &Database,
480 selection: Selection<'_>,
481 pattern: &str,
482 base_dir: &Path,
483) -> Result<OrganizeResult, OrganizeError> {
484 let albums = load_album_facts(&db.conn)?;
485
486 let resolved = match selection {
487 Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
488 Selection::TrackIds(ids) => {
489 let mut rows = Vec::with_capacity(ids.len());
490 for &id in ids {
491 if let Some(row) = queries::get_track_row(&db.conn, id)? {
492 rows.push(row);
493 }
494 }
495 resolve_from_rows(rows, &albums)
496 }
497 Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
498 };
499
500 let mut moves = Vec::new();
501 let mut errors = Vec::new();
502 let mut skipped = 0;
503 let mut dests = DestinationLedger::default();
504 let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
505
506 for entry in resolved {
507 let metadata = match entry.metadata {
508 Ok(m) => m,
509 Err(msg) => {
510 errors.push((entry.source, msg));
511 continue;
512 }
513 };
514 match plan_single_move(
515 &entry.source,
516 entry.track_id,
517 &metadata,
518 pattern,
519 base_dir,
520 &mut dests,
521 &mut planned_ancillary,
522 ) {
523 Ok(Some(file_move)) => moves.push(file_move),
524 Ok(None) => skipped += 1,
525 Err(msg) => errors.push((entry.source, msg)),
526 }
527 }
528
529 Ok(OrganizeResult {
530 moves,
531 errors,
532 skipped,
533 })
534}
535
536fn run(
539 db: &Database,
540 selection: Selection<'_>,
541 pattern: &str,
542 base_dir: &Path,
543) -> Result<OrganizeResult, OrganizeError> {
544 let mut result = plan(db, selection, pattern, base_dir)?;
545
546 if result.moves.is_empty() {
547 return Ok(result);
548 }
549
550 check_free_space(&result.moves, base_dir)?;
551
552 let batch_id = batch_id();
553 let floors = cleanup_floors(Some(base_dir));
554 let mut completed_moves = Vec::new();
555 let mut new_errors = Vec::new();
556
557 for file_move in result.moves.drain(..) {
558 match execute_single_move(db, &file_move, &batch_id, &floors) {
559 Ok(()) => match verify_move(&file_move) {
560 Ok(()) => completed_moves.push(file_move),
561 Err(msg) => new_errors.push((file_move.from, msg)),
562 },
563 Err(e) => {
564 new_errors.push((file_move.from, e.to_string()));
565 }
566 }
567 }
568
569 result.moves = completed_moves;
570 result.errors.extend(new_errors);
571 Ok(result)
572}
573
574pub fn preview(
576 db: &Database,
577 pattern: &str,
578 base_dir: Option<&Path>,
579) -> Result<OrganizeResult, OrganizeError> {
580 let base = resolve_base_dir(base_dir)?;
581 plan(db, Selection::All, pattern, &base)
582}
583
584pub fn execute(
586 db: &Database,
587 pattern: &str,
588 base_dir: Option<&Path>,
589) -> Result<OrganizeResult, OrganizeError> {
590 let base = resolve_base_dir(base_dir)?;
591 run(db, Selection::All, pattern, &base)
592}
593
594pub fn preview_for_tracks(
596 db: &Database,
597 track_ids: &[i64],
598 pattern: &str,
599 base_dir: Option<&Path>,
600) -> Result<OrganizeResult, OrganizeError> {
601 let base = resolve_base_dir(base_dir)?;
602 plan(db, Selection::TrackIds(track_ids), pattern, &base)
603}
604
605pub fn execute_for_tracks(
607 db: &Database,
608 track_ids: &[i64],
609 pattern: &str,
610 base_dir: Option<&Path>,
611) -> Result<OrganizeResult, OrganizeError> {
612 let base = resolve_base_dir(base_dir)?;
613 run(db, Selection::TrackIds(track_ids), pattern, &base)
614}
615
616pub fn preview_for_paths(
618 paths: &[PathBuf],
619 pattern: &str,
620 base_dir: Option<&Path>,
621) -> Result<OrganizeResult, OrganizeError> {
622 let db = Database::open_default()?;
623 let base = resolve_base_dir(base_dir)?;
624 plan(&db, Selection::Paths(paths), pattern, &base)
625}
626
627pub fn execute_for_paths(
630 paths: &[PathBuf],
631 pattern: &str,
632 base_dir: Option<&Path>,
633) -> Result<OrganizeResult, OrganizeError> {
634 let db = Database::open_default()?;
635 let base = resolve_base_dir(base_dir)?;
636 run(&db, Selection::Paths(paths), pattern, &base)
637}
638
639fn verify_move(file_move: &FileMove) -> Result<(), String> {
641 if !file_move.to.exists() {
642 return Err(format!(
643 "destination not found after move: {}",
644 file_move.to.display()
645 ));
646 }
647 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
648 return Err(format!(
649 "source still exists after move: {}",
650 file_move.from.display()
651 ));
652 }
653 Ok(())
654}
655
656fn log_move(
657 conn: &Connection,
658 batch_id: &str,
659 track_id: Option<i64>,
660 from: &Path,
661 to: &Path,
662 size: Option<u64>,
663 mtime: Option<i64>,
664) -> Result<(), OrganizeError> {
665 conn.execute(
666 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
667 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
668 params![
669 batch_id,
670 track_id,
671 from.to_string_lossy().as_ref(),
672 to.to_string_lossy().as_ref(),
673 size.map(|s| s as i64),
674 mtime,
675 ],
676 )?;
677 Ok(())
678}
679
680fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
686 let old_lossy = old.to_string_lossy();
687 let new_lossy = new.to_string_lossy();
688 let old_path = old_lossy.as_ref();
689 let new_path = new_lossy.as_ref();
690
691 conn.execute(
692 "UPDATE tracks SET path = ?1 WHERE path = ?2",
693 params![new_path, old_path],
694 )?;
695 conn.execute(
696 "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
697 params![new_path, old_path],
698 )?;
699 conn.execute(
700 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
701 params![new_path, old_path],
702 )?;
703 conn.execute(
706 "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
707 params![new_path, old_path],
708 )?;
709 conn.execute(
710 "UPDATE queue_snapshots SET cursor_path = ?1 WHERE cursor_path = ?2",
711 params![new_path, old_path],
712 )?;
713 conn.execute(
714 "UPDATE playback_state SET cursor_id = ?1 WHERE cursor_id = ?2",
715 params![new_path, old_path],
716 )?;
717 rewrite_queue_json(conn, "queue_snapshots", old_path, new_path)?;
718 rewrite_queue_json(conn, "playback_state", old_path, new_path)?;
719 Ok(())
720}
721
722fn rewrite_queue_json(
724 conn: &Connection,
725 table: &str,
726 old_path: &str,
727 new_path: &str,
728) -> Result<(), OrganizeError> {
729 let mut stmt = conn.prepare(&format!(
730 "SELECT id, queue_json FROM {table} WHERE instr(queue_json, ?1) > 0"
731 ))?;
732 let rows: Vec<(i64, String)> = stmt
733 .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
734 .collect::<Result<Vec<_>, _>>()?;
735 drop(stmt);
736
737 for (id, json) in rows {
738 let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
739 continue;
740 };
741 let mut changed = false;
742 for item in &mut items {
743 if item.path == old_path {
744 item.path = new_path.to_string();
745 changed = true;
746 }
747 }
748 if !changed {
749 continue;
750 }
751 let Ok(updated) = serde_json::to_string(&items) else {
752 continue;
753 };
754 conn.execute(
755 &format!("UPDATE {table} SET queue_json = ?1 WHERE id = ?2"),
756 params![updated, id],
757 )?;
758 }
759 Ok(())
760}
761
762fn execute_single_move(
766 db: &Database,
767 file_move: &FileMove,
768 batch_id: &str,
769 floors: &[PathBuf],
770) -> Result<(), OrganizeError> {
771 if let Some(parent) = file_move.to.parent() {
772 std::fs::create_dir_all(parent)?;
773 }
774
775 let source_meta = std::fs::metadata(&file_move.from)?;
776 let size = source_meta.len();
777 let mtime = mtime_secs(&source_meta);
778
779 let tx = db.conn.unchecked_transaction()?;
780 log_move(
781 &tx,
782 batch_id,
783 file_move.track_id,
784 &file_move.from,
785 &file_move.to,
786 Some(size),
787 mtime,
788 )?;
789 rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
790
791 move_file(&file_move.from, &file_move.to)?;
793
794 let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
795 let mut failure = None;
796 for (anc_from, anc_to) in &file_move.ancillary {
797 if let Some(parent) = anc_to.parent()
798 && std::fs::create_dir_all(parent).is_err()
799 {
800 continue;
801 }
802 match move_file(anc_from, anc_to) {
804 Ok(()) => {
805 moved_ancillary.push((anc_from, anc_to));
806 let meta = std::fs::metadata(anc_to).ok();
807 if let Err(e) = log_move(
808 &tx,
809 batch_id,
810 None,
811 anc_from,
812 anc_to,
813 meta.as_ref().map(|m| m.len()),
814 meta.as_ref().and_then(mtime_secs),
815 ) {
816 failure = Some(e);
817 break;
818 }
819 }
820 Err(e) => log::warn!(
821 "failed to move ancillary file {}: {}",
822 anc_from.display(),
823 e
824 ),
825 }
826 }
827
828 let outcome = match failure {
829 Some(e) => Err(e),
830 None => tx.commit().map_err(OrganizeError::from),
831 };
832
833 if let Err(e) = outcome {
834 for (anc_from, anc_to) in moved_ancillary {
837 let _ = move_file(anc_to, anc_from);
838 }
839 let _ = move_file(&file_move.to, &file_move.from);
840 return Err(e);
841 }
842
843 if let Some(source_dir) = file_move.from.parent() {
844 remove_empty_dirs(source_dir, floors);
845 }
846
847 Ok(())
848}
849
850pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
856 let batch_id: String = db
859 .conn
860 .query_row(
861 "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
862 [],
863 |row| row.get(0),
864 )
865 .map_err(|_| OrganizeError::NothingToUndo)?;
866
867 let mut stmt = db.conn.prepare(
868 "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
869 WHERE batch_id = ?1 ORDER BY id DESC",
870 )?;
871
872 let entries: Vec<UndoEntry> = stmt
873 .query_map(params![batch_id], |row| {
874 Ok((
875 row.get(0)?,
876 row.get(1)?,
877 row.get(2)?,
878 row.get(3)?,
879 row.get(4)?,
880 ))
881 })?
882 .collect::<Result<Vec<_>, _>>()?;
883 drop(stmt);
884
885 let floors = cleanup_floors(None);
886 let mut result = UndoResult::default();
887
888 for (log_id, from_path, to_path, size, mtime) in &entries {
889 let to = Path::new(to_path);
890 let from = Path::new(from_path);
891
892 if !to.exists() {
893 db.conn
895 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
896 continue;
897 }
898
899 if from.exists() && !paths_equal(from, to) {
900 result.errors.push((
901 from.to_path_buf(),
902 format!(
903 "another file now occupies the original path; {} left in place",
904 to.display()
905 ),
906 ));
907 continue;
908 }
909
910 if let Err(msg) = matches_logged_file(to, *size, *mtime) {
911 result.errors.push((to.to_path_buf(), msg));
912 continue;
913 }
914
915 if let Some(parent) = from.parent()
916 && let Err(e) = std::fs::create_dir_all(parent)
917 {
918 result.errors.push((from.to_path_buf(), e.to_string()));
919 continue;
920 }
921
922 let tx = db.conn.unchecked_transaction()?;
923 if let Err(e) = rewrite_path_references(&tx, to, from) {
924 result.errors.push((to.to_path_buf(), e.to_string()));
925 continue;
926 }
927 if let Err(e) = move_file(to, from) {
928 result.errors.push((to.to_path_buf(), e.to_string()));
929 continue;
930 }
931 if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
932 let _ = move_file(from, to);
933 result.errors.push((to.to_path_buf(), e.to_string()));
934 continue;
935 }
936 if let Err(e) = tx.commit() {
937 let _ = move_file(from, to);
938 result.errors.push((to.to_path_buf(), e.to_string()));
939 continue;
940 }
941
942 if let Some(parent) = to.parent() {
943 remove_empty_dirs(parent, &floors);
944 }
945
946 result.restored += 1;
947 }
948
949 Ok(result)
950}
951
952fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
955 let (Some(size), Some(mtime)) = (size, mtime) else {
956 return Ok(());
957 };
958 let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
959 if meta.len() != size as u64 {
960 return Err(format!(
961 "{} has changed since it was moved (size differs); left in place",
962 path.display()
963 ));
964 }
965 if mtime_secs(&meta).is_some_and(|current| current != mtime) {
966 return Err(format!(
967 "{} has changed since it was moved (modification time differs); left in place",
968 path.display()
969 ));
970 }
971 Ok(())
972}
973
974fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
975 meta.modified()
976 .ok()?
977 .duration_since(UNIX_EPOCH)
978 .ok()
979 .map(|d| d.as_secs() as i64)
980}
981
982fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
984 let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
985 if let Ok(config) = crate::config::Config::load() {
986 floors.extend(config.library.folders);
987 }
988 floors
989}
990
991fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
994 let mut current = start.to_path_buf();
995 loop {
996 if floors.iter().any(|floor| floor == ¤t) {
997 break;
998 }
999 let empty = std::fs::read_dir(¤t)
1000 .map(|mut d| d.next().is_none())
1001 .unwrap_or(false);
1002 if !empty || std::fs::remove_dir(¤t).is_err() {
1003 break;
1004 }
1005 let Some(parent) = current.parent() else {
1006 break;
1007 };
1008 if !floors
1010 .iter()
1011 .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1012 {
1013 break;
1014 }
1015 current = parent.to_path_buf();
1016 }
1017}
1018
1019fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1021 if from == to {
1022 return Ok(());
1023 }
1024 if paths_equal(from, to) {
1025 return rename_via_temp(from, to);
1029 }
1030
1031 match std::fs::OpenOptions::new()
1034 .write(true)
1035 .create_new(true)
1036 .open(to)
1037 {
1038 Ok(_) => {}
1039 Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1040 return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1041 }
1042 Err(e) => return Err(e.into()),
1043 }
1044
1045 match transfer(from, to) {
1046 Ok(()) => Ok(()),
1047 Err(e) => {
1048 let _ = std::fs::remove_file(to);
1050 Err(e)
1051 }
1052 }
1053}
1054
1055fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1056 let temp = temp_sibling(to);
1057 std::fs::rename(from, &temp)?;
1058 match std::fs::rename(&temp, to) {
1059 Ok(()) => Ok(()),
1060 Err(e) => {
1061 let _ = std::fs::rename(&temp, from);
1062 Err(e.into())
1063 }
1064 }
1065}
1066
1067fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1069 match std::fs::rename(from, to) {
1070 Ok(()) => Ok(()),
1071 Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1073 Err(e) => Err(e.into()),
1074 }
1075}
1076
1077fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1080 let source_meta = std::fs::metadata(from)?;
1081 let expected = source_meta.len();
1082 let temp = temp_sibling(to);
1083
1084 let copied = {
1085 let mut reader = std::fs::File::open(from)?;
1086 let mut writer = std::fs::OpenOptions::new()
1087 .write(true)
1088 .create_new(true)
1089 .open(&temp)?;
1090 let copied = std::io::copy(&mut reader, &mut writer)?;
1091 writer.sync_all()?;
1093 if let Ok(modified) = source_meta.modified() {
1094 let _ = writer.set_modified(modified);
1095 }
1096 copied
1097 };
1098
1099 let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1100 if copied != expected || written != expected {
1101 let _ = std::fs::remove_file(&temp);
1102 return Err(OrganizeError::ShortCopy {
1103 path: from.to_path_buf(),
1104 expected,
1105 copied: copied.min(written),
1106 });
1107 }
1108
1109 if let Err(e) = std::fs::rename(&temp, to) {
1110 let _ = std::fs::remove_file(&temp);
1111 return Err(e.into());
1112 }
1113 std::fs::remove_file(from)?;
1114 Ok(())
1115}
1116
1117fn temp_sibling(path: &Path) -> PathBuf {
1118 let nanos = SystemTime::now()
1119 .duration_since(UNIX_EPOCH)
1120 .unwrap_or_default()
1121 .as_nanos();
1122 path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1123}
1124
1125fn paths_equal(a: &Path, b: &Path) -> bool {
1128 if a == b {
1129 return true;
1130 }
1131 #[cfg(unix)]
1132 {
1133 use std::os::unix::fs::MetadataExt;
1134 if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1135 return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1136 }
1137 }
1138 false
1139}
1140
1141fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1144 let Some(target) = existing_ancestor(base_dir) else {
1145 return Ok(());
1146 };
1147 let Some(target_device) = device_id(&target) else {
1148 return Ok(());
1149 };
1150
1151 let mut needed = 0u64;
1152 for file_move in moves {
1153 if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1154 continue;
1155 }
1156 if let Ok(meta) = std::fs::metadata(&file_move.from) {
1157 needed = needed.saturating_add(meta.len());
1158 }
1159 }
1160 if needed == 0 {
1161 return Ok(());
1162 }
1163
1164 match available_bytes(&target) {
1165 Some(available) if available < needed => {
1166 Err(OrganizeError::NotEnoughSpace { needed, available })
1167 }
1168 _ => Ok(()),
1169 }
1170}
1171
1172fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1173 path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1174}
1175
1176#[cfg(unix)]
1177fn device_id(path: &Path) -> Option<u64> {
1178 use std::os::unix::fs::MetadataExt;
1179 std::fs::metadata(path).ok().map(|m| m.dev())
1180}
1181
1182#[cfg(not(unix))]
1183fn device_id(_path: &Path) -> Option<u64> {
1184 None
1185}
1186
1187#[cfg(unix)]
1188fn available_bytes(path: &Path) -> Option<u64> {
1189 use std::os::unix::ffi::OsStrExt;
1190 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1191 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1192 if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1193 return None;
1194 }
1195 (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1197}
1198
1199#[cfg(not(unix))]
1200fn available_bytes(_path: &Path) -> Option<u64> {
1201 None
1202}
1203
1204fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1205 if let Some(dir) = base_dir {
1206 return Ok(dir.to_path_buf());
1207 }
1208
1209 let config = crate::config::Config::load()
1211 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
1212
1213 config.library.folders.into_iter().next().ok_or_else(|| {
1214 OrganizeError::Io(std::io::Error::other(
1215 "no library folders configured; use --base-dir",
1216 ))
1217 })
1218}
1219
1220fn batch_id() -> String {
1221 let now = SystemTime::now()
1222 .duration_since(UNIX_EPOCH)
1223 .unwrap_or_default();
1224 format!("batch-{}", now.as_nanos())
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use crate::db::queries::TrackMeta;
1231 use crate::db::schema;
1232 use tempfile::TempDir;
1233
1234 fn test_db() -> Database {
1235 let conn = rusqlite::Connection::open_in_memory().unwrap();
1236 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1237 schema::create_tables(&conn).unwrap();
1238 Database { conn }
1239 }
1240
1241 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1242 TrackMeta {
1243 title: title.into(),
1244 artist: artist.into(),
1245 album_artist: Some(artist.into()),
1246 album: album.into(),
1247 date: Some("1997-06-16".into()),
1248 disc: Some(1),
1249 track_number: Some(1),
1250 genre: Some("Rock".into()),
1251 label: None,
1252 duration_ms: Some(240_000),
1253 codec: Some("FLAC".into()),
1254 sample_rate: Some(44100),
1255 bit_depth: Some(16),
1256 channels: Some(2),
1257 bitrate: Some(1000),
1258 size_bytes: Some(30_000_000),
1259 mtime: Some(1700000000),
1260 path: None,
1261 source: "local".into(),
1262 remote_id: None,
1263 remote_url: None,
1264 album_added_at: None,
1265 }
1266 }
1267
1268 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1269 TrackRow {
1270 id: 1,
1271 album_id: Some(1),
1272 artist_id: Some(1),
1273 artist_name: artist.into(),
1274 album_artist_name: artist.into(),
1275 album_title: album.into(),
1276 disc: Some(1),
1277 track_number: Some(1),
1278 title: title.into(),
1279 duration_ms: Some(240_000),
1280 path: Some("/music/test.flac".into()),
1281 codec: Some("FLAC".into()),
1282 sample_rate: Some(44100),
1283 bit_depth: Some(16),
1284 channels: Some(2),
1285 bitrate: Some(1000),
1286 genre: None,
1287 source: "local".into(),
1288 remote_id: None,
1289 cached_path: None,
1290 }
1291 }
1292
1293 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1295 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1296 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1297 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1298 meta.track_number = Some(track_number);
1299 meta.path = Some(path.to_string_lossy().into_owned());
1300 queries::upsert_track(&db.conn, &meta).unwrap()
1301 }
1302
1303 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1304 db.conn
1305 .query_row(
1306 "SELECT path FROM tracks WHERE id = ?1",
1307 params![track_id],
1308 |row| row.get(0),
1309 )
1310 .unwrap()
1311 }
1312
1313 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1314 let mut stmt = db
1315 .conn
1316 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1317 .unwrap();
1318 let rows = stmt
1319 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1320 .unwrap();
1321 rows.map(|r| r.unwrap()).collect()
1322 }
1323
1324 #[test]
1327 fn track_metadata_provider_fields() {
1328 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1329 track.track_number = Some(3);
1330 track.genre = Some("Alternative".into());
1331
1332 let album = AlbumFacts {
1333 date: Some("1997-06-16".into()),
1334 label: Some("Parlophone".into()),
1335 };
1336 let meta = TrackMetadata::from_track_row(&track, &album);
1337 assert_eq!(
1338 meta.get_field("title").as_deref(),
1339 Some("Subterranean Homesick Alien")
1340 );
1341 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1342 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1343 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1344 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1345 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1346 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1347 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1348 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1349 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1350 assert_eq!(meta.get_field("nonexistent"), None);
1351 }
1352
1353 #[test]
1356 fn both_metadata_sources_expose_the_same_fields() {
1357 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1358 track.genre = Some("Rock".into());
1359 let album = AlbumFacts {
1360 date: Some("1997-06-16".into()),
1361 label: Some("Parlophone".into()),
1362 };
1363 let from_db = TrackMetadata::from_track_row(&track, &album);
1364
1365 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1366 meta.label = Some("Parlophone".into());
1367 let from_tags = TrackMetadata::from_file_meta(&meta);
1368
1369 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1370 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1371 db_fields.sort();
1372 tag_fields.sort();
1373 assert_eq!(db_fields, tag_fields);
1374 }
1375
1376 #[test]
1377 fn sanitize_replaces_illegal_chars() {
1378 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1379 assert_eq!(sanitise_filename("What?"), "What_");
1380 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1381 assert_eq!(sanitise_filename("normal"), "normal");
1382 }
1383
1384 #[test]
1385 fn sanitize_relative_path_splits() {
1386 assert_eq!(
1387 sanitize_relative_path("Artist/Album/Track").unwrap(),
1388 PathBuf::from("Artist/Album/Track")
1389 );
1390 assert_eq!(
1391 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1392 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1393 );
1394 }
1395
1396 #[test]
1397 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1398 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1401 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1402 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1403 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1404 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1405 assert!(sanitize_relative_path(" /Airbag").is_err());
1406 }
1407
1408 #[test]
1409 fn acdc_artist_name_sanitized() {
1410 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1411 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1412 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1413 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1414 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1415 }
1416
1417 #[test]
1418 fn format_string_evaluation() {
1419 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1420 let album = AlbumFacts {
1421 date: Some("1997-06-16".into()),
1422 label: None,
1423 };
1424 let meta = TrackMetadata::from_track_row(&track, &album);
1425 let pattern =
1426 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1427 assert_eq!(
1428 format::format(pattern, &meta).unwrap(),
1429 "Radiohead/(1997) OK Computer/01. Airbag"
1430 );
1431 }
1432
1433 #[test]
1434 fn ancillary_file_detection() {
1435 let tmp = TempDir::new().unwrap();
1436 let dir = tmp.path();
1437 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1438 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1439 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1440 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1441 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1442
1443 let found = find_ancillary_files(dir);
1444 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1445 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1446 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1447 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1448 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1449 }
1450
1451 #[test]
1454 fn preview_does_not_move_files() {
1455 let db = test_db();
1456 let tmp = TempDir::new().unwrap();
1457 let source = tmp.path().join("src/test.flac");
1458 add_track(&db, &source, "Airbag", 1);
1459
1460 let result = preview(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1461 assert!(source.exists());
1462 assert_eq!(result.moves.len(), 1);
1463 }
1464
1465 #[test]
1466 fn execute_moves_files_and_undo_reverts() {
1467 let db = test_db();
1468 let tmp = TempDir::new().unwrap();
1469 let source = tmp.path().join("src/test.flac");
1470 let id = add_track(&db, &source, "Airbag", 1);
1471
1472 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1473 assert_eq!(result.moves.len(), 1);
1474 assert!(result.errors.is_empty());
1475 assert!(!source.exists());
1476 let dest = result.moves[0].to.clone();
1477 assert!(dest.exists());
1478 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1479
1480 let undone = undo(&db).unwrap();
1481 assert_eq!(undone.restored, 1);
1482 assert!(undone.errors.is_empty());
1483 assert!(source.exists());
1484 assert!(!dest.exists());
1485 assert_eq!(
1486 db_path_of(&db, id).as_deref(),
1487 Some(source.to_str().unwrap())
1488 );
1489 }
1490
1491 #[test]
1495 fn preview_and_execute_agree_on_destinations() {
1496 let db = test_db();
1497 let tmp = TempDir::new().unwrap();
1498 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1499
1500 let source = tmp.path().join("src/aphex.flac");
1501 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1502 std::fs::write(&source, b"audio").unwrap();
1503 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1504 meta.label = Some("Warp Records".into());
1505 meta.path = Some(source.to_string_lossy().into_owned());
1506 queries::upsert_track(&db.conn, &meta).unwrap();
1507
1508 let previewed = preview(&db, pattern, Some(tmp.path())).unwrap();
1509 assert_eq!(previewed.moves.len(), 1);
1510 let expected = previewed.moves[0].to.clone();
1511 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1512
1513 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1514 assert_eq!(executed.moves.len(), 1);
1515 assert_eq!(executed.moves[0].to, expected);
1516 assert!(expected.exists());
1517 }
1518
1519 #[test]
1522 fn colliding_destinations_leave_both_files_intact() {
1523 let db = test_db();
1524 let tmp = TempDir::new().unwrap();
1525 let first = tmp.path().join("src/a.flac");
1526 let second = tmp.path().join("src/b.flac");
1527 let first_id = add_track(&db, &first, "Airbag", 1);
1529 let second_id = add_track(&db, &second, "Airbag", 2);
1530 let second_bytes = std::fs::read(&second).unwrap();
1531
1532 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1533
1534 assert_eq!(result.moves.len(), 1);
1535 assert_eq!(result.errors.len(), 1);
1536 assert!(result.errors[0].1.contains("same destination"));
1537
1538 assert!(second.exists());
1540 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
1541 assert_eq!(
1542 db_path_of(&db, second_id).as_deref(),
1543 Some(second.to_str().unwrap())
1544 );
1545
1546 let dest = &result.moves[0].to;
1547 assert_eq!(
1548 std::fs::read(dest).unwrap(),
1549 b"audio bytes for Airbag".to_vec()
1550 );
1551 assert_eq!(
1552 db_path_of(&db, first_id).as_deref(),
1553 Some(dest.to_str().unwrap())
1554 );
1555 }
1556
1557 #[test]
1558 fn existing_destination_is_never_overwritten() {
1559 let db = test_db();
1560 let tmp = TempDir::new().unwrap();
1561 let source = tmp.path().join("src/new.flac");
1562 add_track(&db, &source, "Airbag", 1);
1563
1564 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1566 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1567 std::fs::write(&dest, b"the good rip").unwrap();
1568
1569 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1570 assert!(result.moves.is_empty());
1571 assert_eq!(result.errors.len(), 1);
1572 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
1573 assert!(source.exists());
1574 }
1575
1576 #[test]
1579 fn move_file_refuses_an_occupied_destination() {
1580 let tmp = TempDir::new().unwrap();
1581 let from = tmp.path().join("a.flac");
1582 let to = tmp.path().join("b.flac");
1583 std::fs::write(&from, b"source").unwrap();
1584 std::fs::write(&to, b"keep me").unwrap();
1585
1586 let err = move_file(&from, &to).unwrap_err();
1587 assert!(matches!(err, OrganizeError::DestinationExists(_)));
1588 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
1589 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
1590 }
1591
1592 #[cfg(target_os = "macos")]
1593 #[test]
1594 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
1595 let db = test_db();
1596 let tmp = TempDir::new().unwrap();
1597 let first = tmp.path().join("src/1.flac");
1598 let second = tmp.path().join("src/2.flac");
1599 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
1600 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
1601 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1602 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1603 meta.track_number = Some(number);
1604 meta.path = Some(path.to_string_lossy().into_owned());
1605 queries::upsert_track(&db.conn, &meta).unwrap();
1606 }
1607
1608 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1609 assert_eq!(result.moves.len(), 1);
1610 assert_eq!(result.errors.len(), 1);
1611 assert!(second.exists());
1612 assert_eq!(
1613 std::fs::read(&second).unwrap(),
1614 b"audio bytes for RAIN".to_vec()
1615 );
1616 }
1617
1618 #[test]
1621 fn case_only_rename_keeps_the_file() {
1622 let tmp = TempDir::new().unwrap();
1623 let from = tmp.path().join("rain.flac");
1624 let to = tmp.path().join("Rain.flac");
1625 std::fs::write(&from, b"audio bytes").unwrap();
1626
1627 move_file(&from, &to).unwrap();
1628
1629 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
1630 let names: Vec<String> = std::fs::read_dir(tmp.path())
1631 .unwrap()
1632 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1633 .collect();
1634 assert_eq!(names, vec!["Rain.flac".to_string()]);
1635 }
1636
1637 #[test]
1640 fn cross_device_copy_verifies_before_dropping_the_source() {
1641 let tmp = TempDir::new().unwrap();
1642 let from = tmp.path().join("a.flac");
1643 let to = tmp.path().join("b.flac");
1644 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
1645 std::fs::write(&from, &bytes).unwrap();
1646 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
1647
1648 copy_across_devices(&from, &to).unwrap();
1649
1650 assert!(!from.exists());
1651 assert_eq!(std::fs::read(&to).unwrap(), bytes);
1652 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
1654 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1656 .unwrap()
1657 .filter(|e| {
1658 e.as_ref()
1659 .unwrap()
1660 .file_name()
1661 .to_string_lossy()
1662 .starts_with(".koan-")
1663 })
1664 .collect();
1665 assert!(leftovers.is_empty());
1666 }
1667
1668 #[test]
1669 fn free_space_check_ignores_same_device_moves() {
1670 let tmp = TempDir::new().unwrap();
1671 let from = tmp.path().join("a.flac");
1672 std::fs::write(&from, b"audio").unwrap();
1673 let moves = vec![FileMove {
1674 track_id: None,
1675 from,
1676 to: tmp.path().join("b.flac"),
1677 ancillary: Vec::new(),
1678 }];
1679 assert!(check_free_space(&moves, tmp.path()).is_ok());
1681 }
1682
1683 #[test]
1686 fn unknown_function_refuses_the_move() {
1687 let db = test_db();
1688 let tmp = TempDir::new().unwrap();
1689 let source = tmp.path().join("src/test.flac");
1690 add_track(&db, &source, "Airbag", 1);
1691
1692 let result = execute(
1694 &db,
1695 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
1696 Some(tmp.path()),
1697 )
1698 .unwrap();
1699 assert!(result.moves.is_empty());
1700 assert_eq!(result.errors.len(), 1);
1701 assert!(result.errors[0].1.contains("unknown function"));
1702 assert!(source.exists());
1703 }
1704
1705 #[test]
1708 fn empty_final_component_refuses_the_move() {
1709 let db = test_db();
1710 let tmp = TempDir::new().unwrap();
1711 let first = tmp.path().join("src/a.flac");
1712 let second = tmp.path().join("src/b.flac");
1713 add_track(&db, &first, "Airbag", 1);
1714 add_track(&db, &second, "Karma Police", 2);
1715
1716 let result = execute(
1718 &db,
1719 "%album artist%/%album%/[%nonexistent field%]",
1720 Some(tmp.path()),
1721 )
1722 .unwrap();
1723
1724 assert!(result.moves.is_empty());
1725 assert_eq!(result.errors.len(), 2);
1726 assert!(first.exists());
1727 assert!(second.exists());
1728 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
1729 }
1730
1731 #[test]
1732 fn long_title_is_truncated_rather_than_failing() {
1733 let db = test_db();
1734 let tmp = TempDir::new().unwrap();
1735 let source = tmp.path().join("src/test.flac");
1736 let title = "a".repeat(300);
1737 add_track(&db, &source, &title, 1);
1738
1739 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1740 assert_eq!(result.moves.len(), 1, "errors: {:?}", result.errors);
1741 let name = result.moves[0].to.file_name().unwrap().to_string_lossy();
1742 assert!(name.len() <= MAX_FILE_NAME_BYTES);
1743 assert!(name.ends_with(".flac"));
1744 assert!(result.moves[0].to.exists());
1745 }
1746
1747 #[test]
1750 fn remove_empty_dirs_never_climbs_past_a_floor() {
1751 let tmp = TempDir::new().unwrap();
1752 let root = tmp.path().join("library");
1753 let nested = root.join("artist/album");
1754 std::fs::create_dir_all(&nested).unwrap();
1755
1756 remove_empty_dirs(&nested, std::slice::from_ref(&root));
1757
1758 assert!(!nested.exists());
1759 assert!(!root.join("artist").exists());
1760 assert!(root.exists(), "the library root must survive");
1761 }
1762
1763 #[test]
1764 fn remove_empty_dirs_stays_put_outside_any_floor() {
1765 let tmp = TempDir::new().unwrap();
1766 let outside = tmp.path().join("incoming/rip");
1767 std::fs::create_dir_all(&outside).unwrap();
1768
1769 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
1770
1771 assert!(!outside.exists());
1772 assert!(
1773 tmp.path().join("incoming").exists(),
1774 "no floor means no climbing"
1775 );
1776 }
1777
1778 #[test]
1779 fn remove_empty_dirs_never_removes_a_floor_itself() {
1780 let tmp = TempDir::new().unwrap();
1781 let root = tmp.path().join("library");
1782 std::fs::create_dir_all(&root).unwrap();
1783
1784 remove_empty_dirs(&root, std::slice::from_ref(&root));
1785
1786 assert!(root.exists());
1787 }
1788
1789 #[test]
1792 fn undo_refuses_when_the_original_path_is_occupied() {
1793 let db = test_db();
1794 let tmp = TempDir::new().unwrap();
1795 let source = tmp.path().join("src/test.flac");
1796 add_track(&db, &source, "Airbag", 1);
1797
1798 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1799 let dest = result.moves[0].to.clone();
1800
1801 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1803 std::fs::write(&source, b"a completely different rip").unwrap();
1804
1805 let undone = undo(&db).unwrap();
1806 assert_eq!(undone.restored, 0);
1807 assert_eq!(undone.errors.len(), 1);
1808 assert_eq!(
1809 std::fs::read(&source).unwrap(),
1810 b"a completely different rip".to_vec()
1811 );
1812 assert!(dest.exists());
1813 assert_eq!(log_rows(&db).len(), 1);
1815 }
1816
1817 #[test]
1818 fn undo_refuses_when_the_moved_file_has_been_replaced() {
1819 let db = test_db();
1820 let tmp = TempDir::new().unwrap();
1821 let source = tmp.path().join("src/test.flac");
1822 add_track(&db, &source, "Airbag", 1);
1823
1824 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1825 let dest = result.moves[0].to.clone();
1826 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
1827
1828 let undone = undo(&db).unwrap();
1829 assert_eq!(undone.restored, 0);
1830 assert_eq!(undone.errors.len(), 1);
1831 assert!(!source.exists());
1832 assert!(dest.exists());
1833 }
1834
1835 #[test]
1837 fn undo_takes_the_newest_batch_when_timestamps_tie() {
1838 let db = test_db();
1839 let tmp = TempDir::new().unwrap();
1840 let older = tmp.path().join("older.flac");
1841 let newer = tmp.path().join("newer.flac");
1842 std::fs::write(&older, b"older").unwrap();
1843 std::fs::write(&newer, b"newer").unwrap();
1844 let moved_older = tmp.path().join("moved-older.flac");
1845 let moved_newer = tmp.path().join("moved-newer.flac");
1846 std::fs::rename(&older, &moved_older).unwrap();
1847 std::fs::rename(&newer, &moved_newer).unwrap();
1848
1849 for (batch, from, to) in [
1850 ("batch-1", &older, &moved_older),
1851 ("batch-2", &newer, &moved_newer),
1852 ] {
1853 db.conn
1854 .execute(
1855 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
1856 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
1857 params![
1858 batch,
1859 from.to_string_lossy().as_ref(),
1860 to.to_string_lossy().as_ref()
1861 ],
1862 )
1863 .unwrap();
1864 }
1865
1866 let undone = undo(&db).unwrap();
1867 assert_eq!(undone.restored, 1);
1868 assert!(newer.exists(), "the newest batch is the one undone");
1869 assert!(!older.exists());
1870 }
1871
1872 #[test]
1875 fn favourites_and_queue_state_follow_the_move() {
1876 let db = test_db();
1877 let tmp = TempDir::new().unwrap();
1878 let source = tmp.path().join("src/test.flac");
1879 add_track(&db, &source, "Airbag", 1);
1880 let source_str = source.to_string_lossy().into_owned();
1881
1882 queries::add_favourite(&db.conn, &source).unwrap();
1883 let item = PersistedQueueItem {
1884 path: source_str.clone(),
1885 title: "Airbag".into(),
1886 artist: "Radiohead".into(),
1887 album_artist: "Radiohead".into(),
1888 album: "OK Computer".into(),
1889 year: None,
1890 codec: None,
1891 track_number: Some(1),
1892 disc: Some(1),
1893 duration_ms: None,
1894 db_id: None,
1895 };
1896 queries::save_snapshot(
1897 &db.conn,
1898 "mine",
1899 std::slice::from_ref(&item),
1900 Some(&source_str),
1901 0,
1902 )
1903 .unwrap();
1904 queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
1905 .unwrap();
1906
1907 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1908 let dest = result.moves[0].to.clone();
1909 let dest_str = dest.to_string_lossy().into_owned();
1910
1911 let favourites = queries::load_favourites(&db.conn).unwrap();
1912 assert!(favourites.contains(&dest));
1913 assert!(!favourites.contains(&source));
1914
1915 let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1916 assert_eq!(snapshot.items[0].path, dest_str);
1917 assert_eq!(snapshot.cursor_path.as_deref(), Some(dest_str.as_str()));
1918
1919 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
1920 assert_eq!(state.items[0].path, dest_str);
1921 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
1922
1923 assert_eq!(undo(&db).unwrap().restored, 1);
1924
1925 let favourites = queries::load_favourites(&db.conn).unwrap();
1926 assert!(favourites.contains(&source));
1927 assert!(!favourites.contains(&dest));
1928 let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1929 assert_eq!(snapshot.items[0].path, source_str);
1930 assert_eq!(snapshot.cursor_path.as_deref(), Some(source_str.as_str()));
1931 }
1932
1933 #[test]
1934 fn scan_cache_follows_the_move() {
1935 let db = test_db();
1936 let tmp = TempDir::new().unwrap();
1937 let source = tmp.path().join("src/test.flac");
1938 let id = add_track(&db, &source, "Airbag", 1);
1939 db.conn
1940 .execute(
1941 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
1942 params![source.to_string_lossy().as_ref(), id],
1943 )
1944 .unwrap();
1945
1946 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1947 let dest = result.moves[0].to.to_string_lossy().into_owned();
1948
1949 let cached: String = db
1950 .conn
1951 .query_row(
1952 "SELECT path FROM scan_cache WHERE track_id = ?1",
1953 params![id],
1954 |r| r.get(0),
1955 )
1956 .unwrap();
1957 assert_eq!(cached, dest);
1958 }
1959
1960 #[test]
1963 fn partial_failure_leaves_the_database_and_result_consistent() {
1964 let db = test_db();
1965 let tmp = TempDir::new().unwrap();
1966 let first = tmp.path().join("src/a.flac");
1967 let clash = tmp.path().join("src/b.flac");
1968 let third = tmp.path().join("src/c.flac");
1969 let first_id = add_track(&db, &first, "Airbag", 1);
1970 let clash_id = add_track(&db, &clash, "Airbag", 2);
1971 let third_id = add_track(&db, &third, "Karma Police", 3);
1972
1973 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1974
1975 assert_eq!(result.moves.len(), 2);
1976 assert_eq!(result.errors.len(), 1);
1977
1978 let logged = log_rows(&db);
1979 assert_eq!(logged.len(), 2);
1980 for file_move in &result.moves {
1981 assert!(file_move.to.exists());
1982 assert!(
1983 logged
1984 .iter()
1985 .any(|(_, _, to)| Path::new(to) == file_move.to)
1986 );
1987 }
1988
1989 assert!(clash.exists());
1991 assert_eq!(
1992 db_path_of(&db, clash_id).as_deref(),
1993 Some(clash.to_str().unwrap())
1994 );
1995 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
1996 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
1997 }
1998
1999 #[test]
2002 fn unknown_paths_are_logged_and_undoable() {
2003 let db = test_db();
2004 let tmp = TempDir::new().unwrap();
2005 let known = tmp.path().join("src/known.flac");
2006 add_track(&db, &known, "Airbag", 1);
2007
2008 let result = run(
2009 &db,
2010 Selection::Paths(std::slice::from_ref(&known)),
2011 "%album artist%/%album%/%title%",
2012 tmp.path(),
2013 )
2014 .unwrap();
2015
2016 assert_eq!(result.moves.len(), 1);
2017 let logged = log_rows(&db);
2018 assert_eq!(logged.len(), 1);
2019 assert!(logged[0].0.is_some());
2020
2021 assert_eq!(undo(&db).unwrap().restored, 1);
2022 assert!(known.exists());
2023 }
2024
2025 #[test]
2026 fn ancillary_files_move_with_the_album() {
2027 let db = test_db();
2028 let tmp = TempDir::new().unwrap();
2029 let source = tmp.path().join("src/test.flac");
2030 add_track(&db, &source, "Airbag", 1);
2031 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2032
2033 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2034 assert_eq!(result.moves.len(), 1);
2035 let dest_dir = result.moves[0].to.parent().unwrap();
2036 assert!(dest_dir.join("cover.jpg").exists());
2037
2038 assert_eq!(log_rows(&db).len(), 2);
2040 assert_eq!(undo(&db).unwrap().restored, 2);
2041 assert!(source.parent().unwrap().join("cover.jpg").exists());
2042 }
2043
2044 #[test]
2047 fn extension_not_clobbered_by_dots_in_title() {
2048 let db = test_db();
2051 let tmp = TempDir::new().unwrap();
2052 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2053 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2054 std::fs::write(&source, b"fake").unwrap();
2055
2056 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2057 meta.track_number = Some(10);
2058 meta.date = Some("2025-11-21".into());
2059 meta.path = Some(source.to_string_lossy().into_owned());
2060 queries::upsert_track(&db.conn, &meta).unwrap();
2061
2062 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2063 let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2064 assert_eq!(result.moves.len(), 1);
2065 assert_eq!(
2066 result.moves[0].to.file_name().unwrap().to_string_lossy(),
2067 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2068 );
2069 }
2070
2071 #[test]
2072 fn extension_preserved_for_tracknumber_dot() {
2073 let db = test_db();
2075 let tmp = TempDir::new().unwrap();
2076 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2077 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2078 std::fs::write(&source, b"fake").unwrap();
2079
2080 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2081 meta.track_number = Some(11);
2082 meta.date = Some("2025-11-21".into());
2083 meta.path = Some(source.to_string_lossy().into_owned());
2084 queries::upsert_track(&db.conn, &meta).unwrap();
2085
2086 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2087 let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2088 assert_eq!(result.moves.len(), 1);
2089 assert_eq!(
2090 result.moves[0].to.file_name().unwrap().to_string_lossy(),
2091 "0111. Bicep - CHROMA 012 TANGZ II.flac"
2092 );
2093 }
2094}