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