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