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_remote_id: None,
1265 artist_remote_id: None,
1266 album_added_at: None,
1267 }
1268 }
1269
1270 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1271 TrackRow {
1272 id: 1,
1273 album_id: Some(1),
1274 artist_id: Some(1),
1275 artist_name: artist.into(),
1276 album_artist_name: artist.into(),
1277 album_title: album.into(),
1278 disc: Some(1),
1279 track_number: Some(1),
1280 title: title.into(),
1281 duration_ms: Some(240_000),
1282 path: Some("/music/test.flac".into()),
1283 codec: Some("FLAC".into()),
1284 sample_rate: Some(44100),
1285 bit_depth: Some(16),
1286 channels: Some(2),
1287 bitrate: Some(1000),
1288 genre: None,
1289 source: "local".into(),
1290 remote_id: None,
1291 cached_path: None,
1292 }
1293 }
1294
1295 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1297 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1298 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1299 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1300 meta.track_number = Some(track_number);
1301 meta.path = Some(path.to_string_lossy().into_owned());
1302 queries::upsert_track(&db.conn, &meta).unwrap()
1303 }
1304
1305 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1306 db.conn
1307 .query_row(
1308 "SELECT path FROM tracks WHERE id = ?1",
1309 params![track_id],
1310 |row| row.get(0),
1311 )
1312 .unwrap()
1313 }
1314
1315 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1316 let mut stmt = db
1317 .conn
1318 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1319 .unwrap();
1320 let rows = stmt
1321 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1322 .unwrap();
1323 rows.map(|r| r.unwrap()).collect()
1324 }
1325
1326 #[test]
1329 fn track_metadata_provider_fields() {
1330 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1331 track.track_number = Some(3);
1332 track.genre = Some("Alternative".into());
1333
1334 let album = AlbumFacts {
1335 date: Some("1997-06-16".into()),
1336 label: Some("Parlophone".into()),
1337 };
1338 let meta = TrackMetadata::from_track_row(&track, &album);
1339 assert_eq!(
1340 meta.get_field("title").as_deref(),
1341 Some("Subterranean Homesick Alien")
1342 );
1343 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1344 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1345 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1346 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1347 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1348 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1349 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1350 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1351 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1352 assert_eq!(meta.get_field("nonexistent"), None);
1353 }
1354
1355 #[test]
1358 fn both_metadata_sources_expose_the_same_fields() {
1359 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1360 track.genre = Some("Rock".into());
1361 let album = AlbumFacts {
1362 date: Some("1997-06-16".into()),
1363 label: Some("Parlophone".into()),
1364 };
1365 let from_db = TrackMetadata::from_track_row(&track, &album);
1366
1367 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1368 meta.label = Some("Parlophone".into());
1369 let from_tags = TrackMetadata::from_file_meta(&meta);
1370
1371 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1372 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1373 db_fields.sort();
1374 tag_fields.sort();
1375 assert_eq!(db_fields, tag_fields);
1376 }
1377
1378 #[test]
1379 fn sanitize_replaces_illegal_chars() {
1380 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1381 assert_eq!(sanitise_filename("What?"), "What_");
1382 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1383 assert_eq!(sanitise_filename("normal"), "normal");
1384 }
1385
1386 #[test]
1387 fn sanitize_relative_path_splits() {
1388 assert_eq!(
1389 sanitize_relative_path("Artist/Album/Track").unwrap(),
1390 PathBuf::from("Artist/Album/Track")
1391 );
1392 assert_eq!(
1393 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1394 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1395 );
1396 }
1397
1398 #[test]
1399 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1400 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1403 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1404 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1405 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1406 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1407 assert!(sanitize_relative_path(" /Airbag").is_err());
1408 }
1409
1410 #[test]
1411 fn acdc_artist_name_sanitized() {
1412 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1413 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1414 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1415 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1416 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1417 }
1418
1419 #[test]
1420 fn format_string_evaluation() {
1421 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1422 let album = AlbumFacts {
1423 date: Some("1997-06-16".into()),
1424 label: None,
1425 };
1426 let meta = TrackMetadata::from_track_row(&track, &album);
1427 let pattern =
1428 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1429 assert_eq!(
1430 format::format(pattern, &meta).unwrap(),
1431 "Radiohead/(1997) OK Computer/01. Airbag"
1432 );
1433 }
1434
1435 #[test]
1436 fn ancillary_file_detection() {
1437 let tmp = TempDir::new().unwrap();
1438 let dir = tmp.path();
1439 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1440 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1441 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1442 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1443 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1444
1445 let found = find_ancillary_files(dir);
1446 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1447 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1448 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1449 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1450 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1451 }
1452
1453 #[test]
1456 fn preview_does_not_move_files() {
1457 let db = test_db();
1458 let tmp = TempDir::new().unwrap();
1459 let source = tmp.path().join("src/test.flac");
1460 add_track(&db, &source, "Airbag", 1);
1461
1462 let result = preview(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1463 assert!(source.exists());
1464 assert_eq!(result.moves.len(), 1);
1465 }
1466
1467 #[test]
1468 fn execute_moves_files_and_undo_reverts() {
1469 let db = test_db();
1470 let tmp = TempDir::new().unwrap();
1471 let source = tmp.path().join("src/test.flac");
1472 let id = add_track(&db, &source, "Airbag", 1);
1473
1474 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1475 assert_eq!(result.moves.len(), 1);
1476 assert!(result.errors.is_empty());
1477 assert!(!source.exists());
1478 let dest = result.moves[0].to.clone();
1479 assert!(dest.exists());
1480 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1481
1482 let undone = undo(&db).unwrap();
1483 assert_eq!(undone.restored, 1);
1484 assert!(undone.errors.is_empty());
1485 assert!(source.exists());
1486 assert!(!dest.exists());
1487 assert_eq!(
1488 db_path_of(&db, id).as_deref(),
1489 Some(source.to_str().unwrap())
1490 );
1491 }
1492
1493 #[test]
1497 fn preview_and_execute_agree_on_destinations() {
1498 let db = test_db();
1499 let tmp = TempDir::new().unwrap();
1500 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1501
1502 let source = tmp.path().join("src/aphex.flac");
1503 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1504 std::fs::write(&source, b"audio").unwrap();
1505 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1506 meta.label = Some("Warp Records".into());
1507 meta.path = Some(source.to_string_lossy().into_owned());
1508 queries::upsert_track(&db.conn, &meta).unwrap();
1509
1510 let previewed = preview(&db, pattern, Some(tmp.path())).unwrap();
1511 assert_eq!(previewed.moves.len(), 1);
1512 let expected = previewed.moves[0].to.clone();
1513 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1514
1515 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1516 assert_eq!(executed.moves.len(), 1);
1517 assert_eq!(executed.moves[0].to, expected);
1518 assert!(expected.exists());
1519 }
1520
1521 #[test]
1524 fn colliding_destinations_leave_both_files_intact() {
1525 let db = test_db();
1526 let tmp = TempDir::new().unwrap();
1527 let first = tmp.path().join("src/a.flac");
1528 let second = tmp.path().join("src/b.flac");
1529 let first_id = add_track(&db, &first, "Airbag", 1);
1531 let second_id = add_track(&db, &second, "Airbag", 2);
1532 let second_bytes = std::fs::read(&second).unwrap();
1533
1534 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1535
1536 assert_eq!(result.moves.len(), 1);
1537 assert_eq!(result.errors.len(), 1);
1538 assert!(result.errors[0].1.contains("same destination"));
1539
1540 assert!(second.exists());
1542 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
1543 assert_eq!(
1544 db_path_of(&db, second_id).as_deref(),
1545 Some(second.to_str().unwrap())
1546 );
1547
1548 let dest = &result.moves[0].to;
1549 assert_eq!(
1550 std::fs::read(dest).unwrap(),
1551 b"audio bytes for Airbag".to_vec()
1552 );
1553 assert_eq!(
1554 db_path_of(&db, first_id).as_deref(),
1555 Some(dest.to_str().unwrap())
1556 );
1557 }
1558
1559 #[test]
1560 fn existing_destination_is_never_overwritten() {
1561 let db = test_db();
1562 let tmp = TempDir::new().unwrap();
1563 let source = tmp.path().join("src/new.flac");
1564 add_track(&db, &source, "Airbag", 1);
1565
1566 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1568 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1569 std::fs::write(&dest, b"the good rip").unwrap();
1570
1571 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1572 assert!(result.moves.is_empty());
1573 assert_eq!(result.errors.len(), 1);
1574 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
1575 assert!(source.exists());
1576 }
1577
1578 #[test]
1581 fn move_file_refuses_an_occupied_destination() {
1582 let tmp = TempDir::new().unwrap();
1583 let from = tmp.path().join("a.flac");
1584 let to = tmp.path().join("b.flac");
1585 std::fs::write(&from, b"source").unwrap();
1586 std::fs::write(&to, b"keep me").unwrap();
1587
1588 let err = move_file(&from, &to).unwrap_err();
1589 assert!(matches!(err, OrganizeError::DestinationExists(_)));
1590 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
1591 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
1592 }
1593
1594 #[cfg(target_os = "macos")]
1595 #[test]
1596 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
1597 let db = test_db();
1598 let tmp = TempDir::new().unwrap();
1599 let first = tmp.path().join("src/1.flac");
1600 let second = tmp.path().join("src/2.flac");
1601 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
1602 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
1603 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1604 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1605 meta.track_number = Some(number);
1606 meta.path = Some(path.to_string_lossy().into_owned());
1607 queries::upsert_track(&db.conn, &meta).unwrap();
1608 }
1609
1610 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1611 assert_eq!(result.moves.len(), 1);
1612 assert_eq!(result.errors.len(), 1);
1613 assert!(second.exists());
1614 assert_eq!(
1615 std::fs::read(&second).unwrap(),
1616 b"audio bytes for RAIN".to_vec()
1617 );
1618 }
1619
1620 #[test]
1623 fn case_only_rename_keeps_the_file() {
1624 let tmp = TempDir::new().unwrap();
1625 let from = tmp.path().join("rain.flac");
1626 let to = tmp.path().join("Rain.flac");
1627 std::fs::write(&from, b"audio bytes").unwrap();
1628
1629 move_file(&from, &to).unwrap();
1630
1631 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
1632 let names: Vec<String> = std::fs::read_dir(tmp.path())
1633 .unwrap()
1634 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1635 .collect();
1636 assert_eq!(names, vec!["Rain.flac".to_string()]);
1637 }
1638
1639 #[test]
1642 fn cross_device_copy_verifies_before_dropping_the_source() {
1643 let tmp = TempDir::new().unwrap();
1644 let from = tmp.path().join("a.flac");
1645 let to = tmp.path().join("b.flac");
1646 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
1647 std::fs::write(&from, &bytes).unwrap();
1648 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
1649
1650 copy_across_devices(&from, &to).unwrap();
1651
1652 assert!(!from.exists());
1653 assert_eq!(std::fs::read(&to).unwrap(), bytes);
1654 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
1656 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1658 .unwrap()
1659 .filter(|e| {
1660 e.as_ref()
1661 .unwrap()
1662 .file_name()
1663 .to_string_lossy()
1664 .starts_with(".koan-")
1665 })
1666 .collect();
1667 assert!(leftovers.is_empty());
1668 }
1669
1670 #[test]
1671 fn free_space_check_ignores_same_device_moves() {
1672 let tmp = TempDir::new().unwrap();
1673 let from = tmp.path().join("a.flac");
1674 std::fs::write(&from, b"audio").unwrap();
1675 let moves = vec![FileMove {
1676 track_id: None,
1677 from,
1678 to: tmp.path().join("b.flac"),
1679 ancillary: Vec::new(),
1680 }];
1681 assert!(check_free_space(&moves, tmp.path()).is_ok());
1683 }
1684
1685 #[test]
1688 fn unknown_function_refuses_the_move() {
1689 let db = test_db();
1690 let tmp = TempDir::new().unwrap();
1691 let source = tmp.path().join("src/test.flac");
1692 add_track(&db, &source, "Airbag", 1);
1693
1694 let result = execute(
1696 &db,
1697 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
1698 Some(tmp.path()),
1699 )
1700 .unwrap();
1701 assert!(result.moves.is_empty());
1702 assert_eq!(result.errors.len(), 1);
1703 assert!(result.errors[0].1.contains("unknown function"));
1704 assert!(source.exists());
1705 }
1706
1707 #[test]
1710 fn empty_final_component_refuses_the_move() {
1711 let db = test_db();
1712 let tmp = TempDir::new().unwrap();
1713 let first = tmp.path().join("src/a.flac");
1714 let second = tmp.path().join("src/b.flac");
1715 add_track(&db, &first, "Airbag", 1);
1716 add_track(&db, &second, "Karma Police", 2);
1717
1718 let result = execute(
1720 &db,
1721 "%album artist%/%album%/[%nonexistent field%]",
1722 Some(tmp.path()),
1723 )
1724 .unwrap();
1725
1726 assert!(result.moves.is_empty());
1727 assert_eq!(result.errors.len(), 2);
1728 assert!(first.exists());
1729 assert!(second.exists());
1730 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
1731 }
1732
1733 #[test]
1734 fn long_title_is_truncated_rather_than_failing() {
1735 let db = test_db();
1736 let tmp = TempDir::new().unwrap();
1737 let source = tmp.path().join("src/test.flac");
1738 let title = "a".repeat(300);
1739 add_track(&db, &source, &title, 1);
1740
1741 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1742 assert_eq!(result.moves.len(), 1, "errors: {:?}", result.errors);
1743 let name = result.moves[0].to.file_name().unwrap().to_string_lossy();
1744 assert!(name.len() <= MAX_FILE_NAME_BYTES);
1745 assert!(name.ends_with(".flac"));
1746 assert!(result.moves[0].to.exists());
1747 }
1748
1749 #[test]
1752 fn remove_empty_dirs_never_climbs_past_a_floor() {
1753 let tmp = TempDir::new().unwrap();
1754 let root = tmp.path().join("library");
1755 let nested = root.join("artist/album");
1756 std::fs::create_dir_all(&nested).unwrap();
1757
1758 remove_empty_dirs(&nested, std::slice::from_ref(&root));
1759
1760 assert!(!nested.exists());
1761 assert!(!root.join("artist").exists());
1762 assert!(root.exists(), "the library root must survive");
1763 }
1764
1765 #[test]
1766 fn remove_empty_dirs_stays_put_outside_any_floor() {
1767 let tmp = TempDir::new().unwrap();
1768 let outside = tmp.path().join("incoming/rip");
1769 std::fs::create_dir_all(&outside).unwrap();
1770
1771 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
1772
1773 assert!(!outside.exists());
1774 assert!(
1775 tmp.path().join("incoming").exists(),
1776 "no floor means no climbing"
1777 );
1778 }
1779
1780 #[test]
1781 fn remove_empty_dirs_never_removes_a_floor_itself() {
1782 let tmp = TempDir::new().unwrap();
1783 let root = tmp.path().join("library");
1784 std::fs::create_dir_all(&root).unwrap();
1785
1786 remove_empty_dirs(&root, std::slice::from_ref(&root));
1787
1788 assert!(root.exists());
1789 }
1790
1791 #[test]
1794 fn undo_refuses_when_the_original_path_is_occupied() {
1795 let db = test_db();
1796 let tmp = TempDir::new().unwrap();
1797 let source = tmp.path().join("src/test.flac");
1798 add_track(&db, &source, "Airbag", 1);
1799
1800 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1801 let dest = result.moves[0].to.clone();
1802
1803 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1805 std::fs::write(&source, b"a completely different rip").unwrap();
1806
1807 let undone = undo(&db).unwrap();
1808 assert_eq!(undone.restored, 0);
1809 assert_eq!(undone.errors.len(), 1);
1810 assert_eq!(
1811 std::fs::read(&source).unwrap(),
1812 b"a completely different rip".to_vec()
1813 );
1814 assert!(dest.exists());
1815 assert_eq!(log_rows(&db).len(), 1);
1817 }
1818
1819 #[test]
1820 fn undo_refuses_when_the_moved_file_has_been_replaced() {
1821 let db = test_db();
1822 let tmp = TempDir::new().unwrap();
1823 let source = tmp.path().join("src/test.flac");
1824 add_track(&db, &source, "Airbag", 1);
1825
1826 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1827 let dest = result.moves[0].to.clone();
1828 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
1829
1830 let undone = undo(&db).unwrap();
1831 assert_eq!(undone.restored, 0);
1832 assert_eq!(undone.errors.len(), 1);
1833 assert!(!source.exists());
1834 assert!(dest.exists());
1835 }
1836
1837 #[test]
1839 fn undo_takes_the_newest_batch_when_timestamps_tie() {
1840 let db = test_db();
1841 let tmp = TempDir::new().unwrap();
1842 let older = tmp.path().join("older.flac");
1843 let newer = tmp.path().join("newer.flac");
1844 std::fs::write(&older, b"older").unwrap();
1845 std::fs::write(&newer, b"newer").unwrap();
1846 let moved_older = tmp.path().join("moved-older.flac");
1847 let moved_newer = tmp.path().join("moved-newer.flac");
1848 std::fs::rename(&older, &moved_older).unwrap();
1849 std::fs::rename(&newer, &moved_newer).unwrap();
1850
1851 for (batch, from, to) in [
1852 ("batch-1", &older, &moved_older),
1853 ("batch-2", &newer, &moved_newer),
1854 ] {
1855 db.conn
1856 .execute(
1857 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
1858 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
1859 params![
1860 batch,
1861 from.to_string_lossy().as_ref(),
1862 to.to_string_lossy().as_ref()
1863 ],
1864 )
1865 .unwrap();
1866 }
1867
1868 let undone = undo(&db).unwrap();
1869 assert_eq!(undone.restored, 1);
1870 assert!(newer.exists(), "the newest batch is the one undone");
1871 assert!(!older.exists());
1872 }
1873
1874 #[test]
1877 fn favourites_and_queue_state_follow_the_move() {
1878 let db = test_db();
1879 let tmp = TempDir::new().unwrap();
1880 let source = tmp.path().join("src/test.flac");
1881 add_track(&db, &source, "Airbag", 1);
1882 let source_str = source.to_string_lossy().into_owned();
1883
1884 queries::add_favourite(&db.conn, &source).unwrap();
1885 let item = PersistedQueueItem {
1886 path: source_str.clone(),
1887 title: "Airbag".into(),
1888 artist: "Radiohead".into(),
1889 album_artist: "Radiohead".into(),
1890 album: "OK Computer".into(),
1891 year: None,
1892 codec: None,
1893 track_number: Some(1),
1894 disc: Some(1),
1895 duration_ms: None,
1896 db_id: None,
1897 };
1898 queries::save_snapshot(
1899 &db.conn,
1900 "mine",
1901 std::slice::from_ref(&item),
1902 Some(&source_str),
1903 0,
1904 )
1905 .unwrap();
1906 queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
1907 .unwrap();
1908
1909 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1910 let dest = result.moves[0].to.clone();
1911 let dest_str = dest.to_string_lossy().into_owned();
1912
1913 let favourites = queries::load_favourites(&db.conn).unwrap();
1914 assert!(favourites.contains(&dest));
1915 assert!(!favourites.contains(&source));
1916
1917 let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1918 assert_eq!(snapshot.items[0].path, dest_str);
1919 assert_eq!(snapshot.cursor_path.as_deref(), Some(dest_str.as_str()));
1920
1921 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
1922 assert_eq!(state.items[0].path, dest_str);
1923 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
1924
1925 assert_eq!(undo(&db).unwrap().restored, 1);
1926
1927 let favourites = queries::load_favourites(&db.conn).unwrap();
1928 assert!(favourites.contains(&source));
1929 assert!(!favourites.contains(&dest));
1930 let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1931 assert_eq!(snapshot.items[0].path, source_str);
1932 assert_eq!(snapshot.cursor_path.as_deref(), Some(source_str.as_str()));
1933 }
1934
1935 #[test]
1936 fn scan_cache_follows_the_move() {
1937 let db = test_db();
1938 let tmp = TempDir::new().unwrap();
1939 let source = tmp.path().join("src/test.flac");
1940 let id = add_track(&db, &source, "Airbag", 1);
1941 db.conn
1942 .execute(
1943 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
1944 params![source.to_string_lossy().as_ref(), id],
1945 )
1946 .unwrap();
1947
1948 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1949 let dest = result.moves[0].to.to_string_lossy().into_owned();
1950
1951 let cached: String = db
1952 .conn
1953 .query_row(
1954 "SELECT path FROM scan_cache WHERE track_id = ?1",
1955 params![id],
1956 |r| r.get(0),
1957 )
1958 .unwrap();
1959 assert_eq!(cached, dest);
1960 }
1961
1962 #[test]
1965 fn partial_failure_leaves_the_database_and_result_consistent() {
1966 let db = test_db();
1967 let tmp = TempDir::new().unwrap();
1968 let first = tmp.path().join("src/a.flac");
1969 let clash = tmp.path().join("src/b.flac");
1970 let third = tmp.path().join("src/c.flac");
1971 let first_id = add_track(&db, &first, "Airbag", 1);
1972 let clash_id = add_track(&db, &clash, "Airbag", 2);
1973 let third_id = add_track(&db, &third, "Karma Police", 3);
1974
1975 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1976
1977 assert_eq!(result.moves.len(), 2);
1978 assert_eq!(result.errors.len(), 1);
1979
1980 let logged = log_rows(&db);
1981 assert_eq!(logged.len(), 2);
1982 for file_move in &result.moves {
1983 assert!(file_move.to.exists());
1984 assert!(
1985 logged
1986 .iter()
1987 .any(|(_, _, to)| Path::new(to) == file_move.to)
1988 );
1989 }
1990
1991 assert!(clash.exists());
1993 assert_eq!(
1994 db_path_of(&db, clash_id).as_deref(),
1995 Some(clash.to_str().unwrap())
1996 );
1997 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
1998 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
1999 }
2000
2001 #[test]
2004 fn unknown_paths_are_logged_and_undoable() {
2005 let db = test_db();
2006 let tmp = TempDir::new().unwrap();
2007 let known = tmp.path().join("src/known.flac");
2008 add_track(&db, &known, "Airbag", 1);
2009
2010 let result = run(
2011 &db,
2012 Selection::Paths(std::slice::from_ref(&known)),
2013 "%album artist%/%album%/%title%",
2014 tmp.path(),
2015 )
2016 .unwrap();
2017
2018 assert_eq!(result.moves.len(), 1);
2019 let logged = log_rows(&db);
2020 assert_eq!(logged.len(), 1);
2021 assert!(logged[0].0.is_some());
2022
2023 assert_eq!(undo(&db).unwrap().restored, 1);
2024 assert!(known.exists());
2025 }
2026
2027 #[test]
2028 fn ancillary_files_move_with_the_album() {
2029 let db = test_db();
2030 let tmp = TempDir::new().unwrap();
2031 let source = tmp.path().join("src/test.flac");
2032 add_track(&db, &source, "Airbag", 1);
2033 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2034
2035 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2036 assert_eq!(result.moves.len(), 1);
2037 let dest_dir = result.moves[0].to.parent().unwrap();
2038 assert!(dest_dir.join("cover.jpg").exists());
2039
2040 assert_eq!(log_rows(&db).len(), 2);
2042 assert_eq!(undo(&db).unwrap().restored, 2);
2043 assert!(source.parent().unwrap().join("cover.jpg").exists());
2044 }
2045
2046 #[test]
2049 fn extension_not_clobbered_by_dots_in_title() {
2050 let db = test_db();
2053 let tmp = TempDir::new().unwrap();
2054 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2055 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2056 std::fs::write(&source, b"fake").unwrap();
2057
2058 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2059 meta.track_number = Some(10);
2060 meta.date = Some("2025-11-21".into());
2061 meta.path = Some(source.to_string_lossy().into_owned());
2062 queries::upsert_track(&db.conn, &meta).unwrap();
2063
2064 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2065 let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2066 assert_eq!(result.moves.len(), 1);
2067 assert_eq!(
2068 result.moves[0].to.file_name().unwrap().to_string_lossy(),
2069 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2070 );
2071 }
2072
2073 #[test]
2074 fn extension_preserved_for_tracknumber_dot() {
2075 let db = test_db();
2077 let tmp = TempDir::new().unwrap();
2078 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2079 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2080 std::fs::write(&source, b"fake").unwrap();
2081
2082 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2083 meta.track_number = Some(11);
2084 meta.date = Some("2025-11-21".into());
2085 meta.path = Some(source.to_string_lossy().into_owned());
2086 queries::upsert_track(&db.conn, &meta).unwrap();
2087
2088 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2089 let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2090 assert_eq!(result.moves.len(), 1);
2091 assert_eq!(
2092 result.moves[0].to.file_name().unwrap().to_string_lossy(),
2093 "0111. Bicep - CHROMA 012 TANGZ II.flac"
2094 );
2095 }
2096}