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 destination folder: add a library folder, or pass --base-dir")]
43 NoDestination,
44 #[error("no organize batches to undo")]
45 NothingToUndo,
46 #[error("destination already exists: {0}")]
47 DestinationExists(PathBuf),
48 #[error("copied {copied} of {expected} bytes from {path}")]
49 ShortCopy {
50 path: PathBuf,
51 expected: u64,
52 copied: u64,
53 },
54 #[error("not enough free space: {needed} bytes needed, {available} available")]
55 NotEnoughSpace { needed: u64, available: u64 },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum PlanOutcome {
66 Move,
68 Unchanged,
70 Conflict(String),
74 Error(String),
76}
77
78impl PlanOutcome {
79 pub fn reason(&self) -> Option<&str> {
81 match self {
82 Self::Conflict(reason) | Self::Error(reason) => Some(reason),
83 _ => None,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
91pub struct PlanEntry {
92 pub track_id: Option<i64>,
95 pub from: PathBuf,
96 pub to: Option<PathBuf>,
99 pub ancillary: Vec<(PathBuf, PathBuf)>,
100 pub outcome: PlanOutcome,
101}
102
103impl PlanEntry {
104 #[cfg(test)]
107 fn dest(&self) -> &Path {
108 self.to.as_deref().expect("plan entry has no destination")
109 }
110
111 fn as_move(&self) -> Option<FileMove> {
113 match (&self.outcome, &self.to) {
114 (PlanOutcome::Move, Some(to)) => Some(FileMove {
115 track_id: self.track_id,
116 from: self.from.clone(),
117 to: to.clone(),
118 ancillary: self.ancillary.clone(),
119 }),
120 _ => None,
121 }
122 }
123}
124
125#[derive(Debug, Default)]
131pub struct OrganizeResult {
132 pub entries: Vec<PlanEntry>,
133}
134
135impl OrganizeResult {
136 pub fn moves(&self) -> impl Iterator<Item = &PlanEntry> {
137 self.entries
138 .iter()
139 .filter(|e| e.outcome == PlanOutcome::Move)
140 }
141
142 pub fn moved_count(&self) -> usize {
143 self.moves().count()
144 }
145
146 pub fn unchanged_count(&self) -> usize {
148 self.entries
149 .iter()
150 .filter(|e| e.outcome == PlanOutcome::Unchanged)
151 .count()
152 }
153
154 pub fn conflicts(&self) -> impl Iterator<Item = &PlanEntry> {
155 self.entries
156 .iter()
157 .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_)))
158 }
159
160 pub fn failures(&self) -> impl Iterator<Item = &PlanEntry> {
163 self.entries
164 .iter()
165 .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_) | PlanOutcome::Error(_)))
166 }
167
168 pub fn failure_messages(&self) -> Vec<String> {
171 self.failures()
172 .map(|e| {
173 let reason = e.outcome.reason().unwrap_or("unknown");
174 match &e.to {
175 Some(to) => format!("{}: {reason} ({})", e.from.display(), to.display()),
176 None => format!("{}: {reason}", e.from.display()),
177 }
178 })
179 .collect()
180 }
181}
182
183#[derive(Debug)]
185pub struct FileMove {
186 pub track_id: Option<i64>,
187 pub from: PathBuf,
188 pub to: PathBuf,
189 pub ancillary: Vec<(PathBuf, PathBuf)>,
190}
191
192type UndoEntry = (i64, String, String, Option<i64>, Option<i64>);
195
196#[derive(Debug, Default)]
197pub struct UndoResult {
198 pub restored: usize,
199 pub errors: Vec<(PathBuf, String)>,
200}
201
202enum Selection<'a> {
204 All,
205 TrackIds(&'a [i64]),
206 Paths(&'a [PathBuf]),
207}
208
209#[derive(Default, Clone)]
211struct AlbumFacts {
212 date: Option<String>,
213 label: Option<String>,
214}
215
216struct ResolvedTrack {
218 source: PathBuf,
219 track_id: Option<i64>,
220 metadata: Result<TrackMetadata, String>,
221}
222
223struct TrackMetadata {
225 fields: HashMap<String, String>,
226}
227
228impl TrackMetadata {
229 fn from_track_row(track: &TrackRow, album: &AlbumFacts) -> Self {
230 let mut fields = HashMap::new();
231 let s = sanitise_filename;
233 fields.insert("title".into(), s(&track.title));
234 fields.insert("artist".into(), s(&track.artist_name));
235 fields.insert("album artist".into(), s(&track.album_artist_name));
236 fields.insert("album".into(), s(&track.album_title));
237 if let Some(n) = track.track_number {
238 fields.insert("tracknumber".into(), format!("{n:02}"));
239 }
240 if let Some(d) = track.disc {
241 fields.insert("discnumber".into(), d.to_string());
242 }
243 if let Some(ref date) = album.date {
244 fields.insert("date".into(), s(date));
245 }
246 if let Some(ref label) = album.label {
247 fields.insert("label".into(), s(label));
248 }
249 if let Some(ref codec) = track.codec {
250 fields.insert("codec".into(), s(codec));
251 }
252 if let Some(ref genre) = track.genre {
253 fields.insert("genre".into(), s(genre));
254 }
255 Self { fields }
256 }
257
258 fn from_file_meta(meta: &queries::TrackMeta) -> Self {
262 let mut fields = HashMap::new();
263 let s = sanitise_filename;
264 fields.insert("title".into(), s(&meta.title));
265 fields.insert("artist".into(), s(&meta.artist));
266 fields.insert(
267 "album artist".into(),
268 s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
269 );
270 fields.insert("album".into(), s(&meta.album));
271 if let Some(n) = meta.track_number {
272 fields.insert("tracknumber".into(), format!("{n:02}"));
273 }
274 if let Some(d) = meta.disc {
275 fields.insert("discnumber".into(), d.to_string());
276 }
277 if let Some(ref date) = meta.date {
278 fields.insert("date".into(), s(date));
279 }
280 if let Some(ref label) = meta.label {
281 fields.insert("label".into(), s(label));
282 }
283 if let Some(ref codec) = meta.codec {
284 fields.insert("codec".into(), s(codec));
285 }
286 if let Some(ref genre) = meta.genre {
287 fields.insert("genre".into(), s(genre));
288 }
289 Self { fields }
290 }
291}
292
293impl MetadataProvider for TrackMetadata {
294 fn get_field(&self, name: &str) -> Option<String> {
295 self.fields.get(name).cloned()
296 }
297}
298
299fn sanitize_relative_path(rel: &str) -> Result<PathBuf, String> {
305 let mut result = PathBuf::new();
306 for part in rel.split(['/', std::path::MAIN_SEPARATOR]) {
307 let sanitized = sanitise_filename(part);
308 if sanitized.is_empty() {
309 return Err(format!(
310 "format string produced an empty path component: {rel:?}"
311 ));
312 }
313 if sanitized == "." || sanitized == ".." {
314 return Err(format!(
315 "format string produced a relative path component: {rel:?}"
316 ));
317 }
318 result.push(sanitized);
319 }
320 if result.as_os_str().is_empty() {
321 return Err("format string produced an empty path".into());
322 }
323 Ok(result)
324}
325
326fn load_album_facts(conn: &Connection) -> Result<HashMap<i64, AlbumFacts>, OrganizeError> {
329 let mut stmt = conn.prepare("SELECT id, date, label FROM albums")?;
330 let rows = stmt.query_map([], |row| {
331 Ok((
332 row.get::<_, i64>(0)?,
333 AlbumFacts {
334 date: row.get(1)?,
335 label: row.get(2)?,
336 },
337 ))
338 })?;
339 let mut map = HashMap::new();
340 for row in rows {
341 let (id, facts) = row?;
342 map.insert(id, facts);
343 }
344 Ok(map)
345}
346
347fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
349 let mut files = Vec::new();
350 let Ok(entries) = std::fs::read_dir(track_dir) else {
351 return files;
352 };
353
354 for entry in entries.flatten() {
355 let path = entry.path();
356 if !path.is_file() {
357 continue;
358 }
359 let name = path
360 .file_name()
361 .and_then(|n| n.to_str())
362 .unwrap_or_default()
363 .to_lowercase();
364
365 if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
367 files.push(path);
368 continue;
369 }
370 if let Some(ext) = path.extension().and_then(|e| e.to_str())
372 && ANCILLARY_EXTENSIONS
373 .iter()
374 .any(|e| ext.eq_ignore_ascii_case(e))
375 {
376 files.push(path);
377 }
378 }
379 files.sort();
380 files
381}
382
383#[derive(Default)]
386struct DestinationLedger {
387 taken: HashSet<String>,
388}
389
390impl DestinationLedger {
391 fn key(path: &Path) -> String {
394 let key = path.to_string_lossy().into_owned();
395 if cfg!(any(
396 target_os = "macos",
397 target_os = "ios",
398 target_os = "windows"
399 )) {
400 key.to_lowercase()
401 } else {
402 key
403 }
404 }
405
406 fn claim(&mut self, path: &Path) -> bool {
408 self.taken.insert(Self::key(path))
409 }
410}
411
412fn plan_single_move(
418 source: &Path,
419 track_id: Option<i64>,
420 metadata: &TrackMetadata,
421 pattern: &str,
422 base_dir: &Path,
423 dests: &mut DestinationLedger,
424) -> PlanEntry {
425 let entry = |to: Option<PathBuf>, outcome: PlanOutcome| PlanEntry {
426 track_id,
427 from: source.to_path_buf(),
428 to,
429 ancillary: Vec::new(),
430 outcome,
431 };
432 macro_rules! bail {
434 ($reason:expr) => {
435 return entry(None, PlanOutcome::Error($reason))
436 };
437 }
438
439 let relative = match format::format(pattern, metadata) {
440 Ok(r) => r,
441 Err(e) => bail!(format!("format error: {e}")),
442 };
443
444 if relative.is_empty() {
445 bail!("format string produced empty path".to_string());
446 }
447
448 let sanitized = match sanitize_relative_path(&relative) {
449 Ok(p) => p,
450 Err(e) => bail!(e),
451 };
452
453 let ext = source
457 .extension()
458 .and_then(|e| e.to_str())
459 .unwrap_or("flac");
460 let Some(stem) = sanitized.file_name().and_then(|n| n.to_str()) else {
461 bail!("format string produced an unusable file name".to_string());
462 };
463 let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
466 if stem.is_empty() {
467 bail!("format string produced an empty file name".to_string());
468 }
469 let mut dest = base_dir.to_path_buf();
470 if let Some(parent) = sanitized.parent() {
471 dest.push(parent);
472 }
473 dest.push(format!("{stem}.{ext}"));
474
475 if !dest.starts_with(base_dir) {
477 let reason = format!(
478 "path traversal blocked: destination {} escapes base dir {}",
479 dest.display(),
480 base_dir.display()
481 );
482 return entry(Some(dest), PlanOutcome::Error(reason));
483 }
484
485 if source == dest {
486 dests.claim(&dest);
488 return entry(Some(dest), PlanOutcome::Unchanged);
489 }
490
491 if !dests.claim(&dest) {
492 return entry(
493 Some(dest),
494 PlanOutcome::Conflict("another file in this run is already going here".into()),
495 );
496 }
497
498 PlanEntry {
502 track_id,
503 from: source.to_path_buf(),
504 to: Some(dest),
505 ancillary: Vec::new(),
506 outcome: PlanOutcome::Move,
507 }
508}
509
510pub fn check_against_disk(result: &mut OrganizeResult, move_ancillary: bool) {
518 let mut dests = DestinationLedger::default();
519 let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
520 let mut ancillary_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
523
524 for entry in &mut result.entries {
525 let (PlanOutcome::Move, Some(dest)) = (&entry.outcome, entry.to.clone()) else {
526 continue;
527 };
528
529 if dest.exists() && !paths_equal(&entry.from, &dest) {
533 entry.outcome =
534 PlanOutcome::Conflict("a file is already here — it would be overwritten".into());
535 continue;
536 }
537 dests.claim(&dest);
538
539 if !move_ancillary {
540 continue;
541 }
542 let source_dir = entry.from.parent().unwrap_or(Path::new("."));
543 let dest_dir = dest.parent().unwrap_or(Path::new("."));
544 if source_dir == dest_dir {
545 continue;
546 }
547 let candidates = ancillary_by_dir
548 .entry(source_dir.to_path_buf())
549 .or_insert_with(|| find_ancillary_files(source_dir))
550 .clone();
551 for anc_path in candidates {
552 if planned_ancillary.contains(&anc_path) {
553 continue;
554 }
555 let Some(anc_name) = anc_path.file_name() else {
556 continue;
557 };
558 let anc_dest = dest_dir.join(anc_name);
559 if anc_dest.exists() || !dests.claim(&anc_dest) {
562 continue;
563 }
564 planned_ancillary.insert(anc_path.clone());
565 entry.ancillary.push((anc_path, anc_dest));
566 }
567 }
568}
569
570fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
571 let fallback = AlbumFacts::default();
572 rows.into_iter()
573 .filter_map(|track| {
574 let source = PathBuf::from(track.path.as_ref()?);
575 if !source.exists() {
576 return None; }
578 let facts = track
579 .album_id
580 .and_then(|id| albums.get(&id))
581 .unwrap_or(&fallback);
582 Some(ResolvedTrack {
583 source,
584 track_id: Some(track.id),
585 metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
586 })
587 })
588 .collect()
589}
590
591fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
592 if !source.exists() {
593 return Err("file not found".to_string());
594 }
595 crate::index::metadata::read_metadata(source)
596 .map(|m| TrackMetadata::from_file_meta(&m))
597 .map_err(|e| format!("metadata error: {e}"))
598}
599
600fn resolve_from_paths(
603 db: &Database,
604 paths: &[PathBuf],
605 albums: &HashMap<i64, AlbumFacts>,
606) -> Result<Vec<ResolvedTrack>, OrganizeError> {
607 use rayon::prelude::*;
608
609 let path_strings: Vec<String> = paths
610 .iter()
611 .map(|p| p.to_string_lossy().into_owned())
612 .collect();
613 let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
614
615 let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
617 .par_iter()
618 .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
619 .map(|p| (p.clone(), read_tag_metadata(p)))
620 .collect();
621
622 let fallback = AlbumFacts::default();
623 let mut resolved = Vec::with_capacity(paths.len());
624 for (path, path_str) in paths.iter().zip(&path_strings) {
625 let entry = match known.get(path_str) {
626 Some(track) => {
627 let facts = track
628 .album_id
629 .and_then(|id| albums.get(&id))
630 .unwrap_or(&fallback);
631 ResolvedTrack {
632 source: path.clone(),
633 track_id: Some(track.id),
634 metadata: Ok(TrackMetadata::from_track_row(track, facts)),
635 }
636 }
637 None => ResolvedTrack {
638 source: path.clone(),
639 track_id: None,
640 metadata: tagged
641 .remove(path)
642 .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
643 },
644 };
645 resolved.push(entry);
646 }
647 Ok(resolved)
648}
649
650pub struct ResolvedSelection {
657 tracks: Vec<ResolvedTrack>,
658}
659
660impl ResolvedSelection {
661 pub fn len(&self) -> usize {
664 self.tracks.len()
665 }
666
667 pub fn is_empty(&self) -> bool {
668 self.tracks.is_empty()
669 }
670}
671
672pub fn resolve(
677 db: &Database,
678 track_ids: Option<&[i64]>,
679) -> Result<ResolvedSelection, OrganizeError> {
680 let selection = match track_ids {
681 Some(ids) => Selection::TrackIds(ids),
682 None => Selection::All,
683 };
684 resolve_selection(db, selection)
685}
686
687pub fn resolve_paths(db: &Database, paths: &[PathBuf]) -> Result<ResolvedSelection, OrganizeError> {
690 resolve_selection(db, Selection::Paths(paths))
691}
692
693fn resolve_selection(
694 db: &Database,
695 selection: Selection<'_>,
696) -> Result<ResolvedSelection, OrganizeError> {
697 let albums = load_album_facts(&db.conn)?;
698 let tracks = match selection {
699 Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
700 Selection::TrackIds(ids) => {
701 let mut rows = Vec::with_capacity(ids.len());
702 for &id in ids {
703 if let Some(row) = queries::get_track_row(&db.conn, id)? {
704 rows.push(row);
705 }
706 }
707 resolve_from_rows(rows, &albums)
708 }
709 Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
710 };
711 Ok(ResolvedSelection { tracks })
712}
713
714pub fn generate(selection: &ResolvedSelection, pattern: &str, base_dir: &Path) -> OrganizeResult {
721 let mut entries = Vec::with_capacity(selection.tracks.len());
722 let mut dests = DestinationLedger::default();
723
724 for track in &selection.tracks {
725 let metadata = match &track.metadata {
726 Ok(m) => m,
727 Err(msg) => {
728 entries.push(PlanEntry {
729 track_id: track.track_id,
730 from: track.source.clone(),
731 to: None,
732 ancillary: Vec::new(),
733 outcome: PlanOutcome::Error(msg.clone()),
734 });
735 continue;
736 }
737 };
738 entries.push(plan_single_move(
739 &track.source,
740 track.track_id,
741 metadata,
742 pattern,
743 base_dir,
744 &mut dests,
745 ));
746 }
747
748 OrganizeResult { entries }
749}
750
751fn plan(
755 db: &Database,
756 selection: Selection<'_>,
757 pattern: &str,
758 base_dir: &Path,
759 check_disk: bool,
760) -> Result<OrganizeResult, OrganizeError> {
761 let resolved = resolve_selection(db, selection)?;
762 let mut result = generate(&resolved, pattern, base_dir);
763 if check_disk {
764 check_against_disk(&mut result, move_ancillary());
765 }
766 Ok(result)
767}
768
769fn run(
772 db: &Database,
773 selection: Selection<'_>,
774 pattern: &str,
775 base_dir: &Path,
776) -> Result<OrganizeResult, OrganizeError> {
777 let mut result = plan(db, selection, pattern, base_dir, true)?;
778
779 let pending: Vec<FileMove> = result
780 .entries
781 .iter()
782 .filter_map(PlanEntry::as_move)
783 .collect();
784 if pending.is_empty() {
785 return Ok(result);
786 }
787
788 check_free_space(&pending, base_dir)?;
789
790 let batch_id = batch_id();
791 let floors = cleanup_floors(Some(base_dir));
792
793 for file_move in pending {
797 let failure = match execute_single_move(db, &file_move, &batch_id, &floors) {
798 Ok(()) => verify_move(&file_move).err(),
799 Err(e) => Some(e.to_string()),
800 };
801 let Some(reason) = failure else { continue };
802 log::warn!(
803 "organize: {} → {} failed: {reason}",
804 file_move.from.display(),
805 file_move.to.display()
806 );
807 if let Some(entry) = result.entries.iter_mut().find(|e| e.from == file_move.from) {
808 entry.outcome = PlanOutcome::Error(reason);
809 }
810 }
811
812 Ok(result)
813}
814
815pub fn preview(
823 db: &Database,
824 pattern: &str,
825 base_dir: Option<&Path>,
826 check_disk: bool,
827) -> Result<OrganizeResult, OrganizeError> {
828 let base = resolve_base_dir(base_dir)?;
829 plan(db, Selection::All, pattern, &base, check_disk)
830}
831
832pub fn execute(
834 db: &Database,
835 pattern: &str,
836 base_dir: Option<&Path>,
837) -> Result<OrganizeResult, OrganizeError> {
838 let base = resolve_base_dir(base_dir)?;
839 run(db, Selection::All, pattern, &base)
840}
841
842pub fn preview_for_tracks(
844 db: &Database,
845 track_ids: &[i64],
846 pattern: &str,
847 base_dir: Option<&Path>,
848 check_disk: bool,
849) -> Result<OrganizeResult, OrganizeError> {
850 let base = resolve_base_dir(base_dir)?;
851 plan(
852 db,
853 Selection::TrackIds(track_ids),
854 pattern,
855 &base,
856 check_disk,
857 )
858}
859
860pub fn execute_for_tracks(
862 db: &Database,
863 track_ids: &[i64],
864 pattern: &str,
865 base_dir: Option<&Path>,
866) -> Result<OrganizeResult, OrganizeError> {
867 let base = resolve_base_dir(base_dir)?;
868 run(db, Selection::TrackIds(track_ids), pattern, &base)
869}
870
871pub fn preview_for_paths(
873 paths: &[PathBuf],
874 pattern: &str,
875 base_dir: Option<&Path>,
876 check_disk: bool,
877) -> Result<OrganizeResult, OrganizeError> {
878 let db = crate::db::pool::shared().get()?;
879 let base = resolve_base_dir(base_dir)?;
880 plan(&db, Selection::Paths(paths), pattern, &base, check_disk)
881}
882
883pub fn execute_for_paths(
886 paths: &[PathBuf],
887 pattern: &str,
888 base_dir: Option<&Path>,
889) -> Result<OrganizeResult, OrganizeError> {
890 let db = crate::db::pool::shared().get()?;
891 let base = resolve_base_dir(base_dir)?;
892 run(&db, Selection::Paths(paths), pattern, &base)
893}
894
895fn verify_move(file_move: &FileMove) -> Result<(), String> {
897 if !file_move.to.exists() {
898 return Err(format!(
899 "destination not found after move: {}",
900 file_move.to.display()
901 ));
902 }
903 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
904 return Err(format!(
905 "source still exists after move: {}",
906 file_move.from.display()
907 ));
908 }
909 Ok(())
910}
911
912fn log_move(
913 conn: &Connection,
914 batch_id: &str,
915 track_id: Option<i64>,
916 from: &Path,
917 to: &Path,
918 size: Option<u64>,
919 mtime: Option<i64>,
920) -> Result<(), OrganizeError> {
921 conn.execute(
922 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
923 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
924 params![
925 batch_id,
926 track_id,
927 from.to_string_lossy().as_ref(),
928 to.to_string_lossy().as_ref(),
929 size.map(|s| s as i64),
930 mtime,
931 ],
932 )?;
933 Ok(())
934}
935
936fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
942 let old_lossy = old.to_string_lossy();
943 let new_lossy = new.to_string_lossy();
944 let old_path = old_lossy.as_ref();
945 let new_path = new_lossy.as_ref();
946
947 conn.execute(
948 "UPDATE tracks SET path = ?1 WHERE path = ?2",
949 params![new_path, old_path],
950 )?;
951 conn.execute(
952 "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
953 params![new_path, old_path],
954 )?;
955 conn.execute(
956 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
957 params![new_path, old_path],
958 )?;
959 conn.execute(
962 "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
963 params![new_path, old_path],
964 )?;
965 conn.execute(
966 "UPDATE playback_state SET cursor_id = ?1 WHERE cursor_id = ?2",
967 params![new_path, old_path],
968 )?;
969 rewrite_queue_json(conn, old_path, new_path)?;
970 Ok(())
971}
972
973fn rewrite_queue_json(
978 conn: &Connection,
979 old_path: &str,
980 new_path: &str,
981) -> Result<(), OrganizeError> {
982 let mut stmt =
983 conn.prepare("SELECT id, queue_json FROM playback_state WHERE instr(queue_json, ?1) > 0")?;
984 let rows: Vec<(i64, String)> = stmt
985 .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
986 .collect::<Result<Vec<_>, _>>()?;
987 drop(stmt);
988
989 for (id, json) in rows {
990 let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
991 continue;
992 };
993 let mut changed = false;
994 for item in &mut items {
995 if item.path == old_path {
996 item.path = new_path.to_string();
997 changed = true;
998 }
999 }
1000 if !changed {
1001 continue;
1002 }
1003 let Ok(updated) = serde_json::to_string(&items) else {
1004 continue;
1005 };
1006 conn.execute(
1007 "UPDATE playback_state SET queue_json = ?1 WHERE id = ?2",
1008 params![updated, id],
1009 )?;
1010 }
1011 Ok(())
1012}
1013
1014fn execute_single_move(
1018 db: &Database,
1019 file_move: &FileMove,
1020 batch_id: &str,
1021 floors: &[PathBuf],
1022) -> Result<(), OrganizeError> {
1023 if let Some(parent) = file_move.to.parent() {
1024 std::fs::create_dir_all(parent)?;
1025 }
1026
1027 let source_meta = std::fs::metadata(&file_move.from)?;
1028 let size = source_meta.len();
1029 let mtime = mtime_secs(&source_meta);
1030
1031 let tx = db.conn.unchecked_transaction()?;
1032 log_move(
1033 &tx,
1034 batch_id,
1035 file_move.track_id,
1036 &file_move.from,
1037 &file_move.to,
1038 Some(size),
1039 mtime,
1040 )?;
1041 rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
1042
1043 move_file(&file_move.from, &file_move.to)?;
1045
1046 let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
1047 let mut failure = None;
1048 for (anc_from, anc_to) in &file_move.ancillary {
1049 if let Some(parent) = anc_to.parent()
1050 && std::fs::create_dir_all(parent).is_err()
1051 {
1052 continue;
1053 }
1054 match move_file(anc_from, anc_to) {
1056 Ok(()) => {
1057 moved_ancillary.push((anc_from, anc_to));
1058 let meta = std::fs::metadata(anc_to).ok();
1059 if let Err(e) = log_move(
1060 &tx,
1061 batch_id,
1062 None,
1063 anc_from,
1064 anc_to,
1065 meta.as_ref().map(|m| m.len()),
1066 meta.as_ref().and_then(mtime_secs),
1067 ) {
1068 failure = Some(e);
1069 break;
1070 }
1071 }
1072 Err(e) => log::warn!(
1073 "failed to move ancillary file {}: {}",
1074 anc_from.display(),
1075 e
1076 ),
1077 }
1078 }
1079
1080 let outcome = match failure {
1081 Some(e) => Err(e),
1082 None => tx.commit().map_err(OrganizeError::from),
1083 };
1084
1085 if let Err(e) = outcome {
1086 for (anc_from, anc_to) in moved_ancillary {
1089 let _ = move_file(anc_to, anc_from);
1090 }
1091 let _ = move_file(&file_move.to, &file_move.from);
1092 return Err(e);
1093 }
1094
1095 if let Some(source_dir) = file_move.from.parent() {
1096 remove_empty_dirs(source_dir, floors);
1097 }
1098
1099 Ok(())
1100}
1101
1102pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
1108 let batch_id: String = db
1111 .conn
1112 .query_row(
1113 "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
1114 [],
1115 |row| row.get(0),
1116 )
1117 .map_err(|_| OrganizeError::NothingToUndo)?;
1118
1119 let mut stmt = db.conn.prepare(
1120 "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
1121 WHERE batch_id = ?1 ORDER BY id DESC",
1122 )?;
1123
1124 let entries: Vec<UndoEntry> = stmt
1125 .query_map(params![batch_id], |row| {
1126 Ok((
1127 row.get(0)?,
1128 row.get(1)?,
1129 row.get(2)?,
1130 row.get(3)?,
1131 row.get(4)?,
1132 ))
1133 })?
1134 .collect::<Result<Vec<_>, _>>()?;
1135 drop(stmt);
1136
1137 let floors = cleanup_floors(None);
1138 let mut result = UndoResult::default();
1139
1140 for (log_id, from_path, to_path, size, mtime) in &entries {
1141 let to = Path::new(to_path);
1142 let from = Path::new(from_path);
1143
1144 if !to.exists() {
1145 db.conn
1147 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
1148 continue;
1149 }
1150
1151 if from.exists() && !paths_equal(from, to) {
1152 result.errors.push((
1153 from.to_path_buf(),
1154 format!(
1155 "another file now occupies the original path; {} left in place",
1156 to.display()
1157 ),
1158 ));
1159 continue;
1160 }
1161
1162 if let Err(msg) = matches_logged_file(to, *size, *mtime) {
1163 result.errors.push((to.to_path_buf(), msg));
1164 continue;
1165 }
1166
1167 if let Some(parent) = from.parent()
1168 && let Err(e) = std::fs::create_dir_all(parent)
1169 {
1170 result.errors.push((from.to_path_buf(), e.to_string()));
1171 continue;
1172 }
1173
1174 let tx = db.conn.unchecked_transaction()?;
1175 if let Err(e) = rewrite_path_references(&tx, to, from) {
1176 result.errors.push((to.to_path_buf(), e.to_string()));
1177 continue;
1178 }
1179 if let Err(e) = move_file(to, from) {
1180 result.errors.push((to.to_path_buf(), e.to_string()));
1181 continue;
1182 }
1183 if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
1184 let _ = move_file(from, to);
1185 result.errors.push((to.to_path_buf(), e.to_string()));
1186 continue;
1187 }
1188 if let Err(e) = tx.commit() {
1189 let _ = move_file(from, to);
1190 result.errors.push((to.to_path_buf(), e.to_string()));
1191 continue;
1192 }
1193
1194 if let Some(parent) = to.parent() {
1195 remove_empty_dirs(parent, &floors);
1196 }
1197
1198 result.restored += 1;
1199 }
1200
1201 Ok(result)
1202}
1203
1204fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
1207 let (Some(size), Some(mtime)) = (size, mtime) else {
1208 return Ok(());
1209 };
1210 let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
1211 if meta.len() != size as u64 {
1212 return Err(format!(
1213 "{} has changed since it was moved (size differs); left in place",
1214 path.display()
1215 ));
1216 }
1217 if mtime_secs(&meta).is_some_and(|current| current != mtime) {
1218 return Err(format!(
1219 "{} has changed since it was moved (modification time differs); left in place",
1220 path.display()
1221 ));
1222 }
1223 Ok(())
1224}
1225
1226fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
1227 meta.modified()
1228 .ok()?
1229 .duration_since(UNIX_EPOCH)
1230 .ok()
1231 .map(|d| d.as_secs() as i64)
1232}
1233
1234fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
1236 let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
1237 if let Ok(config) = crate::config::Config::load() {
1238 floors.extend(config.library.folders);
1239 }
1240 floors
1241}
1242
1243fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
1246 let mut current = start.to_path_buf();
1247 loop {
1248 if floors.iter().any(|floor| floor == ¤t) {
1249 break;
1250 }
1251 let empty = std::fs::read_dir(¤t)
1252 .map(|mut d| d.next().is_none())
1253 .unwrap_or(false);
1254 if !empty || std::fs::remove_dir(¤t).is_err() {
1255 break;
1256 }
1257 let Some(parent) = current.parent() else {
1258 break;
1259 };
1260 if !floors
1262 .iter()
1263 .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1264 {
1265 break;
1266 }
1267 current = parent.to_path_buf();
1268 }
1269}
1270
1271fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1273 if from == to {
1274 return Ok(());
1275 }
1276 if paths_equal(from, to) {
1277 return rename_via_temp(from, to);
1281 }
1282
1283 match std::fs::OpenOptions::new()
1286 .write(true)
1287 .create_new(true)
1288 .open(to)
1289 {
1290 Ok(_) => {}
1291 Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1292 return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1293 }
1294 Err(e) => return Err(e.into()),
1295 }
1296
1297 match transfer(from, to) {
1298 Ok(()) => Ok(()),
1299 Err(e) => {
1300 let _ = std::fs::remove_file(to);
1302 Err(e)
1303 }
1304 }
1305}
1306
1307fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1308 let temp = temp_sibling(to);
1309 std::fs::rename(from, &temp)?;
1310 match std::fs::rename(&temp, to) {
1311 Ok(()) => Ok(()),
1312 Err(e) => {
1313 let _ = std::fs::rename(&temp, from);
1314 Err(e.into())
1315 }
1316 }
1317}
1318
1319fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1321 match std::fs::rename(from, to) {
1322 Ok(()) => Ok(()),
1323 Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1325 Err(e) => Err(e.into()),
1326 }
1327}
1328
1329fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1332 let source_meta = std::fs::metadata(from)?;
1333 let expected = source_meta.len();
1334 let temp = temp_sibling(to);
1335
1336 let copied = {
1337 let mut reader = std::fs::File::open(from)?;
1338 let mut writer = std::fs::OpenOptions::new()
1339 .write(true)
1340 .create_new(true)
1341 .open(&temp)?;
1342 let copied = std::io::copy(&mut reader, &mut writer)?;
1343 writer.sync_all()?;
1345 if let Ok(modified) = source_meta.modified() {
1346 let _ = writer.set_modified(modified);
1347 }
1348 copied
1349 };
1350
1351 let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1352 if copied != expected || written != expected {
1353 let _ = std::fs::remove_file(&temp);
1354 return Err(OrganizeError::ShortCopy {
1355 path: from.to_path_buf(),
1356 expected,
1357 copied: copied.min(written),
1358 });
1359 }
1360
1361 if let Err(e) = std::fs::rename(&temp, to) {
1362 let _ = std::fs::remove_file(&temp);
1363 return Err(e.into());
1364 }
1365 std::fs::remove_file(from)?;
1366 Ok(())
1367}
1368
1369fn temp_sibling(path: &Path) -> PathBuf {
1370 let nanos = SystemTime::now()
1371 .duration_since(UNIX_EPOCH)
1372 .unwrap_or_default()
1373 .as_nanos();
1374 path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1375}
1376
1377fn paths_equal(a: &Path, b: &Path) -> bool {
1380 if a == b {
1381 return true;
1382 }
1383 #[cfg(unix)]
1384 {
1385 use std::os::unix::fs::MetadataExt;
1386 if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1387 return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1388 }
1389 }
1390 false
1391}
1392
1393fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1396 let Some(target) = existing_ancestor(base_dir) else {
1397 return Ok(());
1398 };
1399 let Some(target_device) = device_id(&target) else {
1400 return Ok(());
1401 };
1402
1403 let mut needed = 0u64;
1404 for file_move in moves {
1405 if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1406 continue;
1407 }
1408 if let Ok(meta) = std::fs::metadata(&file_move.from) {
1409 needed = needed.saturating_add(meta.len());
1410 }
1411 }
1412 if needed == 0 {
1413 return Ok(());
1414 }
1415
1416 match available_bytes(&target) {
1417 Some(available) if available < needed => {
1418 Err(OrganizeError::NotEnoughSpace { needed, available })
1419 }
1420 _ => Ok(()),
1421 }
1422}
1423
1424fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1425 path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1426}
1427
1428#[cfg(unix)]
1429fn device_id(path: &Path) -> Option<u64> {
1430 use std::os::unix::fs::MetadataExt;
1431 std::fs::metadata(path).ok().map(|m| m.dev())
1432}
1433
1434#[cfg(not(unix))]
1435fn device_id(_path: &Path) -> Option<u64> {
1436 None
1437}
1438
1439#[cfg(unix)]
1440fn available_bytes(path: &Path) -> Option<u64> {
1441 use std::os::unix::ffi::OsStrExt;
1442 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1443 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1444 if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1445 return None;
1446 }
1447 (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1449}
1450
1451#[cfg(not(unix))]
1452fn available_bytes(_path: &Path) -> Option<u64> {
1453 None
1454}
1455
1456fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1464 let dir = match base_dir {
1465 Some(dir) => dir.to_path_buf(),
1466 None => crate::config::Config::load()
1467 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?
1468 .library
1469 .folders
1470 .into_iter()
1471 .find(|folder| !folder.as_os_str().is_empty())
1472 .ok_or(OrganizeError::NoDestination)?,
1473 };
1474
1475 if dir.as_os_str().is_empty() {
1476 return Err(OrganizeError::NoDestination);
1477 }
1478 Ok(dir)
1479}
1480
1481fn move_ancillary() -> bool {
1484 crate::config::Config::load()
1485 .map(|c| c.organize.move_ancillary)
1486 .unwrap_or(true)
1487}
1488
1489fn batch_id() -> String {
1490 let now = SystemTime::now()
1491 .duration_since(UNIX_EPOCH)
1492 .unwrap_or_default();
1493 format!("batch-{}", now.as_nanos())
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498 use super::*;
1499 use crate::db::queries::TrackMeta;
1500 use crate::db::schema;
1501 use tempfile::TempDir;
1502
1503 fn test_db() -> Database {
1504 let conn = rusqlite::Connection::open_in_memory().unwrap();
1505 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1506 schema::create_tables(&conn).unwrap();
1507 Database { conn }
1508 }
1509
1510 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1511 TrackMeta {
1512 title: title.into(),
1513 artist: artist.into(),
1514 album_artist: Some(artist.into()),
1515 album: album.into(),
1516 date: Some("1997-06-16".into()),
1517 disc: Some(1),
1518 track_number: Some(1),
1519 genre: Some("Rock".into()),
1520 label: None,
1521 duration_ms: Some(240_000),
1522 codec: Some("FLAC".into()),
1523 sample_rate: Some(44100),
1524 bit_depth: Some(16),
1525 channels: Some(2),
1526 bitrate: Some(1000),
1527 size_bytes: Some(30_000_000),
1528 mtime: Some(1700000000),
1529 path: None,
1530 source: "local".into(),
1531 remote_id: None,
1532 remote_url: None,
1533 album_remote_id: None,
1534 artist_remote_id: None,
1535 mbid: None,
1536 album_mbid: None,
1537 album_added_at: None,
1538 }
1539 }
1540
1541 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1542 TrackRow {
1543 id: 1,
1544 album_id: Some(1),
1545 artist_id: Some(1),
1546 artist_name: artist.into(),
1547 album_artist_name: artist.into(),
1548 album_title: album.into(),
1549 disc: Some(1),
1550 track_number: Some(1),
1551 title: title.into(),
1552 duration_ms: Some(240_000),
1553 path: Some("/music/test.flac".into()),
1554 codec: Some("FLAC".into()),
1555 sample_rate: Some(44100),
1556 bit_depth: Some(16),
1557 channels: Some(2),
1558 bitrate: Some(1000),
1559 genre: None,
1560 source: "local".into(),
1561 remote_id: None,
1562 cached_path: None,
1563 }
1564 }
1565
1566 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1568 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1569 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1570 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1571 meta.track_number = Some(track_number);
1572 meta.path = Some(path.to_string_lossy().into_owned());
1573 queries::upsert_track(&db.conn, &meta).unwrap()
1574 }
1575
1576 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1577 db.conn
1578 .query_row(
1579 "SELECT path FROM tracks WHERE id = ?1",
1580 params![track_id],
1581 |row| row.get(0),
1582 )
1583 .unwrap()
1584 }
1585
1586 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1587 let mut stmt = db
1588 .conn
1589 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1590 .unwrap();
1591 let rows = stmt
1592 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1593 .unwrap();
1594 rows.map(|r| r.unwrap()).collect()
1595 }
1596
1597 #[test]
1600 fn track_metadata_provider_fields() {
1601 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1602 track.track_number = Some(3);
1603 track.genre = Some("Alternative".into());
1604
1605 let album = AlbumFacts {
1606 date: Some("1997-06-16".into()),
1607 label: Some("Parlophone".into()),
1608 };
1609 let meta = TrackMetadata::from_track_row(&track, &album);
1610 assert_eq!(
1611 meta.get_field("title").as_deref(),
1612 Some("Subterranean Homesick Alien")
1613 );
1614 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1615 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1616 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1617 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1618 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1619 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1620 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1621 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1622 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1623 assert_eq!(meta.get_field("nonexistent"), None);
1624 }
1625
1626 #[test]
1629 fn both_metadata_sources_expose_the_same_fields() {
1630 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1631 track.genre = Some("Rock".into());
1632 let album = AlbumFacts {
1633 date: Some("1997-06-16".into()),
1634 label: Some("Parlophone".into()),
1635 };
1636 let from_db = TrackMetadata::from_track_row(&track, &album);
1637
1638 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1639 meta.label = Some("Parlophone".into());
1640 let from_tags = TrackMetadata::from_file_meta(&meta);
1641
1642 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1643 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1644 db_fields.sort();
1645 tag_fields.sort();
1646 assert_eq!(db_fields, tag_fields);
1647 }
1648
1649 #[test]
1650 fn sanitize_replaces_illegal_chars() {
1651 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1652 assert_eq!(sanitise_filename("What?"), "What_");
1653 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1654 assert_eq!(sanitise_filename("normal"), "normal");
1655 }
1656
1657 #[test]
1658 fn sanitize_relative_path_splits() {
1659 assert_eq!(
1660 sanitize_relative_path("Artist/Album/Track").unwrap(),
1661 PathBuf::from("Artist/Album/Track")
1662 );
1663 assert_eq!(
1664 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1665 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1666 );
1667 }
1668
1669 #[test]
1670 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1671 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1674 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1675 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1676 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1677 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1678 assert!(sanitize_relative_path(" /Airbag").is_err());
1679 }
1680
1681 #[test]
1682 fn acdc_artist_name_sanitized() {
1683 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1684 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1685 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1686 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1687 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1688 }
1689
1690 #[test]
1691 fn format_string_evaluation() {
1692 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1693 let album = AlbumFacts {
1694 date: Some("1997-06-16".into()),
1695 label: None,
1696 };
1697 let meta = TrackMetadata::from_track_row(&track, &album);
1698 let pattern =
1699 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1700 assert_eq!(
1701 format::format(pattern, &meta).unwrap(),
1702 "Radiohead/(1997) OK Computer/01. Airbag"
1703 );
1704 }
1705
1706 #[test]
1707 fn ancillary_file_detection() {
1708 let tmp = TempDir::new().unwrap();
1709 let dir = tmp.path();
1710 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1711 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1712 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1713 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1714 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1715
1716 let found = find_ancillary_files(dir);
1717 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1718 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1719 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1720 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1721 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1722 }
1723
1724 #[test]
1727 fn preview_does_not_move_files() {
1728 let db = test_db();
1729 let tmp = TempDir::new().unwrap();
1730 let source = tmp.path().join("src/test.flac");
1731 add_track(&db, &source, "Airbag", 1);
1732
1733 let result = preview(
1734 &db,
1735 "%album artist%/%album%/%title%",
1736 Some(tmp.path()),
1737 true,
1738 )
1739 .unwrap();
1740 assert!(source.exists());
1741 assert_eq!(result.moved_count(), 1);
1742 }
1743
1744 #[test]
1745 fn execute_moves_files_and_undo_reverts() {
1746 let db = test_db();
1747 let tmp = TempDir::new().unwrap();
1748 let source = tmp.path().join("src/test.flac");
1749 let id = add_track(&db, &source, "Airbag", 1);
1750
1751 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1752 assert_eq!(result.moved_count(), 1);
1753 assert_eq!(result.failures().count(), 0);
1754 assert!(!source.exists());
1755 let dest = result.moves().next().unwrap().dest().to_path_buf();
1756 assert!(dest.exists());
1757 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1758
1759 let undone = undo(&db).unwrap();
1760 assert_eq!(undone.restored, 1);
1761 assert!(undone.errors.is_empty());
1762 assert!(source.exists());
1763 assert!(!dest.exists());
1764 assert_eq!(
1765 db_path_of(&db, id).as_deref(),
1766 Some(source.to_str().unwrap())
1767 );
1768 }
1769
1770 #[test]
1774 fn preview_and_execute_agree_on_destinations() {
1775 let db = test_db();
1776 let tmp = TempDir::new().unwrap();
1777 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1778
1779 let source = tmp.path().join("src/aphex.flac");
1780 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1781 std::fs::write(&source, b"audio").unwrap();
1782 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1783 meta.label = Some("Warp Records".into());
1784 meta.path = Some(source.to_string_lossy().into_owned());
1785 queries::upsert_track(&db.conn, &meta).unwrap();
1786
1787 let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1788 assert_eq!(previewed.moved_count(), 1);
1789 let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1790 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1791
1792 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1793 assert_eq!(executed.moved_count(), 1);
1794 assert_eq!(executed.moves().next().unwrap().dest(), expected);
1795 assert!(expected.exists());
1796 }
1797
1798 #[test]
1802 fn imported_files_organize_into_the_library_folder() {
1803 let db = test_db();
1804 let tmp = TempDir::new().unwrap();
1805 let outside = tmp.path().join("Downloads/rip");
1806 let library = tmp.path().join("Music");
1807 std::fs::create_dir_all(&outside).unwrap();
1808 std::fs::create_dir_all(&library).unwrap();
1809
1810 let dropped = outside.join("track.wav");
1811 crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1812
1813 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1814 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1815
1816 let result = execute_for_tracks(
1817 &db,
1818 &imported.track_ids,
1819 "%album artist%/%album%/%title%",
1820 Some(&library),
1821 )
1822 .unwrap();
1823
1824 assert_eq!(result.moved_count(), 1);
1825 let dest = result.moves().next().unwrap().dest();
1826 assert!(dest.starts_with(&library), "landed at {}", dest.display());
1827 assert!(dest.exists());
1828 assert!(
1829 !dropped.exists(),
1830 "the original should have moved, not copied"
1831 );
1832
1833 assert_eq!(
1835 db_path_of(&db, imported.track_ids[0]).as_deref(),
1836 dest.to_str()
1837 );
1838 }
1839
1840 #[test]
1845 fn generate_touches_no_files() {
1846 let db = test_db();
1847 let tmp = TempDir::new().unwrap();
1848 let source = tmp.path().join("src/track.flac");
1849 add_track(&db, &source, "Airbag", 1);
1850 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1851
1852 let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1854 std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
1855 std::fs::write(&occupied, b"the good rip").unwrap();
1856
1857 let selection = resolve(&db, None).unwrap();
1858 let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1859
1860 assert_eq!(result.moved_count(), 1);
1862 assert_eq!(result.conflicts().count(), 0);
1863 assert!(result.entries[0].ancillary.is_empty());
1864
1865 check_against_disk(&mut result, true);
1867 assert_eq!(result.moved_count(), 0);
1868 assert_eq!(result.conflicts().count(), 1);
1869 assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
1870 }
1871
1872 #[test]
1877 fn an_empty_base_dir_is_not_a_destination() {
1878 let db = test_db();
1879 add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
1880
1881 let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
1882 assert!(matches!(err, OrganizeError::NoDestination));
1883
1884 let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
1885 assert!(matches!(err, OrganizeError::NoDestination));
1886 }
1887
1888 #[test]
1891 fn generate_agrees_with_a_full_plan() {
1892 let db = test_db();
1893 let tmp = TempDir::new().unwrap();
1894 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1895 add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
1896 let pattern = "%album artist%/%album%/%tracknumber%. %title%";
1897
1898 let selection = resolve(&db, None).unwrap();
1899 let mut generated = generate(&selection, pattern, tmp.path());
1900 check_against_disk(&mut generated, true);
1901 let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1902
1903 assert_eq!(generated.entries.len(), planned.entries.len());
1904 for (a, b) in generated.entries.iter().zip(&planned.entries) {
1905 assert_eq!(a.from, b.from);
1906 assert_eq!(a.to, b.to);
1907 assert_eq!(a.outcome, b.outcome);
1908 assert_eq!(a.ancillary, b.ancillary);
1909 }
1910 }
1911
1912 #[test]
1915 fn ancillary_is_scanned_once_per_directory() {
1916 let db = test_db();
1917 let tmp = TempDir::new().unwrap();
1918 for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
1919 add_track(
1920 &db,
1921 &tmp.path().join(format!("src/{i}.flac")),
1922 title,
1923 i as i32 + 1,
1924 );
1925 }
1926 std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
1927
1928 let result = preview(
1929 &db,
1930 "%album artist%/%album%/%title%",
1931 Some(tmp.path()),
1932 true,
1933 )
1934 .unwrap();
1935
1936 let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
1938 assert_eq!(carrying.len(), 1);
1939 assert_eq!(carrying[0].ancillary.len(), 1);
1940 }
1941
1942 #[test]
1947 fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
1948 let db = test_db();
1949 let tmp = TempDir::new().unwrap();
1950 let pattern = "%album artist%/%album%/%title%";
1951 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1952
1953 let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
1954 assert_eq!(moved.moved_count(), 1);
1955
1956 let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1958 assert_eq!(again.conflicts().count(), 0);
1959 assert_eq!(again.unchanged_count(), 1);
1960 }
1961
1962 #[test]
1963 fn ancillary_files_stay_put_when_they_are_turned_off() {
1964 let db = test_db();
1965 let tmp = TempDir::new().unwrap();
1966 let source = tmp.path().join("src/a.flac");
1967 add_track(&db, &source, "Airbag", 1);
1968 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1969
1970 let selection = resolve(&db, None).unwrap();
1971 let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1972 check_against_disk(&mut off, false);
1973 assert!(off.moves().all(|e| e.ancillary.is_empty()));
1974
1975 let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1976 check_against_disk(&mut on, true);
1977 assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
1978 }
1979
1980 #[test]
1983 fn colliding_destinations_leave_both_files_intact() {
1984 let db = test_db();
1985 let tmp = TempDir::new().unwrap();
1986 let first = tmp.path().join("src/a.flac");
1987 let second = tmp.path().join("src/b.flac");
1988 let first_id = add_track(&db, &first, "Airbag", 1);
1990 let second_id = add_track(&db, &second, "Airbag", 2);
1991 let second_bytes = std::fs::read(&second).unwrap();
1992
1993 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1994
1995 assert_eq!(result.moved_count(), 1);
1996
1997 let blocked = result.conflicts().next().unwrap();
2000 assert_eq!(result.conflicts().count(), 1);
2001 assert_eq!(blocked.from, second);
2002 assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
2003
2004 assert!(second.exists());
2006 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2007 assert_eq!(
2008 db_path_of(&db, second_id).as_deref(),
2009 Some(second.to_str().unwrap())
2010 );
2011
2012 let dest = result.moves().next().unwrap().dest();
2013 assert_eq!(
2014 std::fs::read(dest).unwrap(),
2015 b"audio bytes for Airbag".to_vec()
2016 );
2017 assert_eq!(
2018 db_path_of(&db, first_id).as_deref(),
2019 Some(dest.to_str().unwrap())
2020 );
2021 }
2022
2023 #[test]
2024 fn existing_destination_is_never_overwritten() {
2025 let db = test_db();
2026 let tmp = TempDir::new().unwrap();
2027 let source = tmp.path().join("src/new.flac");
2028 add_track(&db, &source, "Airbag", 1);
2029
2030 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2032 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2033 std::fs::write(&dest, b"the good rip").unwrap();
2034
2035 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2036 assert_eq!(result.moved_count(), 0);
2037
2038 let blocked = result.conflicts().next().unwrap();
2041 assert_eq!(result.conflicts().count(), 1);
2042 assert_eq!(blocked.from, source);
2043 assert_eq!(blocked.dest(), dest);
2044 assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2045
2046 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2047 assert!(source.exists());
2048 }
2049
2050 #[test]
2053 fn move_file_refuses_an_occupied_destination() {
2054 let tmp = TempDir::new().unwrap();
2055 let from = tmp.path().join("a.flac");
2056 let to = tmp.path().join("b.flac");
2057 std::fs::write(&from, b"source").unwrap();
2058 std::fs::write(&to, b"keep me").unwrap();
2059
2060 let err = move_file(&from, &to).unwrap_err();
2061 assert!(matches!(err, OrganizeError::DestinationExists(_)));
2062 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2063 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2064 }
2065
2066 #[cfg(any(target_os = "macos", target_os = "ios"))]
2067 #[test]
2068 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2069 let db = test_db();
2070 let tmp = TempDir::new().unwrap();
2071 let first = tmp.path().join("src/1.flac");
2072 let second = tmp.path().join("src/2.flac");
2073 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2074 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2075 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2076 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2077 meta.track_number = Some(number);
2078 meta.path = Some(path.to_string_lossy().into_owned());
2079 queries::upsert_track(&db.conn, &meta).unwrap();
2080 }
2081
2082 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2083 assert_eq!(result.moved_count(), 1);
2084 assert_eq!(result.failures().count(), 1);
2085 assert!(second.exists());
2086 assert_eq!(
2087 std::fs::read(&second).unwrap(),
2088 b"audio bytes for RAIN".to_vec()
2089 );
2090 }
2091
2092 #[cfg(not(target_os = "ios"))]
2100 #[test]
2101 fn case_only_rename_keeps_the_file() {
2102 let tmp = TempDir::new().unwrap();
2103 let from = tmp.path().join("rain.flac");
2104 let to = tmp.path().join("Rain.flac");
2105 std::fs::write(&from, b"audio bytes").unwrap();
2106
2107 move_file(&from, &to).unwrap();
2108
2109 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2110 let names: Vec<String> = std::fs::read_dir(tmp.path())
2111 .unwrap()
2112 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2113 .collect();
2114 assert_eq!(names, vec!["Rain.flac".to_string()]);
2115 }
2116
2117 #[test]
2120 fn cross_device_copy_verifies_before_dropping_the_source() {
2121 let tmp = TempDir::new().unwrap();
2122 let from = tmp.path().join("a.flac");
2123 let to = tmp.path().join("b.flac");
2124 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2125 std::fs::write(&from, &bytes).unwrap();
2126 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2127
2128 copy_across_devices(&from, &to).unwrap();
2129
2130 assert!(!from.exists());
2131 assert_eq!(std::fs::read(&to).unwrap(), bytes);
2132 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2134 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2136 .unwrap()
2137 .filter(|e| {
2138 e.as_ref()
2139 .unwrap()
2140 .file_name()
2141 .to_string_lossy()
2142 .starts_with(".koan-")
2143 })
2144 .collect();
2145 assert!(leftovers.is_empty());
2146 }
2147
2148 #[test]
2149 fn free_space_check_ignores_same_device_moves() {
2150 let tmp = TempDir::new().unwrap();
2151 let from = tmp.path().join("a.flac");
2152 std::fs::write(&from, b"audio").unwrap();
2153 let moves = vec![FileMove {
2154 track_id: None,
2155 from,
2156 to: tmp.path().join("b.flac"),
2157 ancillary: Vec::new(),
2158 }];
2159 assert!(check_free_space(&moves, tmp.path()).is_ok());
2161 }
2162
2163 #[test]
2166 fn unknown_function_refuses_the_move() {
2167 let db = test_db();
2168 let tmp = TempDir::new().unwrap();
2169 let source = tmp.path().join("src/test.flac");
2170 add_track(&db, &source, "Airbag", 1);
2171
2172 let result = execute(
2174 &db,
2175 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2176 Some(tmp.path()),
2177 )
2178 .unwrap();
2179 assert_eq!(result.moved_count(), 0);
2180 assert_eq!(result.failures().count(), 1);
2181 assert!(result.failure_messages()[0].contains("unknown function"));
2182 assert!(source.exists());
2183 }
2184
2185 #[test]
2188 fn empty_final_component_refuses_the_move() {
2189 let db = test_db();
2190 let tmp = TempDir::new().unwrap();
2191 let first = tmp.path().join("src/a.flac");
2192 let second = tmp.path().join("src/b.flac");
2193 add_track(&db, &first, "Airbag", 1);
2194 add_track(&db, &second, "Karma Police", 2);
2195
2196 let result = execute(
2198 &db,
2199 "%album artist%/%album%/[%nonexistent field%]",
2200 Some(tmp.path()),
2201 )
2202 .unwrap();
2203
2204 assert_eq!(result.moved_count(), 0);
2205 assert_eq!(result.failures().count(), 2);
2206 assert!(first.exists());
2207 assert!(second.exists());
2208 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2209 }
2210
2211 #[test]
2212 fn long_title_is_truncated_rather_than_failing() {
2213 let db = test_db();
2214 let tmp = TempDir::new().unwrap();
2215 let source = tmp.path().join("src/test.flac");
2216 let title = "a".repeat(300);
2217 add_track(&db, &source, &title, 1);
2218
2219 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2220 assert_eq!(
2221 result.moved_count(),
2222 1,
2223 "errors: {:?}",
2224 result.failure_messages()
2225 );
2226 let name = result
2227 .moves()
2228 .next()
2229 .unwrap()
2230 .dest()
2231 .file_name()
2232 .unwrap()
2233 .to_string_lossy();
2234 assert!(name.len() <= MAX_FILE_NAME_BYTES);
2235 assert!(name.ends_with(".flac"));
2236 assert!(result.moves().next().unwrap().dest().exists());
2237 }
2238
2239 #[test]
2242 fn remove_empty_dirs_never_climbs_past_a_floor() {
2243 let tmp = TempDir::new().unwrap();
2244 let root = tmp.path().join("library");
2245 let nested = root.join("artist/album");
2246 std::fs::create_dir_all(&nested).unwrap();
2247
2248 remove_empty_dirs(&nested, std::slice::from_ref(&root));
2249
2250 assert!(!nested.exists());
2251 assert!(!root.join("artist").exists());
2252 assert!(root.exists(), "the library root must survive");
2253 }
2254
2255 #[test]
2256 fn remove_empty_dirs_stays_put_outside_any_floor() {
2257 let tmp = TempDir::new().unwrap();
2258 let outside = tmp.path().join("incoming/rip");
2259 std::fs::create_dir_all(&outside).unwrap();
2260
2261 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2262
2263 assert!(!outside.exists());
2264 assert!(
2265 tmp.path().join("incoming").exists(),
2266 "no floor means no climbing"
2267 );
2268 }
2269
2270 #[test]
2271 fn remove_empty_dirs_never_removes_a_floor_itself() {
2272 let tmp = TempDir::new().unwrap();
2273 let root = tmp.path().join("library");
2274 std::fs::create_dir_all(&root).unwrap();
2275
2276 remove_empty_dirs(&root, std::slice::from_ref(&root));
2277
2278 assert!(root.exists());
2279 }
2280
2281 #[test]
2284 fn undo_refuses_when_the_original_path_is_occupied() {
2285 let db = test_db();
2286 let tmp = TempDir::new().unwrap();
2287 let source = tmp.path().join("src/test.flac");
2288 add_track(&db, &source, "Airbag", 1);
2289
2290 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2291 let dest = result.moves().next().unwrap().dest().to_path_buf();
2292
2293 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2295 std::fs::write(&source, b"a completely different rip").unwrap();
2296
2297 let undone = undo(&db).unwrap();
2298 assert_eq!(undone.restored, 0);
2299 assert_eq!(undone.errors.len(), 1);
2300 assert_eq!(
2301 std::fs::read(&source).unwrap(),
2302 b"a completely different rip".to_vec()
2303 );
2304 assert!(dest.exists());
2305 assert_eq!(log_rows(&db).len(), 1);
2307 }
2308
2309 #[test]
2310 fn undo_refuses_when_the_moved_file_has_been_replaced() {
2311 let db = test_db();
2312 let tmp = TempDir::new().unwrap();
2313 let source = tmp.path().join("src/test.flac");
2314 add_track(&db, &source, "Airbag", 1);
2315
2316 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2317 let dest = result.moves().next().unwrap().dest().to_path_buf();
2318 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2319
2320 let undone = undo(&db).unwrap();
2321 assert_eq!(undone.restored, 0);
2322 assert_eq!(undone.errors.len(), 1);
2323 assert!(!source.exists());
2324 assert!(dest.exists());
2325 }
2326
2327 #[test]
2329 fn undo_takes_the_newest_batch_when_timestamps_tie() {
2330 let db = test_db();
2331 let tmp = TempDir::new().unwrap();
2332 let older = tmp.path().join("older.flac");
2333 let newer = tmp.path().join("newer.flac");
2334 std::fs::write(&older, b"older").unwrap();
2335 std::fs::write(&newer, b"newer").unwrap();
2336 let moved_older = tmp.path().join("moved-older.flac");
2337 let moved_newer = tmp.path().join("moved-newer.flac");
2338 std::fs::rename(&older, &moved_older).unwrap();
2339 std::fs::rename(&newer, &moved_newer).unwrap();
2340
2341 for (batch, from, to) in [
2342 ("batch-1", &older, &moved_older),
2343 ("batch-2", &newer, &moved_newer),
2344 ] {
2345 db.conn
2346 .execute(
2347 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2348 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2349 params![
2350 batch,
2351 from.to_string_lossy().as_ref(),
2352 to.to_string_lossy().as_ref()
2353 ],
2354 )
2355 .unwrap();
2356 }
2357
2358 let undone = undo(&db).unwrap();
2359 assert_eq!(undone.restored, 1);
2360 assert!(newer.exists(), "the newest batch is the one undone");
2361 assert!(!older.exists());
2362 }
2363
2364 #[test]
2367 fn favourites_and_queue_state_follow_the_move() {
2368 let db = test_db();
2369 let tmp = TempDir::new().unwrap();
2370 let source = tmp.path().join("src/test.flac");
2371 add_track(&db, &source, "Airbag", 1);
2372 let source_str = source.to_string_lossy().into_owned();
2373
2374 queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, &source).unwrap();
2375 let item = PersistedQueueItem {
2376 path: source_str.clone(),
2377 title: "Airbag".into(),
2378 artist: "Radiohead".into(),
2379 album_artist: "Radiohead".into(),
2380 album: "OK Computer".into(),
2381 year: None,
2382 codec: None,
2383 track_number: Some(1),
2384 disc: Some(1),
2385 duration_ms: None,
2386 db_id: None,
2387 };
2388 queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
2389 .unwrap();
2390
2391 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2392 let dest = result.moves().next().unwrap().dest().to_path_buf();
2393 let dest_str = dest.to_string_lossy().into_owned();
2394
2395 let favourites =
2396 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2397 assert!(favourites.contains(&dest));
2398 assert!(!favourites.contains(&source));
2399
2400 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2401 assert_eq!(state.items[0].path, dest_str);
2402 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2403
2404 assert_eq!(undo(&db).unwrap().restored, 1);
2405
2406 let favourites =
2407 queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2408 assert!(favourites.contains(&source));
2409 assert!(!favourites.contains(&dest));
2410 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2411 assert_eq!(state.items[0].path, source_str);
2412 assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2413 }
2414
2415 #[test]
2416 fn scan_cache_follows_the_move() {
2417 let db = test_db();
2418 let tmp = TempDir::new().unwrap();
2419 let source = tmp.path().join("src/test.flac");
2420 let id = add_track(&db, &source, "Airbag", 1);
2421 db.conn
2422 .execute(
2423 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2424 params![source.to_string_lossy().as_ref(), id],
2425 )
2426 .unwrap();
2427
2428 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2429 let dest = result
2430 .moves()
2431 .next()
2432 .unwrap()
2433 .dest()
2434 .to_string_lossy()
2435 .into_owned();
2436
2437 let cached: String = db
2438 .conn
2439 .query_row(
2440 "SELECT path FROM scan_cache WHERE track_id = ?1",
2441 params![id],
2442 |r| r.get(0),
2443 )
2444 .unwrap();
2445 assert_eq!(cached, dest);
2446 }
2447
2448 #[test]
2451 fn partial_failure_leaves_the_database_and_result_consistent() {
2452 let db = test_db();
2453 let tmp = TempDir::new().unwrap();
2454 let first = tmp.path().join("src/a.flac");
2455 let clash = tmp.path().join("src/b.flac");
2456 let third = tmp.path().join("src/c.flac");
2457 let first_id = add_track(&db, &first, "Airbag", 1);
2458 let clash_id = add_track(&db, &clash, "Airbag", 2);
2459 let third_id = add_track(&db, &third, "Karma Police", 3);
2460
2461 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2462
2463 assert_eq!(result.moved_count(), 2);
2464 assert_eq!(result.failures().count(), 1);
2465
2466 let logged = log_rows(&db);
2467 assert_eq!(logged.len(), 2);
2468 for file_move in result.moves() {
2469 assert!(file_move.dest().exists());
2470 assert!(
2471 logged
2472 .iter()
2473 .any(|(_, _, to)| Path::new(to) == file_move.dest())
2474 );
2475 }
2476
2477 assert!(clash.exists());
2479 assert_eq!(
2480 db_path_of(&db, clash_id).as_deref(),
2481 Some(clash.to_str().unwrap())
2482 );
2483 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2484 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2485 }
2486
2487 #[test]
2490 fn unknown_paths_are_logged_and_undoable() {
2491 let db = test_db();
2492 let tmp = TempDir::new().unwrap();
2493 let known = tmp.path().join("src/known.flac");
2494 add_track(&db, &known, "Airbag", 1);
2495
2496 let result = run(
2497 &db,
2498 Selection::Paths(std::slice::from_ref(&known)),
2499 "%album artist%/%album%/%title%",
2500 tmp.path(),
2501 )
2502 .unwrap();
2503
2504 assert_eq!(result.moved_count(), 1);
2505 let logged = log_rows(&db);
2506 assert_eq!(logged.len(), 1);
2507 assert!(logged[0].0.is_some());
2508
2509 assert_eq!(undo(&db).unwrap().restored, 1);
2510 assert!(known.exists());
2511 }
2512
2513 #[test]
2514 fn ancillary_files_move_with_the_album() {
2515 let db = test_db();
2516 let tmp = TempDir::new().unwrap();
2517 let source = tmp.path().join("src/test.flac");
2518 add_track(&db, &source, "Airbag", 1);
2519 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2520
2521 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2522 assert_eq!(result.moved_count(), 1);
2523 let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2524 assert!(dest_dir.join("cover.jpg").exists());
2525
2526 assert_eq!(log_rows(&db).len(), 2);
2528 assert_eq!(undo(&db).unwrap().restored, 2);
2529 assert!(source.parent().unwrap().join("cover.jpg").exists());
2530 }
2531
2532 #[test]
2535 fn extension_not_clobbered_by_dots_in_title() {
2536 let db = test_db();
2539 let tmp = TempDir::new().unwrap();
2540 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2541 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2542 std::fs::write(&source, b"fake").unwrap();
2543
2544 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2545 meta.track_number = Some(10);
2546 meta.date = Some("2025-11-21".into());
2547 meta.path = Some(source.to_string_lossy().into_owned());
2548 queries::upsert_track(&db.conn, &meta).unwrap();
2549
2550 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2551 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2552 assert_eq!(result.moved_count(), 1);
2553 assert_eq!(
2554 result
2555 .moves()
2556 .next()
2557 .unwrap()
2558 .dest()
2559 .file_name()
2560 .unwrap()
2561 .to_string_lossy(),
2562 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2563 );
2564 }
2565
2566 #[test]
2567 fn extension_preserved_for_tracknumber_dot() {
2568 let db = test_db();
2570 let tmp = TempDir::new().unwrap();
2571 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2572 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2573 std::fs::write(&source, b"fake").unwrap();
2574
2575 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2576 meta.track_number = Some(11);
2577 meta.date = Some("2025-11-21".into());
2578 meta.path = Some(source.to_string_lossy().into_owned());
2579 queries::upsert_track(&db.conn, &meta).unwrap();
2580
2581 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2582 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2583 assert_eq!(result.moved_count(), 1);
2584 assert_eq!(
2585 result
2586 .moves()
2587 .next()
2588 .unwrap()
2589 .dest()
2590 .file_name()
2591 .unwrap()
2592 .to_string_lossy(),
2593 "0111. Bicep - CHROMA 012 TANGZ II.flac"
2594 );
2595 }
2596}