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(target_os = "macos", target_os = "windows")) {
396 key.to_lowercase()
397 } else {
398 key
399 }
400 }
401
402 fn claim(&mut self, path: &Path) -> bool {
404 self.taken.insert(Self::key(path))
405 }
406}
407
408fn plan_single_move(
414 source: &Path,
415 track_id: Option<i64>,
416 metadata: &TrackMetadata,
417 pattern: &str,
418 base_dir: &Path,
419 dests: &mut DestinationLedger,
420) -> PlanEntry {
421 let entry = |to: Option<PathBuf>, outcome: PlanOutcome| PlanEntry {
422 track_id,
423 from: source.to_path_buf(),
424 to,
425 ancillary: Vec::new(),
426 outcome,
427 };
428 macro_rules! bail {
430 ($reason:expr) => {
431 return entry(None, PlanOutcome::Error($reason))
432 };
433 }
434
435 let relative = match format::format(pattern, metadata) {
436 Ok(r) => r,
437 Err(e) => bail!(format!("format error: {e}")),
438 };
439
440 if relative.is_empty() {
441 bail!("format string produced empty path".to_string());
442 }
443
444 let sanitized = match sanitize_relative_path(&relative) {
445 Ok(p) => p,
446 Err(e) => bail!(e),
447 };
448
449 let ext = source
453 .extension()
454 .and_then(|e| e.to_str())
455 .unwrap_or("flac");
456 let Some(stem) = sanitized.file_name().and_then(|n| n.to_str()) else {
457 bail!("format string produced an unusable file name".to_string());
458 };
459 let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
462 if stem.is_empty() {
463 bail!("format string produced an empty file name".to_string());
464 }
465 let mut dest = base_dir.to_path_buf();
466 if let Some(parent) = sanitized.parent() {
467 dest.push(parent);
468 }
469 dest.push(format!("{stem}.{ext}"));
470
471 if !dest.starts_with(base_dir) {
473 let reason = format!(
474 "path traversal blocked: destination {} escapes base dir {}",
475 dest.display(),
476 base_dir.display()
477 );
478 return entry(Some(dest), PlanOutcome::Error(reason));
479 }
480
481 if source == dest {
482 dests.claim(&dest);
484 return entry(Some(dest), PlanOutcome::Unchanged);
485 }
486
487 if !dests.claim(&dest) {
488 return entry(
489 Some(dest),
490 PlanOutcome::Conflict("another file in this run is already going here".into()),
491 );
492 }
493
494 PlanEntry {
498 track_id,
499 from: source.to_path_buf(),
500 to: Some(dest),
501 ancillary: Vec::new(),
502 outcome: PlanOutcome::Move,
503 }
504}
505
506pub fn check_against_disk(result: &mut OrganizeResult, move_ancillary: bool) {
514 let mut dests = DestinationLedger::default();
515 let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
516 let mut ancillary_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
519
520 for entry in &mut result.entries {
521 let (PlanOutcome::Move, Some(dest)) = (&entry.outcome, entry.to.clone()) else {
522 continue;
523 };
524
525 if dest.exists() && !paths_equal(&entry.from, &dest) {
529 entry.outcome =
530 PlanOutcome::Conflict("a file is already here — it would be overwritten".into());
531 continue;
532 }
533 dests.claim(&dest);
534
535 if !move_ancillary {
536 continue;
537 }
538 let source_dir = entry.from.parent().unwrap_or(Path::new("."));
539 let dest_dir = dest.parent().unwrap_or(Path::new("."));
540 if source_dir == dest_dir {
541 continue;
542 }
543 let candidates = ancillary_by_dir
544 .entry(source_dir.to_path_buf())
545 .or_insert_with(|| find_ancillary_files(source_dir))
546 .clone();
547 for anc_path in candidates {
548 if planned_ancillary.contains(&anc_path) {
549 continue;
550 }
551 let Some(anc_name) = anc_path.file_name() else {
552 continue;
553 };
554 let anc_dest = dest_dir.join(anc_name);
555 if anc_dest.exists() || !dests.claim(&anc_dest) {
558 continue;
559 }
560 planned_ancillary.insert(anc_path.clone());
561 entry.ancillary.push((anc_path, anc_dest));
562 }
563 }
564}
565
566fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
567 let fallback = AlbumFacts::default();
568 rows.into_iter()
569 .filter_map(|track| {
570 let source = PathBuf::from(track.path.as_ref()?);
571 if !source.exists() {
572 return None; }
574 let facts = track
575 .album_id
576 .and_then(|id| albums.get(&id))
577 .unwrap_or(&fallback);
578 Some(ResolvedTrack {
579 source,
580 track_id: Some(track.id),
581 metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
582 })
583 })
584 .collect()
585}
586
587fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
588 if !source.exists() {
589 return Err("file not found".to_string());
590 }
591 crate::index::metadata::read_metadata(source)
592 .map(|m| TrackMetadata::from_file_meta(&m))
593 .map_err(|e| format!("metadata error: {e}"))
594}
595
596fn resolve_from_paths(
599 db: &Database,
600 paths: &[PathBuf],
601 albums: &HashMap<i64, AlbumFacts>,
602) -> Result<Vec<ResolvedTrack>, OrganizeError> {
603 use rayon::prelude::*;
604
605 let path_strings: Vec<String> = paths
606 .iter()
607 .map(|p| p.to_string_lossy().into_owned())
608 .collect();
609 let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
610
611 let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
613 .par_iter()
614 .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
615 .map(|p| (p.clone(), read_tag_metadata(p)))
616 .collect();
617
618 let fallback = AlbumFacts::default();
619 let mut resolved = Vec::with_capacity(paths.len());
620 for (path, path_str) in paths.iter().zip(&path_strings) {
621 let entry = match known.get(path_str) {
622 Some(track) => {
623 let facts = track
624 .album_id
625 .and_then(|id| albums.get(&id))
626 .unwrap_or(&fallback);
627 ResolvedTrack {
628 source: path.clone(),
629 track_id: Some(track.id),
630 metadata: Ok(TrackMetadata::from_track_row(track, facts)),
631 }
632 }
633 None => ResolvedTrack {
634 source: path.clone(),
635 track_id: None,
636 metadata: tagged
637 .remove(path)
638 .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
639 },
640 };
641 resolved.push(entry);
642 }
643 Ok(resolved)
644}
645
646pub struct ResolvedSelection {
653 tracks: Vec<ResolvedTrack>,
654}
655
656impl ResolvedSelection {
657 pub fn len(&self) -> usize {
660 self.tracks.len()
661 }
662
663 pub fn is_empty(&self) -> bool {
664 self.tracks.is_empty()
665 }
666}
667
668pub fn resolve(
673 db: &Database,
674 track_ids: Option<&[i64]>,
675) -> Result<ResolvedSelection, OrganizeError> {
676 let selection = match track_ids {
677 Some(ids) => Selection::TrackIds(ids),
678 None => Selection::All,
679 };
680 resolve_selection(db, selection)
681}
682
683pub fn resolve_paths(db: &Database, paths: &[PathBuf]) -> Result<ResolvedSelection, OrganizeError> {
686 resolve_selection(db, Selection::Paths(paths))
687}
688
689fn resolve_selection(
690 db: &Database,
691 selection: Selection<'_>,
692) -> Result<ResolvedSelection, OrganizeError> {
693 let albums = load_album_facts(&db.conn)?;
694 let tracks = match selection {
695 Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
696 Selection::TrackIds(ids) => {
697 let mut rows = Vec::with_capacity(ids.len());
698 for &id in ids {
699 if let Some(row) = queries::get_track_row(&db.conn, id)? {
700 rows.push(row);
701 }
702 }
703 resolve_from_rows(rows, &albums)
704 }
705 Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
706 };
707 Ok(ResolvedSelection { tracks })
708}
709
710pub fn generate(selection: &ResolvedSelection, pattern: &str, base_dir: &Path) -> OrganizeResult {
717 let mut entries = Vec::with_capacity(selection.tracks.len());
718 let mut dests = DestinationLedger::default();
719
720 for track in &selection.tracks {
721 let metadata = match &track.metadata {
722 Ok(m) => m,
723 Err(msg) => {
724 entries.push(PlanEntry {
725 track_id: track.track_id,
726 from: track.source.clone(),
727 to: None,
728 ancillary: Vec::new(),
729 outcome: PlanOutcome::Error(msg.clone()),
730 });
731 continue;
732 }
733 };
734 entries.push(plan_single_move(
735 &track.source,
736 track.track_id,
737 metadata,
738 pattern,
739 base_dir,
740 &mut dests,
741 ));
742 }
743
744 OrganizeResult { entries }
745}
746
747fn plan(
751 db: &Database,
752 selection: Selection<'_>,
753 pattern: &str,
754 base_dir: &Path,
755 check_disk: bool,
756) -> Result<OrganizeResult, OrganizeError> {
757 let resolved = resolve_selection(db, selection)?;
758 let mut result = generate(&resolved, pattern, base_dir);
759 if check_disk {
760 check_against_disk(&mut result, move_ancillary());
761 }
762 Ok(result)
763}
764
765fn run(
768 db: &Database,
769 selection: Selection<'_>,
770 pattern: &str,
771 base_dir: &Path,
772) -> Result<OrganizeResult, OrganizeError> {
773 let mut result = plan(db, selection, pattern, base_dir, true)?;
774
775 let pending: Vec<FileMove> = result
776 .entries
777 .iter()
778 .filter_map(PlanEntry::as_move)
779 .collect();
780 if pending.is_empty() {
781 return Ok(result);
782 }
783
784 check_free_space(&pending, base_dir)?;
785
786 let batch_id = batch_id();
787 let floors = cleanup_floors(Some(base_dir));
788
789 for file_move in pending {
793 let failure = match execute_single_move(db, &file_move, &batch_id, &floors) {
794 Ok(()) => verify_move(&file_move).err(),
795 Err(e) => Some(e.to_string()),
796 };
797 let Some(reason) = failure else { continue };
798 log::warn!(
799 "organize: {} → {} failed: {reason}",
800 file_move.from.display(),
801 file_move.to.display()
802 );
803 if let Some(entry) = result.entries.iter_mut().find(|e| e.from == file_move.from) {
804 entry.outcome = PlanOutcome::Error(reason);
805 }
806 }
807
808 Ok(result)
809}
810
811pub fn preview(
819 db: &Database,
820 pattern: &str,
821 base_dir: Option<&Path>,
822 check_disk: bool,
823) -> Result<OrganizeResult, OrganizeError> {
824 let base = resolve_base_dir(base_dir)?;
825 plan(db, Selection::All, pattern, &base, check_disk)
826}
827
828pub fn execute(
830 db: &Database,
831 pattern: &str,
832 base_dir: Option<&Path>,
833) -> Result<OrganizeResult, OrganizeError> {
834 let base = resolve_base_dir(base_dir)?;
835 run(db, Selection::All, pattern, &base)
836}
837
838pub fn preview_for_tracks(
840 db: &Database,
841 track_ids: &[i64],
842 pattern: &str,
843 base_dir: Option<&Path>,
844 check_disk: bool,
845) -> Result<OrganizeResult, OrganizeError> {
846 let base = resolve_base_dir(base_dir)?;
847 plan(
848 db,
849 Selection::TrackIds(track_ids),
850 pattern,
851 &base,
852 check_disk,
853 )
854}
855
856pub fn execute_for_tracks(
858 db: &Database,
859 track_ids: &[i64],
860 pattern: &str,
861 base_dir: Option<&Path>,
862) -> Result<OrganizeResult, OrganizeError> {
863 let base = resolve_base_dir(base_dir)?;
864 run(db, Selection::TrackIds(track_ids), pattern, &base)
865}
866
867pub fn preview_for_paths(
869 paths: &[PathBuf],
870 pattern: &str,
871 base_dir: Option<&Path>,
872 check_disk: bool,
873) -> Result<OrganizeResult, OrganizeError> {
874 let db = Database::open_default()?;
875 let base = resolve_base_dir(base_dir)?;
876 plan(&db, Selection::Paths(paths), pattern, &base, check_disk)
877}
878
879pub fn execute_for_paths(
882 paths: &[PathBuf],
883 pattern: &str,
884 base_dir: Option<&Path>,
885) -> Result<OrganizeResult, OrganizeError> {
886 let db = Database::open_default()?;
887 let base = resolve_base_dir(base_dir)?;
888 run(&db, Selection::Paths(paths), pattern, &base)
889}
890
891fn verify_move(file_move: &FileMove) -> Result<(), String> {
893 if !file_move.to.exists() {
894 return Err(format!(
895 "destination not found after move: {}",
896 file_move.to.display()
897 ));
898 }
899 if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
900 return Err(format!(
901 "source still exists after move: {}",
902 file_move.from.display()
903 ));
904 }
905 Ok(())
906}
907
908fn log_move(
909 conn: &Connection,
910 batch_id: &str,
911 track_id: Option<i64>,
912 from: &Path,
913 to: &Path,
914 size: Option<u64>,
915 mtime: Option<i64>,
916) -> Result<(), OrganizeError> {
917 conn.execute(
918 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
919 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
920 params![
921 batch_id,
922 track_id,
923 from.to_string_lossy().as_ref(),
924 to.to_string_lossy().as_ref(),
925 size.map(|s| s as i64),
926 mtime,
927 ],
928 )?;
929 Ok(())
930}
931
932fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
938 let old_lossy = old.to_string_lossy();
939 let new_lossy = new.to_string_lossy();
940 let old_path = old_lossy.as_ref();
941 let new_path = new_lossy.as_ref();
942
943 conn.execute(
944 "UPDATE tracks SET path = ?1 WHERE path = ?2",
945 params![new_path, old_path],
946 )?;
947 conn.execute(
948 "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
949 params![new_path, old_path],
950 )?;
951 conn.execute(
952 "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
953 params![new_path, old_path],
954 )?;
955 conn.execute(
958 "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
959 params![new_path, old_path],
960 )?;
961 conn.execute(
962 "UPDATE playback_state SET cursor_id = ?1 WHERE cursor_id = ?2",
963 params![new_path, old_path],
964 )?;
965 rewrite_queue_json(conn, old_path, new_path)?;
966 Ok(())
967}
968
969fn rewrite_queue_json(
974 conn: &Connection,
975 old_path: &str,
976 new_path: &str,
977) -> Result<(), OrganizeError> {
978 let mut stmt =
979 conn.prepare("SELECT id, queue_json FROM playback_state WHERE instr(queue_json, ?1) > 0")?;
980 let rows: Vec<(i64, String)> = stmt
981 .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
982 .collect::<Result<Vec<_>, _>>()?;
983 drop(stmt);
984
985 for (id, json) in rows {
986 let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
987 continue;
988 };
989 let mut changed = false;
990 for item in &mut items {
991 if item.path == old_path {
992 item.path = new_path.to_string();
993 changed = true;
994 }
995 }
996 if !changed {
997 continue;
998 }
999 let Ok(updated) = serde_json::to_string(&items) else {
1000 continue;
1001 };
1002 conn.execute(
1003 "UPDATE playback_state SET queue_json = ?1 WHERE id = ?2",
1004 params![updated, id],
1005 )?;
1006 }
1007 Ok(())
1008}
1009
1010fn execute_single_move(
1014 db: &Database,
1015 file_move: &FileMove,
1016 batch_id: &str,
1017 floors: &[PathBuf],
1018) -> Result<(), OrganizeError> {
1019 if let Some(parent) = file_move.to.parent() {
1020 std::fs::create_dir_all(parent)?;
1021 }
1022
1023 let source_meta = std::fs::metadata(&file_move.from)?;
1024 let size = source_meta.len();
1025 let mtime = mtime_secs(&source_meta);
1026
1027 let tx = db.conn.unchecked_transaction()?;
1028 log_move(
1029 &tx,
1030 batch_id,
1031 file_move.track_id,
1032 &file_move.from,
1033 &file_move.to,
1034 Some(size),
1035 mtime,
1036 )?;
1037 rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
1038
1039 move_file(&file_move.from, &file_move.to)?;
1041
1042 let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
1043 let mut failure = None;
1044 for (anc_from, anc_to) in &file_move.ancillary {
1045 if let Some(parent) = anc_to.parent()
1046 && std::fs::create_dir_all(parent).is_err()
1047 {
1048 continue;
1049 }
1050 match move_file(anc_from, anc_to) {
1052 Ok(()) => {
1053 moved_ancillary.push((anc_from, anc_to));
1054 let meta = std::fs::metadata(anc_to).ok();
1055 if let Err(e) = log_move(
1056 &tx,
1057 batch_id,
1058 None,
1059 anc_from,
1060 anc_to,
1061 meta.as_ref().map(|m| m.len()),
1062 meta.as_ref().and_then(mtime_secs),
1063 ) {
1064 failure = Some(e);
1065 break;
1066 }
1067 }
1068 Err(e) => log::warn!(
1069 "failed to move ancillary file {}: {}",
1070 anc_from.display(),
1071 e
1072 ),
1073 }
1074 }
1075
1076 let outcome = match failure {
1077 Some(e) => Err(e),
1078 None => tx.commit().map_err(OrganizeError::from),
1079 };
1080
1081 if let Err(e) = outcome {
1082 for (anc_from, anc_to) in moved_ancillary {
1085 let _ = move_file(anc_to, anc_from);
1086 }
1087 let _ = move_file(&file_move.to, &file_move.from);
1088 return Err(e);
1089 }
1090
1091 if let Some(source_dir) = file_move.from.parent() {
1092 remove_empty_dirs(source_dir, floors);
1093 }
1094
1095 Ok(())
1096}
1097
1098pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
1104 let batch_id: String = db
1107 .conn
1108 .query_row(
1109 "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
1110 [],
1111 |row| row.get(0),
1112 )
1113 .map_err(|_| OrganizeError::NothingToUndo)?;
1114
1115 let mut stmt = db.conn.prepare(
1116 "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
1117 WHERE batch_id = ?1 ORDER BY id DESC",
1118 )?;
1119
1120 let entries: Vec<UndoEntry> = stmt
1121 .query_map(params![batch_id], |row| {
1122 Ok((
1123 row.get(0)?,
1124 row.get(1)?,
1125 row.get(2)?,
1126 row.get(3)?,
1127 row.get(4)?,
1128 ))
1129 })?
1130 .collect::<Result<Vec<_>, _>>()?;
1131 drop(stmt);
1132
1133 let floors = cleanup_floors(None);
1134 let mut result = UndoResult::default();
1135
1136 for (log_id, from_path, to_path, size, mtime) in &entries {
1137 let to = Path::new(to_path);
1138 let from = Path::new(from_path);
1139
1140 if !to.exists() {
1141 db.conn
1143 .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
1144 continue;
1145 }
1146
1147 if from.exists() && !paths_equal(from, to) {
1148 result.errors.push((
1149 from.to_path_buf(),
1150 format!(
1151 "another file now occupies the original path; {} left in place",
1152 to.display()
1153 ),
1154 ));
1155 continue;
1156 }
1157
1158 if let Err(msg) = matches_logged_file(to, *size, *mtime) {
1159 result.errors.push((to.to_path_buf(), msg));
1160 continue;
1161 }
1162
1163 if let Some(parent) = from.parent()
1164 && let Err(e) = std::fs::create_dir_all(parent)
1165 {
1166 result.errors.push((from.to_path_buf(), e.to_string()));
1167 continue;
1168 }
1169
1170 let tx = db.conn.unchecked_transaction()?;
1171 if let Err(e) = rewrite_path_references(&tx, to, from) {
1172 result.errors.push((to.to_path_buf(), e.to_string()));
1173 continue;
1174 }
1175 if let Err(e) = move_file(to, from) {
1176 result.errors.push((to.to_path_buf(), e.to_string()));
1177 continue;
1178 }
1179 if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
1180 let _ = move_file(from, to);
1181 result.errors.push((to.to_path_buf(), e.to_string()));
1182 continue;
1183 }
1184 if let Err(e) = tx.commit() {
1185 let _ = move_file(from, to);
1186 result.errors.push((to.to_path_buf(), e.to_string()));
1187 continue;
1188 }
1189
1190 if let Some(parent) = to.parent() {
1191 remove_empty_dirs(parent, &floors);
1192 }
1193
1194 result.restored += 1;
1195 }
1196
1197 Ok(result)
1198}
1199
1200fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
1203 let (Some(size), Some(mtime)) = (size, mtime) else {
1204 return Ok(());
1205 };
1206 let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
1207 if meta.len() != size as u64 {
1208 return Err(format!(
1209 "{} has changed since it was moved (size differs); left in place",
1210 path.display()
1211 ));
1212 }
1213 if mtime_secs(&meta).is_some_and(|current| current != mtime) {
1214 return Err(format!(
1215 "{} has changed since it was moved (modification time differs); left in place",
1216 path.display()
1217 ));
1218 }
1219 Ok(())
1220}
1221
1222fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
1223 meta.modified()
1224 .ok()?
1225 .duration_since(UNIX_EPOCH)
1226 .ok()
1227 .map(|d| d.as_secs() as i64)
1228}
1229
1230fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
1232 let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
1233 if let Ok(config) = crate::config::Config::load() {
1234 floors.extend(config.library.folders);
1235 }
1236 floors
1237}
1238
1239fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
1242 let mut current = start.to_path_buf();
1243 loop {
1244 if floors.iter().any(|floor| floor == ¤t) {
1245 break;
1246 }
1247 let empty = std::fs::read_dir(¤t)
1248 .map(|mut d| d.next().is_none())
1249 .unwrap_or(false);
1250 if !empty || std::fs::remove_dir(¤t).is_err() {
1251 break;
1252 }
1253 let Some(parent) = current.parent() else {
1254 break;
1255 };
1256 if !floors
1258 .iter()
1259 .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1260 {
1261 break;
1262 }
1263 current = parent.to_path_buf();
1264 }
1265}
1266
1267fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1269 if from == to {
1270 return Ok(());
1271 }
1272 if paths_equal(from, to) {
1273 return rename_via_temp(from, to);
1277 }
1278
1279 match std::fs::OpenOptions::new()
1282 .write(true)
1283 .create_new(true)
1284 .open(to)
1285 {
1286 Ok(_) => {}
1287 Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1288 return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1289 }
1290 Err(e) => return Err(e.into()),
1291 }
1292
1293 match transfer(from, to) {
1294 Ok(()) => Ok(()),
1295 Err(e) => {
1296 let _ = std::fs::remove_file(to);
1298 Err(e)
1299 }
1300 }
1301}
1302
1303fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1304 let temp = temp_sibling(to);
1305 std::fs::rename(from, &temp)?;
1306 match std::fs::rename(&temp, to) {
1307 Ok(()) => Ok(()),
1308 Err(e) => {
1309 let _ = std::fs::rename(&temp, from);
1310 Err(e.into())
1311 }
1312 }
1313}
1314
1315fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1317 match std::fs::rename(from, to) {
1318 Ok(()) => Ok(()),
1319 Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1321 Err(e) => Err(e.into()),
1322 }
1323}
1324
1325fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1328 let source_meta = std::fs::metadata(from)?;
1329 let expected = source_meta.len();
1330 let temp = temp_sibling(to);
1331
1332 let copied = {
1333 let mut reader = std::fs::File::open(from)?;
1334 let mut writer = std::fs::OpenOptions::new()
1335 .write(true)
1336 .create_new(true)
1337 .open(&temp)?;
1338 let copied = std::io::copy(&mut reader, &mut writer)?;
1339 writer.sync_all()?;
1341 if let Ok(modified) = source_meta.modified() {
1342 let _ = writer.set_modified(modified);
1343 }
1344 copied
1345 };
1346
1347 let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1348 if copied != expected || written != expected {
1349 let _ = std::fs::remove_file(&temp);
1350 return Err(OrganizeError::ShortCopy {
1351 path: from.to_path_buf(),
1352 expected,
1353 copied: copied.min(written),
1354 });
1355 }
1356
1357 if let Err(e) = std::fs::rename(&temp, to) {
1358 let _ = std::fs::remove_file(&temp);
1359 return Err(e.into());
1360 }
1361 std::fs::remove_file(from)?;
1362 Ok(())
1363}
1364
1365fn temp_sibling(path: &Path) -> PathBuf {
1366 let nanos = SystemTime::now()
1367 .duration_since(UNIX_EPOCH)
1368 .unwrap_or_default()
1369 .as_nanos();
1370 path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1371}
1372
1373fn paths_equal(a: &Path, b: &Path) -> bool {
1376 if a == b {
1377 return true;
1378 }
1379 #[cfg(unix)]
1380 {
1381 use std::os::unix::fs::MetadataExt;
1382 if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1383 return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1384 }
1385 }
1386 false
1387}
1388
1389fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1392 let Some(target) = existing_ancestor(base_dir) else {
1393 return Ok(());
1394 };
1395 let Some(target_device) = device_id(&target) else {
1396 return Ok(());
1397 };
1398
1399 let mut needed = 0u64;
1400 for file_move in moves {
1401 if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1402 continue;
1403 }
1404 if let Ok(meta) = std::fs::metadata(&file_move.from) {
1405 needed = needed.saturating_add(meta.len());
1406 }
1407 }
1408 if needed == 0 {
1409 return Ok(());
1410 }
1411
1412 match available_bytes(&target) {
1413 Some(available) if available < needed => {
1414 Err(OrganizeError::NotEnoughSpace { needed, available })
1415 }
1416 _ => Ok(()),
1417 }
1418}
1419
1420fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1421 path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1422}
1423
1424#[cfg(unix)]
1425fn device_id(path: &Path) -> Option<u64> {
1426 use std::os::unix::fs::MetadataExt;
1427 std::fs::metadata(path).ok().map(|m| m.dev())
1428}
1429
1430#[cfg(not(unix))]
1431fn device_id(_path: &Path) -> Option<u64> {
1432 None
1433}
1434
1435#[cfg(unix)]
1436fn available_bytes(path: &Path) -> Option<u64> {
1437 use std::os::unix::ffi::OsStrExt;
1438 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1439 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1440 if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1441 return None;
1442 }
1443 (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1445}
1446
1447#[cfg(not(unix))]
1448fn available_bytes(_path: &Path) -> Option<u64> {
1449 None
1450}
1451
1452fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1460 let dir = match base_dir {
1461 Some(dir) => dir.to_path_buf(),
1462 None => crate::config::Config::load()
1463 .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?
1464 .library
1465 .folders
1466 .into_iter()
1467 .find(|folder| !folder.as_os_str().is_empty())
1468 .ok_or(OrganizeError::NoDestination)?,
1469 };
1470
1471 if dir.as_os_str().is_empty() {
1472 return Err(OrganizeError::NoDestination);
1473 }
1474 Ok(dir)
1475}
1476
1477fn move_ancillary() -> bool {
1480 crate::config::Config::load()
1481 .map(|c| c.organize.move_ancillary)
1482 .unwrap_or(true)
1483}
1484
1485fn batch_id() -> String {
1486 let now = SystemTime::now()
1487 .duration_since(UNIX_EPOCH)
1488 .unwrap_or_default();
1489 format!("batch-{}", now.as_nanos())
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495 use crate::db::queries::TrackMeta;
1496 use crate::db::schema;
1497 use tempfile::TempDir;
1498
1499 fn test_db() -> Database {
1500 let conn = rusqlite::Connection::open_in_memory().unwrap();
1501 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1502 schema::create_tables(&conn).unwrap();
1503 Database { conn }
1504 }
1505
1506 fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1507 TrackMeta {
1508 title: title.into(),
1509 artist: artist.into(),
1510 album_artist: Some(artist.into()),
1511 album: album.into(),
1512 date: Some("1997-06-16".into()),
1513 disc: Some(1),
1514 track_number: Some(1),
1515 genre: Some("Rock".into()),
1516 label: None,
1517 duration_ms: Some(240_000),
1518 codec: Some("FLAC".into()),
1519 sample_rate: Some(44100),
1520 bit_depth: Some(16),
1521 channels: Some(2),
1522 bitrate: Some(1000),
1523 size_bytes: Some(30_000_000),
1524 mtime: Some(1700000000),
1525 path: None,
1526 source: "local".into(),
1527 remote_id: None,
1528 remote_url: None,
1529 album_remote_id: None,
1530 artist_remote_id: None,
1531 mbid: None,
1532 album_added_at: None,
1533 }
1534 }
1535
1536 fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1537 TrackRow {
1538 id: 1,
1539 album_id: Some(1),
1540 artist_id: Some(1),
1541 artist_name: artist.into(),
1542 album_artist_name: artist.into(),
1543 album_title: album.into(),
1544 disc: Some(1),
1545 track_number: Some(1),
1546 title: title.into(),
1547 duration_ms: Some(240_000),
1548 path: Some("/music/test.flac".into()),
1549 codec: Some("FLAC".into()),
1550 sample_rate: Some(44100),
1551 bit_depth: Some(16),
1552 channels: Some(2),
1553 bitrate: Some(1000),
1554 genre: None,
1555 source: "local".into(),
1556 remote_id: None,
1557 cached_path: None,
1558 }
1559 }
1560
1561 fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1563 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1564 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1565 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1566 meta.track_number = Some(track_number);
1567 meta.path = Some(path.to_string_lossy().into_owned());
1568 queries::upsert_track(&db.conn, &meta).unwrap()
1569 }
1570
1571 fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1572 db.conn
1573 .query_row(
1574 "SELECT path FROM tracks WHERE id = ?1",
1575 params![track_id],
1576 |row| row.get(0),
1577 )
1578 .unwrap()
1579 }
1580
1581 fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1582 let mut stmt = db
1583 .conn
1584 .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1585 .unwrap();
1586 let rows = stmt
1587 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1588 .unwrap();
1589 rows.map(|r| r.unwrap()).collect()
1590 }
1591
1592 #[test]
1595 fn track_metadata_provider_fields() {
1596 let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1597 track.track_number = Some(3);
1598 track.genre = Some("Alternative".into());
1599
1600 let album = AlbumFacts {
1601 date: Some("1997-06-16".into()),
1602 label: Some("Parlophone".into()),
1603 };
1604 let meta = TrackMetadata::from_track_row(&track, &album);
1605 assert_eq!(
1606 meta.get_field("title").as_deref(),
1607 Some("Subterranean Homesick Alien")
1608 );
1609 assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1610 assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1611 assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1612 assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1613 assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1614 assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1615 assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1616 assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1617 assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1618 assert_eq!(meta.get_field("nonexistent"), None);
1619 }
1620
1621 #[test]
1624 fn both_metadata_sources_expose_the_same_fields() {
1625 let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1626 track.genre = Some("Rock".into());
1627 let album = AlbumFacts {
1628 date: Some("1997-06-16".into()),
1629 label: Some("Parlophone".into()),
1630 };
1631 let from_db = TrackMetadata::from_track_row(&track, &album);
1632
1633 let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1634 meta.label = Some("Parlophone".into());
1635 let from_tags = TrackMetadata::from_file_meta(&meta);
1636
1637 let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1638 let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1639 db_fields.sort();
1640 tag_fields.sort();
1641 assert_eq!(db_fields, tag_fields);
1642 }
1643
1644 #[test]
1645 fn sanitize_replaces_illegal_chars() {
1646 assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1647 assert_eq!(sanitise_filename("What?"), "What_");
1648 assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1649 assert_eq!(sanitise_filename("normal"), "normal");
1650 }
1651
1652 #[test]
1653 fn sanitize_relative_path_splits() {
1654 assert_eq!(
1655 sanitize_relative_path("Artist/Album/Track").unwrap(),
1656 PathBuf::from("Artist/Album/Track")
1657 );
1658 assert_eq!(
1659 sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1660 PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1661 );
1662 }
1663
1664 #[test]
1665 fn sanitize_relative_path_refuses_traversal_and_gaps() {
1666 assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1669 assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1670 assert!(sanitize_relative_path("./Artist/./Album").is_err());
1671 assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1672 assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1673 assert!(sanitize_relative_path(" /Airbag").is_err());
1674 }
1675
1676 #[test]
1677 fn acdc_artist_name_sanitized() {
1678 let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1679 let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1680 assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1681 let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1682 assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1683 }
1684
1685 #[test]
1686 fn format_string_evaluation() {
1687 let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1688 let album = AlbumFacts {
1689 date: Some("1997-06-16".into()),
1690 label: None,
1691 };
1692 let meta = TrackMetadata::from_track_row(&track, &album);
1693 let pattern =
1694 "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1695 assert_eq!(
1696 format::format(pattern, &meta).unwrap(),
1697 "Radiohead/(1997) OK Computer/01. Airbag"
1698 );
1699 }
1700
1701 #[test]
1702 fn ancillary_file_detection() {
1703 let tmp = TempDir::new().unwrap();
1704 let dir = tmp.path();
1705 std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1706 std::fs::write(dir.join("cover.png"), b"img").unwrap();
1707 std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1708 std::fs::write(dir.join("rip.log"), b"log").unwrap();
1709 std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1710
1711 let found = find_ancillary_files(dir);
1712 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1713 assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1714 assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1715 assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1716 assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1717 }
1718
1719 #[test]
1722 fn preview_does_not_move_files() {
1723 let db = test_db();
1724 let tmp = TempDir::new().unwrap();
1725 let source = tmp.path().join("src/test.flac");
1726 add_track(&db, &source, "Airbag", 1);
1727
1728 let result = preview(
1729 &db,
1730 "%album artist%/%album%/%title%",
1731 Some(tmp.path()),
1732 true,
1733 )
1734 .unwrap();
1735 assert!(source.exists());
1736 assert_eq!(result.moved_count(), 1);
1737 }
1738
1739 #[test]
1740 fn execute_moves_files_and_undo_reverts() {
1741 let db = test_db();
1742 let tmp = TempDir::new().unwrap();
1743 let source = tmp.path().join("src/test.flac");
1744 let id = add_track(&db, &source, "Airbag", 1);
1745
1746 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1747 assert_eq!(result.moved_count(), 1);
1748 assert_eq!(result.failures().count(), 0);
1749 assert!(!source.exists());
1750 let dest = result.moves().next().unwrap().dest().to_path_buf();
1751 assert!(dest.exists());
1752 assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1753
1754 let undone = undo(&db).unwrap();
1755 assert_eq!(undone.restored, 1);
1756 assert!(undone.errors.is_empty());
1757 assert!(source.exists());
1758 assert!(!dest.exists());
1759 assert_eq!(
1760 db_path_of(&db, id).as_deref(),
1761 Some(source.to_str().unwrap())
1762 );
1763 }
1764
1765 #[test]
1769 fn preview_and_execute_agree_on_destinations() {
1770 let db = test_db();
1771 let tmp = TempDir::new().unwrap();
1772 let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1773
1774 let source = tmp.path().join("src/aphex.flac");
1775 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1776 std::fs::write(&source, b"audio").unwrap();
1777 let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1778 meta.label = Some("Warp Records".into());
1779 meta.path = Some(source.to_string_lossy().into_owned());
1780 queries::upsert_track(&db.conn, &meta).unwrap();
1781
1782 let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1783 assert_eq!(previewed.moved_count(), 1);
1784 let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1785 assert!(expected.starts_with(tmp.path().join("Warp Records")));
1786
1787 let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1788 assert_eq!(executed.moved_count(), 1);
1789 assert_eq!(executed.moves().next().unwrap().dest(), expected);
1790 assert!(expected.exists());
1791 }
1792
1793 #[test]
1797 fn imported_files_organize_into_the_library_folder() {
1798 let db = test_db();
1799 let tmp = TempDir::new().unwrap();
1800 let outside = tmp.path().join("Downloads/rip");
1801 let library = tmp.path().join("Music");
1802 std::fs::create_dir_all(&outside).unwrap();
1803 std::fs::create_dir_all(&library).unwrap();
1804
1805 let dropped = outside.join("track.wav");
1806 crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1807
1808 let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1809 assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1810
1811 let result = execute_for_tracks(
1812 &db,
1813 &imported.track_ids,
1814 "%album artist%/%album%/%title%",
1815 Some(&library),
1816 )
1817 .unwrap();
1818
1819 assert_eq!(result.moved_count(), 1);
1820 let dest = result.moves().next().unwrap().dest();
1821 assert!(dest.starts_with(&library), "landed at {}", dest.display());
1822 assert!(dest.exists());
1823 assert!(
1824 !dropped.exists(),
1825 "the original should have moved, not copied"
1826 );
1827
1828 assert_eq!(
1830 db_path_of(&db, imported.track_ids[0]).as_deref(),
1831 dest.to_str()
1832 );
1833 }
1834
1835 #[test]
1840 fn generate_touches_no_files() {
1841 let db = test_db();
1842 let tmp = TempDir::new().unwrap();
1843 let source = tmp.path().join("src/track.flac");
1844 add_track(&db, &source, "Airbag", 1);
1845 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1846
1847 let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1849 std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
1850 std::fs::write(&occupied, b"the good rip").unwrap();
1851
1852 let selection = resolve(&db, None).unwrap();
1853 let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1854
1855 assert_eq!(result.moved_count(), 1);
1857 assert_eq!(result.conflicts().count(), 0);
1858 assert!(result.entries[0].ancillary.is_empty());
1859
1860 check_against_disk(&mut result, true);
1862 assert_eq!(result.moved_count(), 0);
1863 assert_eq!(result.conflicts().count(), 1);
1864 assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
1865 }
1866
1867 #[test]
1872 fn an_empty_base_dir_is_not_a_destination() {
1873 let db = test_db();
1874 add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
1875
1876 let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
1877 assert!(matches!(err, OrganizeError::NoDestination));
1878
1879 let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
1880 assert!(matches!(err, OrganizeError::NoDestination));
1881 }
1882
1883 #[test]
1886 fn generate_agrees_with_a_full_plan() {
1887 let db = test_db();
1888 let tmp = TempDir::new().unwrap();
1889 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1890 add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
1891 let pattern = "%album artist%/%album%/%tracknumber%. %title%";
1892
1893 let selection = resolve(&db, None).unwrap();
1894 let mut generated = generate(&selection, pattern, tmp.path());
1895 check_against_disk(&mut generated, true);
1896 let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1897
1898 assert_eq!(generated.entries.len(), planned.entries.len());
1899 for (a, b) in generated.entries.iter().zip(&planned.entries) {
1900 assert_eq!(a.from, b.from);
1901 assert_eq!(a.to, b.to);
1902 assert_eq!(a.outcome, b.outcome);
1903 assert_eq!(a.ancillary, b.ancillary);
1904 }
1905 }
1906
1907 #[test]
1910 fn ancillary_is_scanned_once_per_directory() {
1911 let db = test_db();
1912 let tmp = TempDir::new().unwrap();
1913 for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
1914 add_track(
1915 &db,
1916 &tmp.path().join(format!("src/{i}.flac")),
1917 title,
1918 i as i32 + 1,
1919 );
1920 }
1921 std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
1922
1923 let result = preview(
1924 &db,
1925 "%album artist%/%album%/%title%",
1926 Some(tmp.path()),
1927 true,
1928 )
1929 .unwrap();
1930
1931 let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
1933 assert_eq!(carrying.len(), 1);
1934 assert_eq!(carrying[0].ancillary.len(), 1);
1935 }
1936
1937 #[test]
1942 fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
1943 let db = test_db();
1944 let tmp = TempDir::new().unwrap();
1945 let pattern = "%album artist%/%album%/%title%";
1946 add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1947
1948 let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
1949 assert_eq!(moved.moved_count(), 1);
1950
1951 let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1953 assert_eq!(again.conflicts().count(), 0);
1954 assert_eq!(again.unchanged_count(), 1);
1955 }
1956
1957 #[test]
1958 fn ancillary_files_stay_put_when_they_are_turned_off() {
1959 let db = test_db();
1960 let tmp = TempDir::new().unwrap();
1961 let source = tmp.path().join("src/a.flac");
1962 add_track(&db, &source, "Airbag", 1);
1963 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1964
1965 let selection = resolve(&db, None).unwrap();
1966 let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1967 check_against_disk(&mut off, false);
1968 assert!(off.moves().all(|e| e.ancillary.is_empty()));
1969
1970 let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1971 check_against_disk(&mut on, true);
1972 assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
1973 }
1974
1975 #[test]
1978 fn colliding_destinations_leave_both_files_intact() {
1979 let db = test_db();
1980 let tmp = TempDir::new().unwrap();
1981 let first = tmp.path().join("src/a.flac");
1982 let second = tmp.path().join("src/b.flac");
1983 let first_id = add_track(&db, &first, "Airbag", 1);
1985 let second_id = add_track(&db, &second, "Airbag", 2);
1986 let second_bytes = std::fs::read(&second).unwrap();
1987
1988 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1989
1990 assert_eq!(result.moved_count(), 1);
1991
1992 let blocked = result.conflicts().next().unwrap();
1995 assert_eq!(result.conflicts().count(), 1);
1996 assert_eq!(blocked.from, second);
1997 assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
1998
1999 assert!(second.exists());
2001 assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2002 assert_eq!(
2003 db_path_of(&db, second_id).as_deref(),
2004 Some(second.to_str().unwrap())
2005 );
2006
2007 let dest = result.moves().next().unwrap().dest();
2008 assert_eq!(
2009 std::fs::read(dest).unwrap(),
2010 b"audio bytes for Airbag".to_vec()
2011 );
2012 assert_eq!(
2013 db_path_of(&db, first_id).as_deref(),
2014 Some(dest.to_str().unwrap())
2015 );
2016 }
2017
2018 #[test]
2019 fn existing_destination_is_never_overwritten() {
2020 let db = test_db();
2021 let tmp = TempDir::new().unwrap();
2022 let source = tmp.path().join("src/new.flac");
2023 add_track(&db, &source, "Airbag", 1);
2024
2025 let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2027 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2028 std::fs::write(&dest, b"the good rip").unwrap();
2029
2030 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2031 assert_eq!(result.moved_count(), 0);
2032
2033 let blocked = result.conflicts().next().unwrap();
2036 assert_eq!(result.conflicts().count(), 1);
2037 assert_eq!(blocked.from, source);
2038 assert_eq!(blocked.dest(), dest);
2039 assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2040
2041 assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2042 assert!(source.exists());
2043 }
2044
2045 #[test]
2048 fn move_file_refuses_an_occupied_destination() {
2049 let tmp = TempDir::new().unwrap();
2050 let from = tmp.path().join("a.flac");
2051 let to = tmp.path().join("b.flac");
2052 std::fs::write(&from, b"source").unwrap();
2053 std::fs::write(&to, b"keep me").unwrap();
2054
2055 let err = move_file(&from, &to).unwrap_err();
2056 assert!(matches!(err, OrganizeError::DestinationExists(_)));
2057 assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2058 assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2059 }
2060
2061 #[cfg(target_os = "macos")]
2062 #[test]
2063 fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2064 let db = test_db();
2065 let tmp = TempDir::new().unwrap();
2066 let first = tmp.path().join("src/1.flac");
2067 let second = tmp.path().join("src/2.flac");
2068 std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2069 for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2070 std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2071 let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2072 meta.track_number = Some(number);
2073 meta.path = Some(path.to_string_lossy().into_owned());
2074 queries::upsert_track(&db.conn, &meta).unwrap();
2075 }
2076
2077 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2078 assert_eq!(result.moved_count(), 1);
2079 assert_eq!(result.failures().count(), 1);
2080 assert!(second.exists());
2081 assert_eq!(
2082 std::fs::read(&second).unwrap(),
2083 b"audio bytes for RAIN".to_vec()
2084 );
2085 }
2086
2087 #[test]
2090 fn case_only_rename_keeps_the_file() {
2091 let tmp = TempDir::new().unwrap();
2092 let from = tmp.path().join("rain.flac");
2093 let to = tmp.path().join("Rain.flac");
2094 std::fs::write(&from, b"audio bytes").unwrap();
2095
2096 move_file(&from, &to).unwrap();
2097
2098 assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2099 let names: Vec<String> = std::fs::read_dir(tmp.path())
2100 .unwrap()
2101 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2102 .collect();
2103 assert_eq!(names, vec!["Rain.flac".to_string()]);
2104 }
2105
2106 #[test]
2109 fn cross_device_copy_verifies_before_dropping_the_source() {
2110 let tmp = TempDir::new().unwrap();
2111 let from = tmp.path().join("a.flac");
2112 let to = tmp.path().join("b.flac");
2113 let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2114 std::fs::write(&from, &bytes).unwrap();
2115 let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2116
2117 copy_across_devices(&from, &to).unwrap();
2118
2119 assert!(!from.exists());
2120 assert_eq!(std::fs::read(&to).unwrap(), bytes);
2121 assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2123 let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2125 .unwrap()
2126 .filter(|e| {
2127 e.as_ref()
2128 .unwrap()
2129 .file_name()
2130 .to_string_lossy()
2131 .starts_with(".koan-")
2132 })
2133 .collect();
2134 assert!(leftovers.is_empty());
2135 }
2136
2137 #[test]
2138 fn free_space_check_ignores_same_device_moves() {
2139 let tmp = TempDir::new().unwrap();
2140 let from = tmp.path().join("a.flac");
2141 std::fs::write(&from, b"audio").unwrap();
2142 let moves = vec![FileMove {
2143 track_id: None,
2144 from,
2145 to: tmp.path().join("b.flac"),
2146 ancillary: Vec::new(),
2147 }];
2148 assert!(check_free_space(&moves, tmp.path()).is_ok());
2150 }
2151
2152 #[test]
2155 fn unknown_function_refuses_the_move() {
2156 let db = test_db();
2157 let tmp = TempDir::new().unwrap();
2158 let source = tmp.path().join("src/test.flac");
2159 add_track(&db, &source, "Airbag", 1);
2160
2161 let result = execute(
2163 &db,
2164 "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2165 Some(tmp.path()),
2166 )
2167 .unwrap();
2168 assert_eq!(result.moved_count(), 0);
2169 assert_eq!(result.failures().count(), 1);
2170 assert!(result.failure_messages()[0].contains("unknown function"));
2171 assert!(source.exists());
2172 }
2173
2174 #[test]
2177 fn empty_final_component_refuses_the_move() {
2178 let db = test_db();
2179 let tmp = TempDir::new().unwrap();
2180 let first = tmp.path().join("src/a.flac");
2181 let second = tmp.path().join("src/b.flac");
2182 add_track(&db, &first, "Airbag", 1);
2183 add_track(&db, &second, "Karma Police", 2);
2184
2185 let result = execute(
2187 &db,
2188 "%album artist%/%album%/[%nonexistent field%]",
2189 Some(tmp.path()),
2190 )
2191 .unwrap();
2192
2193 assert_eq!(result.moved_count(), 0);
2194 assert_eq!(result.failures().count(), 2);
2195 assert!(first.exists());
2196 assert!(second.exists());
2197 assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2198 }
2199
2200 #[test]
2201 fn long_title_is_truncated_rather_than_failing() {
2202 let db = test_db();
2203 let tmp = TempDir::new().unwrap();
2204 let source = tmp.path().join("src/test.flac");
2205 let title = "a".repeat(300);
2206 add_track(&db, &source, &title, 1);
2207
2208 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2209 assert_eq!(
2210 result.moved_count(),
2211 1,
2212 "errors: {:?}",
2213 result.failure_messages()
2214 );
2215 let name = result
2216 .moves()
2217 .next()
2218 .unwrap()
2219 .dest()
2220 .file_name()
2221 .unwrap()
2222 .to_string_lossy();
2223 assert!(name.len() <= MAX_FILE_NAME_BYTES);
2224 assert!(name.ends_with(".flac"));
2225 assert!(result.moves().next().unwrap().dest().exists());
2226 }
2227
2228 #[test]
2231 fn remove_empty_dirs_never_climbs_past_a_floor() {
2232 let tmp = TempDir::new().unwrap();
2233 let root = tmp.path().join("library");
2234 let nested = root.join("artist/album");
2235 std::fs::create_dir_all(&nested).unwrap();
2236
2237 remove_empty_dirs(&nested, std::slice::from_ref(&root));
2238
2239 assert!(!nested.exists());
2240 assert!(!root.join("artist").exists());
2241 assert!(root.exists(), "the library root must survive");
2242 }
2243
2244 #[test]
2245 fn remove_empty_dirs_stays_put_outside_any_floor() {
2246 let tmp = TempDir::new().unwrap();
2247 let outside = tmp.path().join("incoming/rip");
2248 std::fs::create_dir_all(&outside).unwrap();
2249
2250 remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2251
2252 assert!(!outside.exists());
2253 assert!(
2254 tmp.path().join("incoming").exists(),
2255 "no floor means no climbing"
2256 );
2257 }
2258
2259 #[test]
2260 fn remove_empty_dirs_never_removes_a_floor_itself() {
2261 let tmp = TempDir::new().unwrap();
2262 let root = tmp.path().join("library");
2263 std::fs::create_dir_all(&root).unwrap();
2264
2265 remove_empty_dirs(&root, std::slice::from_ref(&root));
2266
2267 assert!(root.exists());
2268 }
2269
2270 #[test]
2273 fn undo_refuses_when_the_original_path_is_occupied() {
2274 let db = test_db();
2275 let tmp = TempDir::new().unwrap();
2276 let source = tmp.path().join("src/test.flac");
2277 add_track(&db, &source, "Airbag", 1);
2278
2279 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2280 let dest = result.moves().next().unwrap().dest().to_path_buf();
2281
2282 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2284 std::fs::write(&source, b"a completely different rip").unwrap();
2285
2286 let undone = undo(&db).unwrap();
2287 assert_eq!(undone.restored, 0);
2288 assert_eq!(undone.errors.len(), 1);
2289 assert_eq!(
2290 std::fs::read(&source).unwrap(),
2291 b"a completely different rip".to_vec()
2292 );
2293 assert!(dest.exists());
2294 assert_eq!(log_rows(&db).len(), 1);
2296 }
2297
2298 #[test]
2299 fn undo_refuses_when_the_moved_file_has_been_replaced() {
2300 let db = test_db();
2301 let tmp = TempDir::new().unwrap();
2302 let source = tmp.path().join("src/test.flac");
2303 add_track(&db, &source, "Airbag", 1);
2304
2305 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2306 let dest = result.moves().next().unwrap().dest().to_path_buf();
2307 std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2308
2309 let undone = undo(&db).unwrap();
2310 assert_eq!(undone.restored, 0);
2311 assert_eq!(undone.errors.len(), 1);
2312 assert!(!source.exists());
2313 assert!(dest.exists());
2314 }
2315
2316 #[test]
2318 fn undo_takes_the_newest_batch_when_timestamps_tie() {
2319 let db = test_db();
2320 let tmp = TempDir::new().unwrap();
2321 let older = tmp.path().join("older.flac");
2322 let newer = tmp.path().join("newer.flac");
2323 std::fs::write(&older, b"older").unwrap();
2324 std::fs::write(&newer, b"newer").unwrap();
2325 let moved_older = tmp.path().join("moved-older.flac");
2326 let moved_newer = tmp.path().join("moved-newer.flac");
2327 std::fs::rename(&older, &moved_older).unwrap();
2328 std::fs::rename(&newer, &moved_newer).unwrap();
2329
2330 for (batch, from, to) in [
2331 ("batch-1", &older, &moved_older),
2332 ("batch-2", &newer, &moved_newer),
2333 ] {
2334 db.conn
2335 .execute(
2336 "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2337 VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2338 params![
2339 batch,
2340 from.to_string_lossy().as_ref(),
2341 to.to_string_lossy().as_ref()
2342 ],
2343 )
2344 .unwrap();
2345 }
2346
2347 let undone = undo(&db).unwrap();
2348 assert_eq!(undone.restored, 1);
2349 assert!(newer.exists(), "the newest batch is the one undone");
2350 assert!(!older.exists());
2351 }
2352
2353 #[test]
2356 fn favourites_and_queue_state_follow_the_move() {
2357 let db = test_db();
2358 let tmp = TempDir::new().unwrap();
2359 let source = tmp.path().join("src/test.flac");
2360 add_track(&db, &source, "Airbag", 1);
2361 let source_str = source.to_string_lossy().into_owned();
2362
2363 queries::add_favourite(&db.conn, &source).unwrap();
2364 let item = PersistedQueueItem {
2365 path: source_str.clone(),
2366 title: "Airbag".into(),
2367 artist: "Radiohead".into(),
2368 album_artist: "Radiohead".into(),
2369 album: "OK Computer".into(),
2370 year: None,
2371 codec: None,
2372 track_number: Some(1),
2373 disc: Some(1),
2374 duration_ms: None,
2375 db_id: None,
2376 };
2377 queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
2378 .unwrap();
2379
2380 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2381 let dest = result.moves().next().unwrap().dest().to_path_buf();
2382 let dest_str = dest.to_string_lossy().into_owned();
2383
2384 let favourites = queries::load_favourites(&db.conn).unwrap();
2385 assert!(favourites.contains(&dest));
2386 assert!(!favourites.contains(&source));
2387
2388 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2389 assert_eq!(state.items[0].path, dest_str);
2390 assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2391
2392 assert_eq!(undo(&db).unwrap().restored, 1);
2393
2394 let favourites = queries::load_favourites(&db.conn).unwrap();
2395 assert!(favourites.contains(&source));
2396 assert!(!favourites.contains(&dest));
2397 let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2398 assert_eq!(state.items[0].path, source_str);
2399 assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2400 }
2401
2402 #[test]
2403 fn scan_cache_follows_the_move() {
2404 let db = test_db();
2405 let tmp = TempDir::new().unwrap();
2406 let source = tmp.path().join("src/test.flac");
2407 let id = add_track(&db, &source, "Airbag", 1);
2408 db.conn
2409 .execute(
2410 "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2411 params![source.to_string_lossy().as_ref(), id],
2412 )
2413 .unwrap();
2414
2415 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2416 let dest = result
2417 .moves()
2418 .next()
2419 .unwrap()
2420 .dest()
2421 .to_string_lossy()
2422 .into_owned();
2423
2424 let cached: String = db
2425 .conn
2426 .query_row(
2427 "SELECT path FROM scan_cache WHERE track_id = ?1",
2428 params![id],
2429 |r| r.get(0),
2430 )
2431 .unwrap();
2432 assert_eq!(cached, dest);
2433 }
2434
2435 #[test]
2438 fn partial_failure_leaves_the_database_and_result_consistent() {
2439 let db = test_db();
2440 let tmp = TempDir::new().unwrap();
2441 let first = tmp.path().join("src/a.flac");
2442 let clash = tmp.path().join("src/b.flac");
2443 let third = tmp.path().join("src/c.flac");
2444 let first_id = add_track(&db, &first, "Airbag", 1);
2445 let clash_id = add_track(&db, &clash, "Airbag", 2);
2446 let third_id = add_track(&db, &third, "Karma Police", 3);
2447
2448 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2449
2450 assert_eq!(result.moved_count(), 2);
2451 assert_eq!(result.failures().count(), 1);
2452
2453 let logged = log_rows(&db);
2454 assert_eq!(logged.len(), 2);
2455 for file_move in result.moves() {
2456 assert!(file_move.dest().exists());
2457 assert!(
2458 logged
2459 .iter()
2460 .any(|(_, _, to)| Path::new(to) == file_move.dest())
2461 );
2462 }
2463
2464 assert!(clash.exists());
2466 assert_eq!(
2467 db_path_of(&db, clash_id).as_deref(),
2468 Some(clash.to_str().unwrap())
2469 );
2470 assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2471 assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2472 }
2473
2474 #[test]
2477 fn unknown_paths_are_logged_and_undoable() {
2478 let db = test_db();
2479 let tmp = TempDir::new().unwrap();
2480 let known = tmp.path().join("src/known.flac");
2481 add_track(&db, &known, "Airbag", 1);
2482
2483 let result = run(
2484 &db,
2485 Selection::Paths(std::slice::from_ref(&known)),
2486 "%album artist%/%album%/%title%",
2487 tmp.path(),
2488 )
2489 .unwrap();
2490
2491 assert_eq!(result.moved_count(), 1);
2492 let logged = log_rows(&db);
2493 assert_eq!(logged.len(), 1);
2494 assert!(logged[0].0.is_some());
2495
2496 assert_eq!(undo(&db).unwrap().restored, 1);
2497 assert!(known.exists());
2498 }
2499
2500 #[test]
2501 fn ancillary_files_move_with_the_album() {
2502 let db = test_db();
2503 let tmp = TempDir::new().unwrap();
2504 let source = tmp.path().join("src/test.flac");
2505 add_track(&db, &source, "Airbag", 1);
2506 std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2507
2508 let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2509 assert_eq!(result.moved_count(), 1);
2510 let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2511 assert!(dest_dir.join("cover.jpg").exists());
2512
2513 assert_eq!(log_rows(&db).len(), 2);
2515 assert_eq!(undo(&db).unwrap().restored, 2);
2516 assert!(source.parent().unwrap().join("cover.jpg").exists());
2517 }
2518
2519 #[test]
2522 fn extension_not_clobbered_by_dots_in_title() {
2523 let db = test_db();
2526 let tmp = TempDir::new().unwrap();
2527 let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2528 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2529 std::fs::write(&source, b"fake").unwrap();
2530
2531 let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2532 meta.track_number = Some(10);
2533 meta.date = Some("2025-11-21".into());
2534 meta.path = Some(source.to_string_lossy().into_owned());
2535 queries::upsert_track(&db.conn, &meta).unwrap();
2536
2537 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2538 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2539 assert_eq!(result.moved_count(), 1);
2540 assert_eq!(
2541 result
2542 .moves()
2543 .next()
2544 .unwrap()
2545 .dest()
2546 .file_name()
2547 .unwrap()
2548 .to_string_lossy(),
2549 "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2550 );
2551 }
2552
2553 #[test]
2554 fn extension_preserved_for_tracknumber_dot() {
2555 let db = test_db();
2557 let tmp = TempDir::new().unwrap();
2558 let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2559 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2560 std::fs::write(&source, b"fake").unwrap();
2561
2562 let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2563 meta.track_number = Some(11);
2564 meta.date = Some("2025-11-21".into());
2565 meta.path = Some(source.to_string_lossy().into_owned());
2566 queries::upsert_track(&db.conn, &meta).unwrap();
2567
2568 let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2569 let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2570 assert_eq!(result.moved_count(), 1);
2571 assert_eq!(
2572 result
2573 .moves()
2574 .next()
2575 .unwrap()
2576 .dest()
2577 .file_name()
2578 .unwrap()
2579 .to_string_lossy(),
2580 "0111. Bicep - CHROMA 012 TANGZ II.flac"
2581 );
2582 }
2583}