Skip to main content

koan_core/
organize.rs

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
13/// Ancillary file patterns we move alongside audio files.
14const 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
26/// Byte ceiling for a destination file name, extension included. Filesystems we
27/// target cap a single name at 255 bytes.
28const MAX_FILE_NAME_BYTES: usize = 250;
29
30#[derive(Debug, thiserror::Error)]
31pub enum OrganizeError {
32    #[error("database error: {0}")]
33    Db(#[from] DbError),
34    #[error("sqlite error: {0}")]
35    Sqlite(#[from] rusqlite::Error),
36    #[error("format error: {0}")]
37    Format(#[from] FormatError),
38    #[error("io error: {0}")]
39    Io(#[from] std::io::Error),
40    #[error("no tracks with local paths found")]
41    NoLocalTracks,
42    #[error("no destination folder: add a library folder, or pass --base-dir")]
43    NoDestination,
44    #[error("no organize batches to undo")]
45    NothingToUndo,
46    #[error("destination already exists: {0}")]
47    DestinationExists(PathBuf),
48    #[error("copied {copied} of {expected} bytes from {path}")]
49    ShortCopy {
50        path: PathBuf,
51        expected: u64,
52        copied: u64,
53    },
54    #[error("not enough free space: {needed} bytes needed, {available} available")]
55    NotEnoughSpace { needed: u64, available: u64 },
56}
57
58/// What the pattern means for one file.
59///
60/// Conflicts are an outcome rather than an error off to one side: "this would
61/// overwrite something" is the single most important thing a preview can tell
62/// you, and it belongs on the row it concerns, next to the destination it would
63/// have landed on.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum PlanOutcome {
66    /// The file will be moved, or was.
67    Move,
68    /// Already exactly where the pattern puts it. Nothing to do.
69    Unchanged,
70    /// Something holds the destination — a file already there, or another file
71    /// in the same run that claimed it first. Nothing is ever overwritten, so
72    /// this file stays where it is.
73    Conflict(String),
74    /// The pattern produced nothing usable for this file, or the move failed.
75    Error(String),
76}
77
78impl PlanOutcome {
79    /// The reason a file isn't moving, for anything rendering it as text.
80    pub fn reason(&self) -> Option<&str> {
81        match self {
82            Self::Conflict(reason) | Self::Error(reason) => Some(reason),
83            _ => None,
84        }
85    }
86}
87
88/// One file's place in a plan: where it is, where the pattern puts it, and
89/// whether that can happen.
90#[derive(Debug, Clone)]
91pub struct PlanEntry {
92    /// The library track this file belongs to, or `None` for a file the library
93    /// doesn't know about. Either way the move is logged and can be undone.
94    pub track_id: Option<i64>,
95    pub from: PathBuf,
96    /// Where the pattern puts it. `None` only when the pattern failed before it
97    /// produced a path at all.
98    pub to: Option<PathBuf>,
99    pub ancillary: Vec<(PathBuf, PathBuf)>,
100    pub outcome: PlanOutcome,
101}
102
103impl PlanEntry {
104    /// Where this file is headed. Only call it on an entry that has a
105    /// destination — a plan that failed before producing one has none.
106    #[cfg(test)]
107    fn dest(&self) -> &Path {
108        self.to.as_deref().expect("plan entry has no destination")
109    }
110
111    /// The executable move this entry stands for, if it is one.
112    fn as_move(&self) -> Option<FileMove> {
113        match (&self.outcome, &self.to) {
114            (PlanOutcome::Move, Some(to)) => Some(FileMove {
115                track_id: self.track_id,
116                from: self.from.clone(),
117                to: to.clone(),
118                ancillary: self.ancillary.clone(),
119            }),
120            _ => None,
121        }
122    }
123}
124
125/// Every selected file and what happens to it, in the order it was planned.
126///
127/// One ordered list rather than separate buckets: a preview is a table with a
128/// row per file, and splitting the failures out of it loses both their place in
129/// the run and the destination they were headed for.
130#[derive(Debug, Default)]
131pub struct OrganizeResult {
132    pub entries: Vec<PlanEntry>,
133}
134
135impl OrganizeResult {
136    pub fn moves(&self) -> impl Iterator<Item = &PlanEntry> {
137        self.entries
138            .iter()
139            .filter(|e| e.outcome == PlanOutcome::Move)
140    }
141
142    pub fn moved_count(&self) -> usize {
143        self.moves().count()
144    }
145
146    /// Files already where the pattern puts them.
147    pub fn unchanged_count(&self) -> usize {
148        self.entries
149            .iter()
150            .filter(|e| e.outcome == PlanOutcome::Unchanged)
151            .count()
152    }
153
154    pub fn conflicts(&self) -> impl Iterator<Item = &PlanEntry> {
155        self.entries
156            .iter()
157            .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_)))
158    }
159
160    /// Everything that isn't happening and isn't already right — conflicts and
161    /// errors together, since a caller reporting failures wants both.
162    pub fn failures(&self) -> impl Iterator<Item = &PlanEntry> {
163        self.entries
164            .iter()
165            .filter(|e| matches!(e.outcome, PlanOutcome::Conflict(_) | PlanOutcome::Error(_)))
166    }
167
168    /// One line per failure, for callers that render a flat list. Includes the
169    /// destination where there was one — for a conflict that is the whole point.
170    pub fn failure_messages(&self) -> Vec<String> {
171        self.failures()
172            .map(|e| {
173                let reason = e.outcome.reason().unwrap_or("unknown");
174                match &e.to {
175                    Some(to) => format!("{}: {reason} ({})", e.from.display(), to.display()),
176                    None => format!("{}: {reason}", e.from.display()),
177                }
178            })
179            .collect()
180    }
181}
182
183/// An executable move, extracted from a plan entry that can proceed.
184#[derive(Debug)]
185pub struct FileMove {
186    pub track_id: Option<i64>,
187    pub from: PathBuf,
188    pub to: PathBuf,
189    pub ancillary: Vec<(PathBuf, PathBuf)>,
190}
191
192/// One `organize_log` row: id, original path, moved-to path, and the size and
193/// modification time the file had when it was moved.
194type UndoEntry = (i64, String, String, Option<i64>, Option<i64>);
195
196#[derive(Debug, Default)]
197pub struct UndoResult {
198    pub restored: usize,
199    pub errors: Vec<(PathBuf, String)>,
200}
201
202/// Which files an organize run covers.
203enum Selection<'a> {
204    All,
205    TrackIds(&'a [i64]),
206    Paths(&'a [PathBuf]),
207}
208
209/// Album fields a track inherits: both come from the album row, not the track row.
210#[derive(Default, Clone)]
211struct AlbumFacts {
212    date: Option<String>,
213    label: Option<String>,
214}
215
216/// A source file with the metadata its destination will be built from.
217struct ResolvedTrack {
218    source: PathBuf,
219    track_id: Option<i64>,
220    metadata: Result<TrackMetadata, String>,
221}
222
223/// Metadata provider backed by a HashMap, for evaluating format strings against track data.
224struct TrackMetadata {
225    fields: HashMap<String, String>,
226}
227
228impl TrackMetadata {
229    fn from_track_row(track: &TrackRow, album: &AlbumFacts) -> Self {
230        let mut fields = HashMap::new();
231        // Sanitize all field values so they can't inject path separators or illegal chars.
232        let s = sanitise_filename;
233        fields.insert("title".into(), s(&track.title));
234        fields.insert("artist".into(), s(&track.artist_name));
235        fields.insert("album artist".into(), s(&track.album_artist_name));
236        fields.insert("album".into(), s(&track.album_title));
237        if let Some(n) = track.track_number {
238            fields.insert("tracknumber".into(), format!("{n:02}"));
239        }
240        if let Some(d) = track.disc {
241            fields.insert("discnumber".into(), d.to_string());
242        }
243        if let Some(ref date) = album.date {
244            fields.insert("date".into(), s(date));
245        }
246        if let Some(ref label) = album.label {
247            fields.insert("label".into(), s(label));
248        }
249        if let Some(ref codec) = track.codec {
250            fields.insert("codec".into(), s(codec));
251        }
252        if let Some(ref genre) = track.genre {
253            fields.insert("genre".into(), s(genre));
254        }
255        Self { fields }
256    }
257
258    /// Build metadata directly from file tags, for files the library doesn't know about.
259    /// Populates exactly the same field set as `from_track_row` so a preview and the
260    /// move it authorises can never resolve to different paths.
261    fn from_file_meta(meta: &queries::TrackMeta) -> Self {
262        let mut fields = HashMap::new();
263        let s = sanitise_filename;
264        fields.insert("title".into(), s(&meta.title));
265        fields.insert("artist".into(), s(&meta.artist));
266        fields.insert(
267            "album artist".into(),
268            s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
269        );
270        fields.insert("album".into(), s(&meta.album));
271        if let Some(n) = meta.track_number {
272            fields.insert("tracknumber".into(), format!("{n:02}"));
273        }
274        if let Some(d) = meta.disc {
275            fields.insert("discnumber".into(), d.to_string());
276        }
277        if let Some(ref date) = meta.date {
278            fields.insert("date".into(), s(date));
279        }
280        if let Some(ref label) = meta.label {
281            fields.insert("label".into(), s(label));
282        }
283        if let Some(ref codec) = meta.codec {
284            fields.insert("codec".into(), s(codec));
285        }
286        if let Some(ref genre) = meta.genre {
287            fields.insert("genre".into(), s(genre));
288        }
289        Self { fields }
290    }
291}
292
293impl MetadataProvider for TrackMetadata {
294    fn get_field(&self, name: &str) -> Option<String> {
295        self.fields.get(name).cloned()
296    }
297}
298
299/// Sanitize each component of a relative path independently.
300///
301/// An empty, `.` or `..` component is an error, not something to skip: dropping one
302/// silently collapses a whole album onto a single filename, and the tracks that land
303/// there overwrite each other.
304fn sanitize_relative_path(rel: &str) -> Result<PathBuf, String> {
305    let mut result = PathBuf::new();
306    for part in rel.split(['/', std::path::MAIN_SEPARATOR]) {
307        let sanitized = sanitise_filename(part);
308        if sanitized.is_empty() {
309            return Err(format!(
310                "format string produced an empty path component: {rel:?}"
311            ));
312        }
313        if sanitized == "." || sanitized == ".." {
314            return Err(format!(
315                "format string produced a relative path component: {rel:?}"
316            ));
317        }
318        result.push(sanitized);
319    }
320    if result.as_os_str().is_empty() {
321        return Err("format string produced an empty path".into());
322    }
323    Ok(result)
324}
325
326/// Load every album's date and label in one query — both are fields a format string
327/// can reference, and both live on the album row.
328fn load_album_facts(conn: &Connection) -> Result<HashMap<i64, AlbumFacts>, OrganizeError> {
329    let mut stmt = conn.prepare("SELECT id, date, label FROM albums")?;
330    let rows = stmt.query_map([], |row| {
331        Ok((
332            row.get::<_, i64>(0)?,
333            AlbumFacts {
334                date: row.get(1)?,
335                label: row.get(2)?,
336            },
337        ))
338    })?;
339    let mut map = HashMap::new();
340    for row in rows {
341        let (id, facts) = row?;
342        map.insert(id, facts);
343    }
344    Ok(map)
345}
346
347/// Find ancillary files in the same directory as a track.
348fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
349    let mut files = Vec::new();
350    let Ok(entries) = std::fs::read_dir(track_dir) else {
351        return files;
352    };
353
354    for entry in entries.flatten() {
355        let path = entry.path();
356        if !path.is_file() {
357            continue;
358        }
359        let name = path
360            .file_name()
361            .and_then(|n| n.to_str())
362            .unwrap_or_default()
363            .to_lowercase();
364
365        // Check exact name matches.
366        if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
367            files.push(path);
368            continue;
369        }
370        // Check extension matches.
371        if let Some(ext) = path.extension().and_then(|e| e.to_str())
372            && ANCILLARY_EXTENSIONS
373                .iter()
374                .any(|e| ext.eq_ignore_ascii_case(e))
375        {
376            files.push(path);
377        }
378    }
379    files.sort();
380    files
381}
382
383/// The destination names a run has already committed to, so two files can never be
384/// planned onto the same path.
385#[derive(Default)]
386struct DestinationLedger {
387    taken: HashSet<String>,
388}
389
390impl DestinationLedger {
391    /// macOS, iOS and Windows filesystems are case-insensitive by default, so
392    /// `Rain.flac` and `RAIN.flac` are one file there and must collide here too.
393    fn key(path: &Path) -> String {
394        let key = path.to_string_lossy().into_owned();
395        if cfg!(any(
396            target_os = "macos",
397            target_os = "ios",
398            target_os = "windows"
399        )) {
400            key.to_lowercase()
401        } else {
402            key
403        }
404    }
405
406    /// Returns false if this destination is already spoken for.
407    fn claim(&mut self, path: &Path) -> bool {
408        self.taken.insert(Self::key(path))
409    }
410}
411
412/// Plan one file: format the pattern, sanitize it into a path, and decide
413/// whether the file can actually go there.
414///
415/// Always yields an entry. A file that can't move is still a row in the plan,
416/// carrying the destination it was headed for and why it isn't going.
417fn plan_single_move(
418    source: &Path,
419    track_id: Option<i64>,
420    metadata: &TrackMetadata,
421    pattern: &str,
422    base_dir: &Path,
423    dests: &mut DestinationLedger,
424) -> PlanEntry {
425    let entry = |to: Option<PathBuf>, outcome: PlanOutcome| PlanEntry {
426        track_id,
427        from: source.to_path_buf(),
428        to,
429        ancillary: Vec::new(),
430        outcome,
431    };
432    // Everything before a destination exists is an error with nothing to point at.
433    macro_rules! bail {
434        ($reason:expr) => {
435            return entry(None, PlanOutcome::Error($reason))
436        };
437    }
438
439    let relative = match format::format(pattern, metadata) {
440        Ok(r) => r,
441        Err(e) => bail!(format!("format error: {e}")),
442    };
443
444    if relative.is_empty() {
445        bail!("format string produced empty path".to_string());
446    }
447
448    let sanitized = match sanitize_relative_path(&relative) {
449        Ok(p) => p,
450        Err(e) => bail!(e),
451    };
452
453    // Preserve the original file extension.
454    // Don't use with_extension() — it replaces after the LAST dot, which
455    // destroys titles containing dots (e.g. "0111. Bicep - TANGZ II" → "0111.flac").
456    let ext = source
457        .extension()
458        .and_then(|e| e.to_str())
459        .unwrap_or("flac");
460    let Some(stem) = sanitized.file_name().and_then(|n| n.to_str()) else {
461        bail!("format string produced an unusable file name".to_string());
462    };
463    // Leave room for the extension, so a long title is shortened rather than
464    // previewing cleanly and failing with ENAMETOOLONG at move time.
465    let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
466    if stem.is_empty() {
467        bail!("format string produced an empty file name".to_string());
468    }
469    let mut dest = base_dir.to_path_buf();
470    if let Some(parent) = sanitized.parent() {
471        dest.push(parent);
472    }
473    dest.push(format!("{stem}.{ext}"));
474
475    // Safety: verify dest stays under base_dir (defense-in-depth against path traversal).
476    if !dest.starts_with(base_dir) {
477        let reason = format!(
478            "path traversal blocked: destination {} escapes base dir {}",
479            dest.display(),
480            base_dir.display()
481        );
482        return entry(Some(dest), PlanOutcome::Error(reason));
483    }
484
485    if source == dest {
486        // Already in place — claim the name anyway so nothing else targets it.
487        dests.claim(&dest);
488        return entry(Some(dest), PlanOutcome::Unchanged);
489    }
490
491    if !dests.claim(&dest) {
492        return entry(
493            Some(dest),
494            PlanOutcome::Conflict("another file in this run is already going here".into()),
495        );
496    }
497
498    // Whether something is *already* sitting at the destination is a question
499    // for the filesystem, and this function deliberately does not ask one —
500    // see `check_against_disk`.
501    PlanEntry {
502        track_id,
503        from: source.to_path_buf(),
504        to: Some(dest),
505        ancillary: Vec::new(),
506        outcome: PlanOutcome::Move,
507    }
508}
509
510/// Ask the filesystem the two questions formatting cannot answer: whether a
511/// destination is already occupied, and what ancillary files travel with each
512/// move.
513///
514/// Separate from planning because it is the only part that touches the disk. A
515/// preview that reruns on every keystroke wants the pure half immediately and
516/// this afterwards; an execute wants both before it moves anything.
517pub fn check_against_disk(result: &mut OrganizeResult, move_ancillary: bool) {
518    let mut dests = DestinationLedger::default();
519    let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
520    // One directory read per source folder. An album is one folder and a dozen
521    // tracks, so doing this per file repeated the same readdir a dozen times.
522    let mut ancillary_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
523
524    for entry in &mut result.entries {
525        let (PlanOutcome::Move, Some(dest)) = (&entry.outcome, entry.to.clone()) else {
526            continue;
527        };
528
529        // A destination that resolves to the source itself is a case-only
530        // rename, which is a real move; anything else already there would be
531        // overwritten.
532        if dest.exists() && !paths_equal(&entry.from, &dest) {
533            entry.outcome =
534                PlanOutcome::Conflict("a file is already here — it would be overwritten".into());
535            continue;
536        }
537        dests.claim(&dest);
538
539        if !move_ancillary {
540            continue;
541        }
542        let source_dir = entry.from.parent().unwrap_or(Path::new("."));
543        let dest_dir = dest.parent().unwrap_or(Path::new("."));
544        if source_dir == dest_dir {
545            continue;
546        }
547        let candidates = ancillary_by_dir
548            .entry(source_dir.to_path_buf())
549            .or_insert_with(|| find_ancillary_files(source_dir))
550            .clone();
551        for anc_path in candidates {
552            if planned_ancillary.contains(&anc_path) {
553                continue;
554            }
555            let Some(anc_name) = anc_path.file_name() else {
556                continue;
557            };
558            let anc_dest = dest_dir.join(anc_name);
559            // Artwork already at the destination is left alone rather than
560            // overwritten; the audio file is what matters here.
561            if anc_dest.exists() || !dests.claim(&anc_dest) {
562                continue;
563            }
564            planned_ancillary.insert(anc_path.clone());
565            entry.ancillary.push((anc_path, anc_dest));
566        }
567    }
568}
569
570fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
571    let fallback = AlbumFacts::default();
572    rows.into_iter()
573        .filter_map(|track| {
574            let source = PathBuf::from(track.path.as_ref()?);
575            if !source.exists() {
576                return None; // file gone, skip
577            }
578            let facts = track
579                .album_id
580                .and_then(|id| albums.get(&id))
581                .unwrap_or(&fallback);
582            Some(ResolvedTrack {
583                source,
584                track_id: Some(track.id),
585                metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
586            })
587        })
588        .collect()
589}
590
591fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
592    if !source.exists() {
593        return Err("file not found".to_string());
594    }
595    crate::index::metadata::read_metadata(source)
596        .map(|m| TrackMetadata::from_file_meta(&m))
597        .map_err(|e| format!("metadata error: {e}"))
598}
599
600/// Resolve arbitrary paths: library rows where we have them, file tags otherwise.
601/// Preview and execute both come through here, so both see the same metadata.
602fn resolve_from_paths(
603    db: &Database,
604    paths: &[PathBuf],
605    albums: &HashMap<i64, AlbumFacts>,
606) -> Result<Vec<ResolvedTrack>, OrganizeError> {
607    use rayon::prelude::*;
608
609    let path_strings: Vec<String> = paths
610        .iter()
611        .map(|p| p.to_string_lossy().into_owned())
612        .collect();
613    let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
614
615    // Tag reads are the expensive part, so only the unknown files pay for them.
616    let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
617        .par_iter()
618        .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
619        .map(|p| (p.clone(), read_tag_metadata(p)))
620        .collect();
621
622    let fallback = AlbumFacts::default();
623    let mut resolved = Vec::with_capacity(paths.len());
624    for (path, path_str) in paths.iter().zip(&path_strings) {
625        let entry = match known.get(path_str) {
626            Some(track) => {
627                let facts = track
628                    .album_id
629                    .and_then(|id| albums.get(&id))
630                    .unwrap_or(&fallback);
631                ResolvedTrack {
632                    source: path.clone(),
633                    track_id: Some(track.id),
634                    metadata: Ok(TrackMetadata::from_track_row(track, facts)),
635                }
636            }
637            None => ResolvedTrack {
638                source: path.clone(),
639                track_id: None,
640                metadata: tagged
641                    .remove(path)
642                    .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
643            },
644        };
645        resolved.push(entry);
646    }
647    Ok(resolved)
648}
649
650/// A selection with every read already done: library rows, album facts, and
651/// tags for files the library has never seen.
652///
653/// This is the half that costs something. Generating destinations from it is
654/// pure string work, so a preview that reruns as a pattern is typed resolves
655/// once here and formats many times against the result.
656pub struct ResolvedSelection {
657    tracks: Vec<ResolvedTrack>,
658}
659
660impl ResolvedSelection {
661    /// How many files resolved to something with a local path. Fewer than were
662    /// asked for means the rest are remote-only or gone from disk.
663    pub fn len(&self) -> usize {
664        self.tracks.len()
665    }
666
667    pub fn is_empty(&self) -> bool {
668        self.tracks.is_empty()
669    }
670}
671
672/// Read a selection out of the library. `track_ids` of `None` means all of it.
673///
674/// Database reads and a `stat` per file, so it belongs off whatever thread is
675/// drawing — but it only has to happen once per selection.
676pub fn resolve(
677    db: &Database,
678    track_ids: Option<&[i64]>,
679) -> Result<ResolvedSelection, OrganizeError> {
680    let selection = match track_ids {
681        Some(ids) => Selection::TrackIds(ids),
682        None => Selection::All,
683    };
684    resolve_selection(db, selection)
685}
686
687/// Read a selection of file paths, which may or may not be in the library.
688/// Unknown files pay for a tag read; known ones come from their row.
689pub fn resolve_paths(db: &Database, paths: &[PathBuf]) -> Result<ResolvedSelection, OrganizeError> {
690    resolve_selection(db, Selection::Paths(paths))
691}
692
693fn resolve_selection(
694    db: &Database,
695    selection: Selection<'_>,
696) -> Result<ResolvedSelection, OrganizeError> {
697    let albums = load_album_facts(&db.conn)?;
698    let tracks = match selection {
699        Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
700        Selection::TrackIds(ids) => {
701            let mut rows = Vec::with_capacity(ids.len());
702            for &id in ids {
703                if let Some(row) = queries::get_track_row(&db.conn, id)? {
704                    rows.push(row);
705                }
706            }
707            resolve_from_rows(rows, &albums)
708        }
709        Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
710    };
711    Ok(ResolvedSelection { tracks })
712}
713
714/// Turn a pattern into destinations. **Touches no files at all.**
715///
716/// Everything here is formatting the pattern, sanitising what it produced, and
717/// checking the result against the destinations this same run has already
718/// claimed. That is fast enough to run on every keystroke, which is the whole
719/// reason it is separate from `check_against_disk`.
720pub fn generate(selection: &ResolvedSelection, pattern: &str, base_dir: &Path) -> OrganizeResult {
721    let mut entries = Vec::with_capacity(selection.tracks.len());
722    let mut dests = DestinationLedger::default();
723
724    for track in &selection.tracks {
725        let metadata = match &track.metadata {
726            Ok(m) => m,
727            Err(msg) => {
728                entries.push(PlanEntry {
729                    track_id: track.track_id,
730                    from: track.source.clone(),
731                    to: None,
732                    ancillary: Vec::new(),
733                    outcome: PlanOutcome::Error(msg.clone()),
734                });
735                continue;
736            }
737        };
738        entries.push(plan_single_move(
739            &track.source,
740            track.track_id,
741            metadata,
742            pattern,
743            base_dir,
744            &mut dests,
745        ));
746    }
747
748    OrganizeResult { entries }
749}
750
751/// Resolve, generate, and optionally ask the disk. Every entry point plans
752/// through here, so a preview and the execute that follows it produce the same
753/// destinations from the same metadata.
754fn plan(
755    db: &Database,
756    selection: Selection<'_>,
757    pattern: &str,
758    base_dir: &Path,
759    check_disk: bool,
760) -> Result<OrganizeResult, OrganizeError> {
761    let resolved = resolve_selection(db, selection)?;
762    let mut result = generate(&resolved, pattern, base_dir);
763    if check_disk {
764        check_against_disk(&mut result, move_ancillary());
765    }
766    Ok(result)
767}
768
769/// Plan, then carry out the moves: each file's database rows and its rename land
770/// together or not at all.
771fn run(
772    db: &Database,
773    selection: Selection<'_>,
774    pattern: &str,
775    base_dir: &Path,
776) -> Result<OrganizeResult, OrganizeError> {
777    let mut result = plan(db, selection, pattern, base_dir, true)?;
778
779    let pending: Vec<FileMove> = result
780        .entries
781        .iter()
782        .filter_map(PlanEntry::as_move)
783        .collect();
784    if pending.is_empty() {
785        return Ok(result);
786    }
787
788    check_free_space(&pending, base_dir)?;
789
790    let batch_id = batch_id();
791    let floors = cleanup_floors(Some(base_dir));
792
793    // The plan is the report: a move that fails has its own row demoted to an
794    // error, so the caller sees the same table it confirmed, now saying what
795    // actually happened to each file.
796    for file_move in pending {
797        let failure = match execute_single_move(db, &file_move, &batch_id, &floors) {
798            Ok(()) => verify_move(&file_move).err(),
799            Err(e) => Some(e.to_string()),
800        };
801        let Some(reason) = failure else { continue };
802        log::warn!(
803            "organize: {} → {} failed: {reason}",
804            file_move.from.display(),
805            file_move.to.display()
806        );
807        if let Some(entry) = result.entries.iter_mut().find(|e| e.from == file_move.from) {
808            entry.outcome = PlanOutcome::Error(reason);
809        }
810    }
811
812    Ok(result)
813}
814
815/// Preview what would happen without moving files.
816///
817/// `check_disk` is what finds destinations that are already occupied and the
818/// ancillary files travelling with each move. It is a `stat` per file and a
819/// directory read per source folder, and it changes none of the destinations —
820/// so a preview that reruns as a pattern is typed leaves it off and fills it in
821/// afterwards.
822pub fn preview(
823    db: &Database,
824    pattern: &str,
825    base_dir: Option<&Path>,
826    check_disk: bool,
827) -> Result<OrganizeResult, OrganizeError> {
828    let base = resolve_base_dir(base_dir)?;
829    plan(db, Selection::All, pattern, &base, check_disk)
830}
831
832/// Execute the moves: rename files, update DB, log for undo.
833pub fn execute(
834    db: &Database,
835    pattern: &str,
836    base_dir: Option<&Path>,
837) -> Result<OrganizeResult, OrganizeError> {
838    let base = resolve_base_dir(base_dir)?;
839    run(db, Selection::All, pattern, &base)
840}
841
842/// Preview organize for a specific set of tracks.
843pub fn preview_for_tracks(
844    db: &Database,
845    track_ids: &[i64],
846    pattern: &str,
847    base_dir: Option<&Path>,
848    check_disk: bool,
849) -> Result<OrganizeResult, OrganizeError> {
850    let base = resolve_base_dir(base_dir)?;
851    plan(
852        db,
853        Selection::TrackIds(track_ids),
854        pattern,
855        &base,
856        check_disk,
857    )
858}
859
860/// Execute organize for a specific set of tracks.
861pub fn execute_for_tracks(
862    db: &Database,
863    track_ids: &[i64],
864    pattern: &str,
865    base_dir: Option<&Path>,
866) -> Result<OrganizeResult, OrganizeError> {
867    let base = resolve_base_dir(base_dir)?;
868    run(db, Selection::TrackIds(track_ids), pattern, &base)
869}
870
871/// Preview organize for file paths, which may or may not be in the library.
872pub fn preview_for_paths(
873    paths: &[PathBuf],
874    pattern: &str,
875    base_dir: Option<&Path>,
876    check_disk: bool,
877) -> Result<OrganizeResult, OrganizeError> {
878    let db = crate::db::pool::shared().get()?;
879    let base = resolve_base_dir(base_dir)?;
880    plan(&db, Selection::Paths(paths), pattern, &base, check_disk)
881}
882
883/// Execute organize for file paths. Requires the library database: without it there is
884/// nowhere to record the moves, and an organize that can't be undone isn't offered.
885pub fn execute_for_paths(
886    paths: &[PathBuf],
887    pattern: &str,
888    base_dir: Option<&Path>,
889) -> Result<OrganizeResult, OrganizeError> {
890    let db = crate::db::pool::shared().get()?;
891    let base = resolve_base_dir(base_dir)?;
892    run(&db, Selection::Paths(paths), pattern, &base)
893}
894
895/// Verify a move actually happened — dest exists and source is gone.
896fn verify_move(file_move: &FileMove) -> Result<(), String> {
897    if !file_move.to.exists() {
898        return Err(format!(
899            "destination not found after move: {}",
900            file_move.to.display()
901        ));
902    }
903    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
904        return Err(format!(
905            "source still exists after move: {}",
906            file_move.from.display()
907        ));
908    }
909    Ok(())
910}
911
912fn log_move(
913    conn: &Connection,
914    batch_id: &str,
915    track_id: Option<i64>,
916    from: &Path,
917    to: &Path,
918    size: Option<u64>,
919    mtime: Option<i64>,
920) -> Result<(), OrganizeError> {
921    conn.execute(
922        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
923         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
924        params![
925            batch_id,
926            track_id,
927            from.to_string_lossy().as_ref(),
928            to.to_string_lossy().as_ref(),
929            size.map(|s| s as i64),
930            mtime,
931        ],
932    )?;
933    Ok(())
934}
935
936/// Point every path-keyed row at the file's new location.
937///
938/// `tracks.path` and `scan_cache.path` are UNIQUE, so a move onto a path another row
939/// already claims fails here — inside the caller's transaction, before the file itself
940/// is touched.
941fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
942    let old_lossy = old.to_string_lossy();
943    let new_lossy = new.to_string_lossy();
944    let old_path = old_lossy.as_ref();
945    let new_path = new_lossy.as_ref();
946
947    conn.execute(
948        "UPDATE tracks SET path = ?1 WHERE path = ?2",
949        params![new_path, old_path],
950    )?;
951    conn.execute(
952        "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
953        params![new_path, old_path],
954    )?;
955    conn.execute(
956        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
957        params![new_path, old_path],
958    )?;
959    // The destination may already be starred from an earlier move; OR REPLACE
960    // leaves exactly one favourite row rather than failing on the primary key.
961    conn.execute(
962        "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
963        params![new_path, old_path],
964    )?;
965    conn.execute(
966        "UPDATE playback_state SET cursor_id = ?1 WHERE cursor_id = ?2",
967        params![new_path, old_path],
968    )?;
969    rewrite_queue_json(conn, old_path, new_path)?;
970    Ok(())
971}
972
973/// Rewrite paths inside the saved session's serialized queue.
974///
975/// Playlists need no equivalent: they point at library rows, and a row's path
976/// changing is a column this function has already updated.
977fn rewrite_queue_json(
978    conn: &Connection,
979    old_path: &str,
980    new_path: &str,
981) -> Result<(), OrganizeError> {
982    let mut stmt =
983        conn.prepare("SELECT id, queue_json FROM playback_state WHERE instr(queue_json, ?1) > 0")?;
984    let rows: Vec<(i64, String)> = stmt
985        .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
986        .collect::<Result<Vec<_>, _>>()?;
987    drop(stmt);
988
989    for (id, json) in rows {
990        let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
991            continue;
992        };
993        let mut changed = false;
994        for item in &mut items {
995            if item.path == old_path {
996                item.path = new_path.to_string();
997                changed = true;
998            }
999        }
1000        if !changed {
1001            continue;
1002        }
1003        let Ok(updated) = serde_json::to_string(&items) else {
1004            continue;
1005        };
1006        conn.execute(
1007            "UPDATE playback_state SET queue_json = ?1 WHERE id = ?2",
1008            params![updated, id],
1009        )?;
1010    }
1011    Ok(())
1012}
1013
1014/// Execute a single file move: write the database rows first, then move the file.
1015/// A constraint violation therefore aborts before anything on disk changes, and a
1016/// failed rename rolls the rows back.
1017fn execute_single_move(
1018    db: &Database,
1019    file_move: &FileMove,
1020    batch_id: &str,
1021    floors: &[PathBuf],
1022) -> Result<(), OrganizeError> {
1023    if let Some(parent) = file_move.to.parent() {
1024        std::fs::create_dir_all(parent)?;
1025    }
1026
1027    let source_meta = std::fs::metadata(&file_move.from)?;
1028    let size = source_meta.len();
1029    let mtime = mtime_secs(&source_meta);
1030
1031    let tx = db.conn.unchecked_transaction()?;
1032    log_move(
1033        &tx,
1034        batch_id,
1035        file_move.track_id,
1036        &file_move.from,
1037        &file_move.to,
1038        Some(size),
1039        mtime,
1040    )?;
1041    rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
1042
1043    // Dropping `tx` on the way out of this `?` rolls the rows back.
1044    move_file(&file_move.from, &file_move.to)?;
1045
1046    let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
1047    let mut failure = None;
1048    for (anc_from, anc_to) in &file_move.ancillary {
1049        if let Some(parent) = anc_to.parent()
1050            && std::fs::create_dir_all(parent).is_err()
1051        {
1052            continue;
1053        }
1054        // Best-effort — artwork that won't move doesn't hold up the audio file.
1055        match move_file(anc_from, anc_to) {
1056            Ok(()) => {
1057                moved_ancillary.push((anc_from, anc_to));
1058                let meta = std::fs::metadata(anc_to).ok();
1059                if let Err(e) = log_move(
1060                    &tx,
1061                    batch_id,
1062                    None,
1063                    anc_from,
1064                    anc_to,
1065                    meta.as_ref().map(|m| m.len()),
1066                    meta.as_ref().and_then(mtime_secs),
1067                ) {
1068                    failure = Some(e);
1069                    break;
1070                }
1071            }
1072            Err(e) => log::warn!(
1073                "failed to move ancillary file {}: {}",
1074                anc_from.display(),
1075                e
1076            ),
1077        }
1078    }
1079
1080    let outcome = match failure {
1081        Some(e) => Err(e),
1082        None => tx.commit().map_err(OrganizeError::from),
1083    };
1084
1085    if let Err(e) = outcome {
1086        // The rows rolled back, so nothing records these files as moved and nothing
1087        // could undo them. Put them back.
1088        for (anc_from, anc_to) in moved_ancillary {
1089            let _ = move_file(anc_to, anc_from);
1090        }
1091        let _ = move_file(&file_move.to, &file_move.from);
1092        return Err(e);
1093    }
1094
1095    if let Some(source_dir) = file_move.from.parent() {
1096        remove_empty_dirs(source_dir, floors);
1097    }
1098
1099    Ok(())
1100}
1101
1102/// Undo the most recent organize batch.
1103///
1104/// Each entry is restored only when the original path is still free and the moved file
1105/// is still the one that was logged. Anything else is reported and left in the log, so
1106/// a single blocked file doesn't strand the rest of the batch.
1107pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
1108    // Newest batch by primary key: created_at only has one-second resolution, so two
1109    // batches in the same second would tie.
1110    let batch_id: String = db
1111        .conn
1112        .query_row(
1113            "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
1114            [],
1115            |row| row.get(0),
1116        )
1117        .map_err(|_| OrganizeError::NothingToUndo)?;
1118
1119    let mut stmt = db.conn.prepare(
1120        "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
1121         WHERE batch_id = ?1 ORDER BY id DESC",
1122    )?;
1123
1124    let entries: Vec<UndoEntry> = stmt
1125        .query_map(params![batch_id], |row| {
1126            Ok((
1127                row.get(0)?,
1128                row.get(1)?,
1129                row.get(2)?,
1130                row.get(3)?,
1131                row.get(4)?,
1132            ))
1133        })?
1134        .collect::<Result<Vec<_>, _>>()?;
1135    drop(stmt);
1136
1137    let floors = cleanup_floors(None);
1138    let mut result = UndoResult::default();
1139
1140    for (log_id, from_path, to_path, size, mtime) in &entries {
1141        let to = Path::new(to_path);
1142        let from = Path::new(from_path);
1143
1144        if !to.exists() {
1145            // Already moved back or deleted — drop the log row.
1146            db.conn
1147                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
1148            continue;
1149        }
1150
1151        if from.exists() && !paths_equal(from, to) {
1152            result.errors.push((
1153                from.to_path_buf(),
1154                format!(
1155                    "another file now occupies the original path; {} left in place",
1156                    to.display()
1157                ),
1158            ));
1159            continue;
1160        }
1161
1162        if let Err(msg) = matches_logged_file(to, *size, *mtime) {
1163            result.errors.push((to.to_path_buf(), msg));
1164            continue;
1165        }
1166
1167        if let Some(parent) = from.parent()
1168            && let Err(e) = std::fs::create_dir_all(parent)
1169        {
1170            result.errors.push((from.to_path_buf(), e.to_string()));
1171            continue;
1172        }
1173
1174        let tx = db.conn.unchecked_transaction()?;
1175        if let Err(e) = rewrite_path_references(&tx, to, from) {
1176            result.errors.push((to.to_path_buf(), e.to_string()));
1177            continue;
1178        }
1179        if let Err(e) = move_file(to, from) {
1180            result.errors.push((to.to_path_buf(), e.to_string()));
1181            continue;
1182        }
1183        if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
1184            let _ = move_file(from, to);
1185            result.errors.push((to.to_path_buf(), e.to_string()));
1186            continue;
1187        }
1188        if let Err(e) = tx.commit() {
1189            let _ = move_file(from, to);
1190            result.errors.push((to.to_path_buf(), e.to_string()));
1191            continue;
1192        }
1193
1194        if let Some(parent) = to.parent() {
1195            remove_empty_dirs(parent, &floors);
1196        }
1197
1198        result.restored += 1;
1199    }
1200
1201    Ok(result)
1202}
1203
1204/// Confirm the file at a logged destination is still the file that was moved there.
1205/// Rows written before size/mtime were recorded carry neither and are accepted.
1206fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
1207    let (Some(size), Some(mtime)) = (size, mtime) else {
1208        return Ok(());
1209    };
1210    let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
1211    if meta.len() != size as u64 {
1212        return Err(format!(
1213            "{} has changed since it was moved (size differs); left in place",
1214            path.display()
1215        ));
1216    }
1217    if mtime_secs(&meta).is_some_and(|current| current != mtime) {
1218        return Err(format!(
1219            "{} has changed since it was moved (modification time differs); left in place",
1220            path.display()
1221        ));
1222    }
1223    Ok(())
1224}
1225
1226fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
1227    meta.modified()
1228        .ok()?
1229        .duration_since(UNIX_EPOCH)
1230        .ok()
1231        .map(|d| d.as_secs() as i64)
1232}
1233
1234/// Directories an empty-directory sweep must never remove or climb past.
1235fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
1236    let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
1237    if let Ok(config) = crate::config::Config::load() {
1238        floors.extend(config.library.folders);
1239    }
1240    floors
1241}
1242
1243/// Remove the directory a file just left, and its now-empty parents — but never a
1244/// configured library root, and never anything above one.
1245fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
1246    let mut current = start.to_path_buf();
1247    loop {
1248        if floors.iter().any(|floor| floor == &current) {
1249            break;
1250        }
1251        let empty = std::fs::read_dir(&current)
1252            .map(|mut d| d.next().is_none())
1253            .unwrap_or(false);
1254        if !empty || std::fs::remove_dir(&current).is_err() {
1255            break;
1256        }
1257        let Some(parent) = current.parent() else {
1258            break;
1259        };
1260        // Only keep climbing inside a directory the run was told about.
1261        if !floors
1262            .iter()
1263            .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1264        {
1265            break;
1266        }
1267        current = parent.to_path_buf();
1268    }
1269}
1270
1271/// Move a file, never overwriting whatever is already at the destination.
1272fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1273    if from == to {
1274        return Ok(());
1275    }
1276    if paths_equal(from, to) {
1277        // Same file under a different spelling — a case-only rename on a
1278        // case-insensitive filesystem. Reserving the destination would land on
1279        // the source itself, so it goes via a temporary name.
1280        return rename_via_temp(from, to);
1281    }
1282
1283    // Claim the name atomically: nothing can slip into the destination between
1284    // this check and the rename below.
1285    match std::fs::OpenOptions::new()
1286        .write(true)
1287        .create_new(true)
1288        .open(to)
1289    {
1290        Ok(_) => {}
1291        Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1292            return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1293        }
1294        Err(e) => return Err(e.into()),
1295    }
1296
1297    match transfer(from, to) {
1298        Ok(()) => Ok(()),
1299        Err(e) => {
1300            // Don't leave the empty placeholder behind.
1301            let _ = std::fs::remove_file(to);
1302            Err(e)
1303        }
1304    }
1305}
1306
1307fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1308    let temp = temp_sibling(to);
1309    std::fs::rename(from, &temp)?;
1310    match std::fs::rename(&temp, to) {
1311        Ok(()) => Ok(()),
1312        Err(e) => {
1313            let _ = std::fs::rename(&temp, from);
1314            Err(e.into())
1315        }
1316    }
1317}
1318
1319/// Rename, falling back to a verified copy when the destination is on another filesystem.
1320fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1321    match std::fs::rename(from, to) {
1322        Ok(()) => Ok(()),
1323        // EXDEV (18): cross-device link.
1324        Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1325        Err(e) => Err(e.into()),
1326    }
1327}
1328
1329/// Copy to a temporary file, flush it to disk, verify its length, and only then
1330/// drop the original. A crash at any point leaves the source intact.
1331fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1332    let source_meta = std::fs::metadata(from)?;
1333    let expected = source_meta.len();
1334    let temp = temp_sibling(to);
1335
1336    let copied = {
1337        let mut reader = std::fs::File::open(from)?;
1338        let mut writer = std::fs::OpenOptions::new()
1339            .write(true)
1340            .create_new(true)
1341            .open(&temp)?;
1342        let copied = std::io::copy(&mut reader, &mut writer)?;
1343        // std::io::copy returning Ok only means the bytes reached the page cache.
1344        writer.sync_all()?;
1345        if let Ok(modified) = source_meta.modified() {
1346            let _ = writer.set_modified(modified);
1347        }
1348        copied
1349    };
1350
1351    let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1352    if copied != expected || written != expected {
1353        let _ = std::fs::remove_file(&temp);
1354        return Err(OrganizeError::ShortCopy {
1355            path: from.to_path_buf(),
1356            expected,
1357            copied: copied.min(written),
1358        });
1359    }
1360
1361    if let Err(e) = std::fs::rename(&temp, to) {
1362        let _ = std::fs::remove_file(&temp);
1363        return Err(e.into());
1364    }
1365    std::fs::remove_file(from)?;
1366    Ok(())
1367}
1368
1369fn temp_sibling(path: &Path) -> PathBuf {
1370    let nanos = SystemTime::now()
1371        .duration_since(UNIX_EPOCH)
1372        .unwrap_or_default()
1373        .as_nanos();
1374    path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1375}
1376
1377/// Compare paths for equality, including two spellings of one file on a
1378/// case-insensitive filesystem.
1379fn paths_equal(a: &Path, b: &Path) -> bool {
1380    if a == b {
1381        return true;
1382    }
1383    #[cfg(unix)]
1384    {
1385        use std::os::unix::fs::MetadataExt;
1386        if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1387            return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1388        }
1389    }
1390    false
1391}
1392
1393/// Refuse a run that can't fit, rather than discovering it partway through.
1394/// Only files landing on a different filesystem need space.
1395fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1396    let Some(target) = existing_ancestor(base_dir) else {
1397        return Ok(());
1398    };
1399    let Some(target_device) = device_id(&target) else {
1400        return Ok(());
1401    };
1402
1403    let mut needed = 0u64;
1404    for file_move in moves {
1405        if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1406            continue;
1407        }
1408        if let Ok(meta) = std::fs::metadata(&file_move.from) {
1409            needed = needed.saturating_add(meta.len());
1410        }
1411    }
1412    if needed == 0 {
1413        return Ok(());
1414    }
1415
1416    match available_bytes(&target) {
1417        Some(available) if available < needed => {
1418            Err(OrganizeError::NotEnoughSpace { needed, available })
1419        }
1420        _ => Ok(()),
1421    }
1422}
1423
1424fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1425    path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1426}
1427
1428#[cfg(unix)]
1429fn device_id(path: &Path) -> Option<u64> {
1430    use std::os::unix::fs::MetadataExt;
1431    std::fs::metadata(path).ok().map(|m| m.dev())
1432}
1433
1434#[cfg(not(unix))]
1435fn device_id(_path: &Path) -> Option<u64> {
1436    None
1437}
1438
1439#[cfg(unix)]
1440fn available_bytes(path: &Path) -> Option<u64> {
1441    use std::os::unix::ffi::OsStrExt;
1442    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1443    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1444    if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1445        return None;
1446    }
1447    // Widths of these fields differ between macOS and Linux.
1448    (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1449}
1450
1451#[cfg(not(unix))]
1452fn available_bytes(_path: &Path) -> Option<u64> {
1453    None
1454}
1455
1456/// Where the pattern's relative paths hang off. `None` uses the first
1457/// configured library folder.
1458///
1459/// An empty path is refused rather than accepted as "here". It makes every
1460/// destination relative to the process's working directory — `/` for an app
1461/// bundle — so the plan formats and previews perfectly and every single move
1462/// then fails at `create_dir_all`.
1463fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1464    let dir = match base_dir {
1465        Some(dir) => dir.to_path_buf(),
1466        None => crate::config::Config::load()
1467            .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?
1468            .library
1469            .folders
1470            .into_iter()
1471            .find(|folder| !folder.as_os_str().is_empty())
1472            .ok_or(OrganizeError::NoDestination)?,
1473    };
1474
1475    if dir.as_os_str().is_empty() {
1476        return Err(OrganizeError::NoDestination);
1477    }
1478    Ok(dir)
1479}
1480
1481/// Whether cover art and cue sheets travel with the music. A preference, so it
1482/// is read where it is used rather than threaded through every signature.
1483fn move_ancillary() -> bool {
1484    crate::config::Config::load()
1485        .map(|c| c.organize.move_ancillary)
1486        .unwrap_or(true)
1487}
1488
1489fn batch_id() -> String {
1490    let now = SystemTime::now()
1491        .duration_since(UNIX_EPOCH)
1492        .unwrap_or_default();
1493    format!("batch-{}", now.as_nanos())
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499    use crate::db::queries::TrackMeta;
1500    use crate::db::schema;
1501    use tempfile::TempDir;
1502
1503    fn test_db() -> Database {
1504        let conn = rusqlite::Connection::open_in_memory().unwrap();
1505        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1506        schema::create_tables(&conn).unwrap();
1507        Database { conn }
1508    }
1509
1510    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1511        TrackMeta {
1512            title: title.into(),
1513            artist: artist.into(),
1514            album_artist: Some(artist.into()),
1515            album: album.into(),
1516            date: Some("1997-06-16".into()),
1517            disc: Some(1),
1518            track_number: Some(1),
1519            genre: Some("Rock".into()),
1520            label: None,
1521            duration_ms: Some(240_000),
1522            codec: Some("FLAC".into()),
1523            sample_rate: Some(44100),
1524            bit_depth: Some(16),
1525            channels: Some(2),
1526            bitrate: Some(1000),
1527            size_bytes: Some(30_000_000),
1528            mtime: Some(1700000000),
1529            path: None,
1530            source: "local".into(),
1531            remote_id: None,
1532            remote_url: None,
1533            album_remote_id: None,
1534            artist_remote_id: None,
1535            mbid: None,
1536            album_mbid: None,
1537            album_added_at: None,
1538        }
1539    }
1540
1541    fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1542        TrackRow {
1543            id: 1,
1544            album_id: Some(1),
1545            artist_id: Some(1),
1546            artist_name: artist.into(),
1547            album_artist_name: artist.into(),
1548            album_title: album.into(),
1549            disc: Some(1),
1550            track_number: Some(1),
1551            title: title.into(),
1552            duration_ms: Some(240_000),
1553            path: Some("/music/test.flac".into()),
1554            codec: Some("FLAC".into()),
1555            sample_rate: Some(44100),
1556            bit_depth: Some(16),
1557            channels: Some(2),
1558            bitrate: Some(1000),
1559            genre: None,
1560            source: "local".into(),
1561            remote_id: None,
1562            cached_path: None,
1563        }
1564    }
1565
1566    /// Write a file with recognisable contents and register it in the library.
1567    fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1568        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1569        std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1570        let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1571        meta.track_number = Some(track_number);
1572        meta.path = Some(path.to_string_lossy().into_owned());
1573        queries::upsert_track(&db.conn, &meta).unwrap()
1574    }
1575
1576    fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1577        db.conn
1578            .query_row(
1579                "SELECT path FROM tracks WHERE id = ?1",
1580                params![track_id],
1581                |row| row.get(0),
1582            )
1583            .unwrap()
1584    }
1585
1586    fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1587        let mut stmt = db
1588            .conn
1589            .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1590            .unwrap();
1591        let rows = stmt
1592            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1593            .unwrap();
1594        rows.map(|r| r.unwrap()).collect()
1595    }
1596
1597    // ---- Metadata + sanitisation ----
1598
1599    #[test]
1600    fn track_metadata_provider_fields() {
1601        let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1602        track.track_number = Some(3);
1603        track.genre = Some("Alternative".into());
1604
1605        let album = AlbumFacts {
1606            date: Some("1997-06-16".into()),
1607            label: Some("Parlophone".into()),
1608        };
1609        let meta = TrackMetadata::from_track_row(&track, &album);
1610        assert_eq!(
1611            meta.get_field("title").as_deref(),
1612            Some("Subterranean Homesick Alien")
1613        );
1614        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1615        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1616        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1617        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1618        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1619        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1620        assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1621        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1622        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1623        assert_eq!(meta.get_field("nonexistent"), None);
1624    }
1625
1626    /// Both providers must populate the same field names, or a preview taken from one
1627    /// authorises a move planned by the other.
1628    #[test]
1629    fn both_metadata_sources_expose_the_same_fields() {
1630        let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1631        track.genre = Some("Rock".into());
1632        let album = AlbumFacts {
1633            date: Some("1997-06-16".into()),
1634            label: Some("Parlophone".into()),
1635        };
1636        let from_db = TrackMetadata::from_track_row(&track, &album);
1637
1638        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1639        meta.label = Some("Parlophone".into());
1640        let from_tags = TrackMetadata::from_file_meta(&meta);
1641
1642        let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1643        let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1644        db_fields.sort();
1645        tag_fields.sort();
1646        assert_eq!(db_fields, tag_fields);
1647    }
1648
1649    #[test]
1650    fn sanitize_replaces_illegal_chars() {
1651        assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1652        assert_eq!(sanitise_filename("What?"), "What_");
1653        assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1654        assert_eq!(sanitise_filename("normal"), "normal");
1655    }
1656
1657    #[test]
1658    fn sanitize_relative_path_splits() {
1659        assert_eq!(
1660            sanitize_relative_path("Artist/Album/Track").unwrap(),
1661            PathBuf::from("Artist/Album/Track")
1662        );
1663        assert_eq!(
1664            sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1665            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1666        );
1667    }
1668
1669    #[test]
1670    fn sanitize_relative_path_refuses_traversal_and_gaps() {
1671        // Reinterpreting these silently is what turns one bad pattern into a
1672        // directory full of overwritten files.
1673        assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1674        assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1675        assert!(sanitize_relative_path("./Artist/./Album").is_err());
1676        assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1677        assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1678        assert!(sanitize_relative_path("   /Airbag").is_err());
1679    }
1680
1681    #[test]
1682    fn acdc_artist_name_sanitized() {
1683        let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1684        let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1685        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1686        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1687        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1688    }
1689
1690    #[test]
1691    fn format_string_evaluation() {
1692        let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1693        let album = AlbumFacts {
1694            date: Some("1997-06-16".into()),
1695            label: None,
1696        };
1697        let meta = TrackMetadata::from_track_row(&track, &album);
1698        let pattern =
1699            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1700        assert_eq!(
1701            format::format(pattern, &meta).unwrap(),
1702            "Radiohead/(1997) OK Computer/01. Airbag"
1703        );
1704    }
1705
1706    #[test]
1707    fn ancillary_file_detection() {
1708        let tmp = TempDir::new().unwrap();
1709        let dir = tmp.path();
1710        std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1711        std::fs::write(dir.join("cover.png"), b"img").unwrap();
1712        std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1713        std::fs::write(dir.join("rip.log"), b"log").unwrap();
1714        std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1715
1716        let found = find_ancillary_files(dir);
1717        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1718        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1719        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1720        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1721        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1722    }
1723
1724    // ---- Preview / execute ----
1725
1726    #[test]
1727    fn preview_does_not_move_files() {
1728        let db = test_db();
1729        let tmp = TempDir::new().unwrap();
1730        let source = tmp.path().join("src/test.flac");
1731        add_track(&db, &source, "Airbag", 1);
1732
1733        let result = preview(
1734            &db,
1735            "%album artist%/%album%/%title%",
1736            Some(tmp.path()),
1737            true,
1738        )
1739        .unwrap();
1740        assert!(source.exists());
1741        assert_eq!(result.moved_count(), 1);
1742    }
1743
1744    #[test]
1745    fn execute_moves_files_and_undo_reverts() {
1746        let db = test_db();
1747        let tmp = TempDir::new().unwrap();
1748        let source = tmp.path().join("src/test.flac");
1749        let id = add_track(&db, &source, "Airbag", 1);
1750
1751        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1752        assert_eq!(result.moved_count(), 1);
1753        assert_eq!(result.failures().count(), 0);
1754        assert!(!source.exists());
1755        let dest = result.moves().next().unwrap().dest().to_path_buf();
1756        assert!(dest.exists());
1757        assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1758
1759        let undone = undo(&db).unwrap();
1760        assert_eq!(undone.restored, 1);
1761        assert!(undone.errors.is_empty());
1762        assert!(source.exists());
1763        assert!(!dest.exists());
1764        assert_eq!(
1765            db_path_of(&db, id).as_deref(),
1766            Some(source.to_str().unwrap())
1767        );
1768    }
1769
1770    /// The preview a user confirms and the moves that follow must agree. They read
1771    /// metadata through the same resolver, so a pattern using an album-level field
1772    /// (here `%label%`) resolves identically in both.
1773    #[test]
1774    fn preview_and_execute_agree_on_destinations() {
1775        let db = test_db();
1776        let tmp = TempDir::new().unwrap();
1777        let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1778
1779        let source = tmp.path().join("src/aphex.flac");
1780        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1781        std::fs::write(&source, b"audio").unwrap();
1782        let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1783        meta.label = Some("Warp Records".into());
1784        meta.path = Some(source.to_string_lossy().into_owned());
1785        queries::upsert_track(&db.conn, &meta).unwrap();
1786
1787        let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1788        assert_eq!(previewed.moved_count(), 1);
1789        let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1790        assert!(expected.starts_with(tmp.path().join("Warp Records")));
1791
1792        let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1793        assert_eq!(executed.moved_count(), 1);
1794        assert_eq!(executed.moves().next().unwrap().dest(), expected);
1795        assert!(expected.exists());
1796    }
1797
1798    /// The whole macOS flow, end to end: files land from outside the library,
1799    /// get rows where they lie, and organize is what puts them under the music
1800    /// tree. Nothing about the import knows where they will end up.
1801    #[test]
1802    fn imported_files_organize_into_the_library_folder() {
1803        let db = test_db();
1804        let tmp = TempDir::new().unwrap();
1805        let outside = tmp.path().join("Downloads/rip");
1806        let library = tmp.path().join("Music");
1807        std::fs::create_dir_all(&outside).unwrap();
1808        std::fs::create_dir_all(&library).unwrap();
1809
1810        let dropped = outside.join("track.wav");
1811        crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1812
1813        let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1814        assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1815
1816        let result = execute_for_tracks(
1817            &db,
1818            &imported.track_ids,
1819            "%album artist%/%album%/%title%",
1820            Some(&library),
1821        )
1822        .unwrap();
1823
1824        assert_eq!(result.moved_count(), 1);
1825        let dest = result.moves().next().unwrap().dest();
1826        assert!(dest.starts_with(&library), "landed at {}", dest.display());
1827        assert!(dest.exists());
1828        assert!(
1829            !dropped.exists(),
1830            "the original should have moved, not copied"
1831        );
1832
1833        // The row followed the file, so playing it afterwards still works.
1834        assert_eq!(
1835            db_path_of(&db, imported.track_ids[0]).as_deref(),
1836            dest.to_str()
1837        );
1838    }
1839
1840    /// Generation is pure. It is what reruns on every keystroke, so if it ever
1841    /// starts touching the filesystem this is what says so: the destination is
1842    /// occupied and the source directory is full of cover art, and neither
1843    /// shows up until the disk is actually asked.
1844    #[test]
1845    fn generate_touches_no_files() {
1846        let db = test_db();
1847        let tmp = TempDir::new().unwrap();
1848        let source = tmp.path().join("src/track.flac");
1849        add_track(&db, &source, "Airbag", 1);
1850        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1851
1852        // Something is already sitting where the pattern points.
1853        let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1854        std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
1855        std::fs::write(&occupied, b"the good rip").unwrap();
1856
1857        let selection = resolve(&db, None).unwrap();
1858        let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1859
1860        // Pure pass: a move, no conflict, no ancillary — it has not looked.
1861        assert_eq!(result.moved_count(), 1);
1862        assert_eq!(result.conflicts().count(), 0);
1863        assert!(result.entries[0].ancillary.is_empty());
1864
1865        // Asking the disk is what finds both.
1866        check_against_disk(&mut result, true);
1867        assert_eq!(result.moved_count(), 0);
1868        assert_eq!(result.conflicts().count(), 1);
1869        assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
1870    }
1871
1872    /// A caller with no library folder configured passes an empty base dir.
1873    /// Taken literally it means "relative to wherever this process happens to
1874    /// be", which plans and previews cleanly and then fails on every file, so
1875    /// it is refused before a plan exists to confirm.
1876    #[test]
1877    fn an_empty_base_dir_is_not_a_destination() {
1878        let db = test_db();
1879        add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
1880
1881        let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
1882        assert!(matches!(err, OrganizeError::NoDestination));
1883
1884        let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
1885        assert!(matches!(err, OrganizeError::NoDestination));
1886    }
1887
1888    /// Resolving once and generating many times must agree with planning from
1889    /// scratch, or the preview would be lying about what execute will do.
1890    #[test]
1891    fn generate_agrees_with_a_full_plan() {
1892        let db = test_db();
1893        let tmp = TempDir::new().unwrap();
1894        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1895        add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
1896        let pattern = "%album artist%/%album%/%tracknumber%. %title%";
1897
1898        let selection = resolve(&db, None).unwrap();
1899        let mut generated = generate(&selection, pattern, tmp.path());
1900        check_against_disk(&mut generated, true);
1901        let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1902
1903        assert_eq!(generated.entries.len(), planned.entries.len());
1904        for (a, b) in generated.entries.iter().zip(&planned.entries) {
1905            assert_eq!(a.from, b.from);
1906            assert_eq!(a.to, b.to);
1907            assert_eq!(a.outcome, b.outcome);
1908            assert_eq!(a.ancillary, b.ancillary);
1909        }
1910    }
1911
1912    /// One readdir per source directory, not one per file — the thing that made
1913    /// a preview over an album on a slow volume cost what it did.
1914    #[test]
1915    fn ancillary_is_scanned_once_per_directory() {
1916        let db = test_db();
1917        let tmp = TempDir::new().unwrap();
1918        for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
1919            add_track(
1920                &db,
1921                &tmp.path().join(format!("src/{i}.flac")),
1922                title,
1923                i as i32 + 1,
1924            );
1925        }
1926        std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
1927
1928        let result = preview(
1929            &db,
1930            "%album artist%/%album%/%title%",
1931            Some(tmp.path()),
1932            true,
1933        )
1934        .unwrap();
1935
1936        // The cover travels with exactly one of them, not all three.
1937        let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
1938        assert_eq!(carrying.len(), 1);
1939        assert_eq!(carrying[0].ancillary.len(), 1);
1940    }
1941
1942    /// The disk pass must not mistake a file for its own obstacle. Nothing
1943    /// stops a caller planning against paths a previous run already moved, and
1944    /// a bare `exists()` on the destination says "occupied" for every one of
1945    /// them.
1946    #[test]
1947    fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
1948        let db = test_db();
1949        let tmp = TempDir::new().unwrap();
1950        let pattern = "%album artist%/%album%/%title%";
1951        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
1952
1953        let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
1954        assert_eq!(moved.moved_count(), 1);
1955
1956        // Plan again, from the rows as they now stand.
1957        let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1958        assert_eq!(again.conflicts().count(), 0);
1959        assert_eq!(again.unchanged_count(), 1);
1960    }
1961
1962    #[test]
1963    fn ancillary_files_stay_put_when_they_are_turned_off() {
1964        let db = test_db();
1965        let tmp = TempDir::new().unwrap();
1966        let source = tmp.path().join("src/a.flac");
1967        add_track(&db, &source, "Airbag", 1);
1968        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
1969
1970        let selection = resolve(&db, None).unwrap();
1971        let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1972        check_against_disk(&mut off, false);
1973        assert!(off.moves().all(|e| e.ancillary.is_empty()));
1974
1975        let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
1976        check_against_disk(&mut on, true);
1977        assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
1978    }
1979
1980    // ---- Collisions ----
1981
1982    #[test]
1983    fn colliding_destinations_leave_both_files_intact() {
1984        let db = test_db();
1985        let tmp = TempDir::new().unwrap();
1986        let first = tmp.path().join("src/a.flac");
1987        let second = tmp.path().join("src/b.flac");
1988        // Same title, different track numbers: two library rows, one destination.
1989        let first_id = add_track(&db, &first, "Airbag", 1);
1990        let second_id = add_track(&db, &second, "Airbag", 2);
1991        let second_bytes = std::fs::read(&second).unwrap();
1992
1993        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1994
1995        assert_eq!(result.moved_count(), 1);
1996
1997        // The loser is a row in the plan, flagged as a conflict and still
1998        // carrying the destination it lost — that is what a preview shows.
1999        let blocked = result.conflicts().next().unwrap();
2000        assert_eq!(result.conflicts().count(), 1);
2001        assert_eq!(blocked.from, second);
2002        assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
2003
2004        // The loser stays exactly where it was, byte for byte.
2005        assert!(second.exists());
2006        assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2007        assert_eq!(
2008            db_path_of(&db, second_id).as_deref(),
2009            Some(second.to_str().unwrap())
2010        );
2011
2012        let dest = result.moves().next().unwrap().dest();
2013        assert_eq!(
2014            std::fs::read(dest).unwrap(),
2015            b"audio bytes for Airbag".to_vec()
2016        );
2017        assert_eq!(
2018            db_path_of(&db, first_id).as_deref(),
2019            Some(dest.to_str().unwrap())
2020        );
2021    }
2022
2023    #[test]
2024    fn existing_destination_is_never_overwritten() {
2025        let db = test_db();
2026        let tmp = TempDir::new().unwrap();
2027        let source = tmp.path().join("src/new.flac");
2028        add_track(&db, &source, "Airbag", 1);
2029
2030        // Something unrelated is already sitting at the destination.
2031        let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2032        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2033        std::fs::write(&dest, b"the good rip").unwrap();
2034
2035        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2036        assert_eq!(result.moved_count(), 0);
2037
2038        // Flagged as a conflict against the occupied path, so a preview can say
2039        // what would have been overwritten before anyone presses the button.
2040        let blocked = result.conflicts().next().unwrap();
2041        assert_eq!(result.conflicts().count(), 1);
2042        assert_eq!(blocked.from, source);
2043        assert_eq!(blocked.dest(), dest);
2044        assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2045
2046        assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2047        assert!(source.exists());
2048    }
2049
2050    /// `move_file` is the last line of defence: even handed a destination that exists,
2051    /// it refuses rather than replacing it.
2052    #[test]
2053    fn move_file_refuses_an_occupied_destination() {
2054        let tmp = TempDir::new().unwrap();
2055        let from = tmp.path().join("a.flac");
2056        let to = tmp.path().join("b.flac");
2057        std::fs::write(&from, b"source").unwrap();
2058        std::fs::write(&to, b"keep me").unwrap();
2059
2060        let err = move_file(&from, &to).unwrap_err();
2061        assert!(matches!(err, OrganizeError::DestinationExists(_)));
2062        assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2063        assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2064    }
2065
2066    #[cfg(any(target_os = "macos", target_os = "ios"))]
2067    #[test]
2068    fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2069        let db = test_db();
2070        let tmp = TempDir::new().unwrap();
2071        let first = tmp.path().join("src/1.flac");
2072        let second = tmp.path().join("src/2.flac");
2073        std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2074        for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2075            std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2076            let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2077            meta.track_number = Some(number);
2078            meta.path = Some(path.to_string_lossy().into_owned());
2079            queries::upsert_track(&db.conn, &meta).unwrap();
2080        }
2081
2082        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2083        assert_eq!(result.moved_count(), 1);
2084        assert_eq!(result.failures().count(), 1);
2085        assert!(second.exists());
2086        assert_eq!(
2087            std::fs::read(&second).unwrap(),
2088            b"audio bytes for RAIN".to_vec()
2089        );
2090    }
2091
2092    /// A rename that only changes case has to go via a temporary name: reserving the
2093    /// destination would otherwise open the source file itself.
2094    /// Not on iOS: the simulator's sandboxed filesystem answers `stat` for
2095    /// `Rain.flac` with ENOENT while `open(O_CREAT|O_EXCL)` on the same name
2096    /// answers EEXIST — measured, both, in one directory. `paths_equal` reads
2097    /// `stat`, so the case-only rename it exists to catch cannot be detected
2098    /// there. Unverified on a device, where the volume is ordinary APFS.
2099    #[cfg(not(target_os = "ios"))]
2100    #[test]
2101    fn case_only_rename_keeps_the_file() {
2102        let tmp = TempDir::new().unwrap();
2103        let from = tmp.path().join("rain.flac");
2104        let to = tmp.path().join("Rain.flac");
2105        std::fs::write(&from, b"audio bytes").unwrap();
2106
2107        move_file(&from, &to).unwrap();
2108
2109        assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2110        let names: Vec<String> = std::fs::read_dir(tmp.path())
2111            .unwrap()
2112            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2113            .collect();
2114        assert_eq!(names, vec!["Rain.flac".to_string()]);
2115    }
2116
2117    /// The cross-device path copies, flushes and verifies before unlinking the
2118    /// original, so an interrupted move can never leave a truncated file and no source.
2119    #[test]
2120    fn cross_device_copy_verifies_before_dropping_the_source() {
2121        let tmp = TempDir::new().unwrap();
2122        let from = tmp.path().join("a.flac");
2123        let to = tmp.path().join("b.flac");
2124        let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2125        std::fs::write(&from, &bytes).unwrap();
2126        let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2127
2128        copy_across_devices(&from, &to).unwrap();
2129
2130        assert!(!from.exists());
2131        assert_eq!(std::fs::read(&to).unwrap(), bytes);
2132        // Preserved, so scan_cache entries stay valid across a cross-device move.
2133        assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2134        // No temporary left behind.
2135        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2136            .unwrap()
2137            .filter(|e| {
2138                e.as_ref()
2139                    .unwrap()
2140                    .file_name()
2141                    .to_string_lossy()
2142                    .starts_with(".koan-")
2143            })
2144            .collect();
2145        assert!(leftovers.is_empty());
2146    }
2147
2148    #[test]
2149    fn free_space_check_ignores_same_device_moves() {
2150        let tmp = TempDir::new().unwrap();
2151        let from = tmp.path().join("a.flac");
2152        std::fs::write(&from, b"audio").unwrap();
2153        let moves = vec![FileMove {
2154            track_id: None,
2155            from,
2156            to: tmp.path().join("b.flac"),
2157            ancillary: Vec::new(),
2158        }];
2159        // A rename within one filesystem consumes no space.
2160        assert!(check_free_space(&moves, tmp.path()).is_ok());
2161    }
2162
2163    // ---- Bad patterns ----
2164
2165    #[test]
2166    fn unknown_function_refuses_the_move() {
2167        let db = test_db();
2168        let tmp = TempDir::new().unwrap();
2169        let source = tmp.path().join("src/test.flac");
2170        add_track(&db, &source, "Airbag", 1);
2171
2172        // $nun instead of $num.
2173        let result = execute(
2174            &db,
2175            "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2176            Some(tmp.path()),
2177        )
2178        .unwrap();
2179        assert_eq!(result.moved_count(), 0);
2180        assert_eq!(result.failures().count(), 1);
2181        assert!(result.failure_messages()[0].contains("unknown function"));
2182        assert!(source.exists());
2183    }
2184
2185    /// An empty last component used to append the extension to the parent directory,
2186    /// pointing every track on an album at one file.
2187    #[test]
2188    fn empty_final_component_refuses_the_move() {
2189        let db = test_db();
2190        let tmp = TempDir::new().unwrap();
2191        let first = tmp.path().join("src/a.flac");
2192        let second = tmp.path().join("src/b.flac");
2193        add_track(&db, &first, "Airbag", 1);
2194        add_track(&db, &second, "Karma Police", 2);
2195
2196        // The conditional resolves to nothing, leaving a trailing separator.
2197        let result = execute(
2198            &db,
2199            "%album artist%/%album%/[%nonexistent field%]",
2200            Some(tmp.path()),
2201        )
2202        .unwrap();
2203
2204        assert_eq!(result.moved_count(), 0);
2205        assert_eq!(result.failures().count(), 2);
2206        assert!(first.exists());
2207        assert!(second.exists());
2208        assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2209    }
2210
2211    #[test]
2212    fn long_title_is_truncated_rather_than_failing() {
2213        let db = test_db();
2214        let tmp = TempDir::new().unwrap();
2215        let source = tmp.path().join("src/test.flac");
2216        let title = "a".repeat(300);
2217        add_track(&db, &source, &title, 1);
2218
2219        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2220        assert_eq!(
2221            result.moved_count(),
2222            1,
2223            "errors: {:?}",
2224            result.failure_messages()
2225        );
2226        let name = result
2227            .moves()
2228            .next()
2229            .unwrap()
2230            .dest()
2231            .file_name()
2232            .unwrap()
2233            .to_string_lossy();
2234        assert!(name.len() <= MAX_FILE_NAME_BYTES);
2235        assert!(name.ends_with(".flac"));
2236        assert!(result.moves().next().unwrap().dest().exists());
2237    }
2238
2239    // ---- Directory cleanup ----
2240
2241    #[test]
2242    fn remove_empty_dirs_never_climbs_past_a_floor() {
2243        let tmp = TempDir::new().unwrap();
2244        let root = tmp.path().join("library");
2245        let nested = root.join("artist/album");
2246        std::fs::create_dir_all(&nested).unwrap();
2247
2248        remove_empty_dirs(&nested, std::slice::from_ref(&root));
2249
2250        assert!(!nested.exists());
2251        assert!(!root.join("artist").exists());
2252        assert!(root.exists(), "the library root must survive");
2253    }
2254
2255    #[test]
2256    fn remove_empty_dirs_stays_put_outside_any_floor() {
2257        let tmp = TempDir::new().unwrap();
2258        let outside = tmp.path().join("incoming/rip");
2259        std::fs::create_dir_all(&outside).unwrap();
2260
2261        remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2262
2263        assert!(!outside.exists());
2264        assert!(
2265            tmp.path().join("incoming").exists(),
2266            "no floor means no climbing"
2267        );
2268    }
2269
2270    #[test]
2271    fn remove_empty_dirs_never_removes_a_floor_itself() {
2272        let tmp = TempDir::new().unwrap();
2273        let root = tmp.path().join("library");
2274        std::fs::create_dir_all(&root).unwrap();
2275
2276        remove_empty_dirs(&root, std::slice::from_ref(&root));
2277
2278        assert!(root.exists());
2279    }
2280
2281    // ---- Undo ----
2282
2283    #[test]
2284    fn undo_refuses_when_the_original_path_is_occupied() {
2285        let db = test_db();
2286        let tmp = TempDir::new().unwrap();
2287        let source = tmp.path().join("src/test.flac");
2288        add_track(&db, &source, "Airbag", 1);
2289
2290        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2291        let dest = result.moves().next().unwrap().dest().to_path_buf();
2292
2293        // A different rip lands at the vacated path before the undo.
2294        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2295        std::fs::write(&source, b"a completely different rip").unwrap();
2296
2297        let undone = undo(&db).unwrap();
2298        assert_eq!(undone.restored, 0);
2299        assert_eq!(undone.errors.len(), 1);
2300        assert_eq!(
2301            std::fs::read(&source).unwrap(),
2302            b"a completely different rip".to_vec()
2303        );
2304        assert!(dest.exists());
2305        // The entry stays in the log so it can be undone once the path is free.
2306        assert_eq!(log_rows(&db).len(), 1);
2307    }
2308
2309    #[test]
2310    fn undo_refuses_when_the_moved_file_has_been_replaced() {
2311        let db = test_db();
2312        let tmp = TempDir::new().unwrap();
2313        let source = tmp.path().join("src/test.flac");
2314        add_track(&db, &source, "Airbag", 1);
2315
2316        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2317        let dest = result.moves().next().unwrap().dest().to_path_buf();
2318        std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2319
2320        let undone = undo(&db).unwrap();
2321        assert_eq!(undone.restored, 0);
2322        assert_eq!(undone.errors.len(), 1);
2323        assert!(!source.exists());
2324        assert!(dest.exists());
2325    }
2326
2327    /// `created_at` has one-second resolution, so batches are ordered by primary key.
2328    #[test]
2329    fn undo_takes_the_newest_batch_when_timestamps_tie() {
2330        let db = test_db();
2331        let tmp = TempDir::new().unwrap();
2332        let older = tmp.path().join("older.flac");
2333        let newer = tmp.path().join("newer.flac");
2334        std::fs::write(&older, b"older").unwrap();
2335        std::fs::write(&newer, b"newer").unwrap();
2336        let moved_older = tmp.path().join("moved-older.flac");
2337        let moved_newer = tmp.path().join("moved-newer.flac");
2338        std::fs::rename(&older, &moved_older).unwrap();
2339        std::fs::rename(&newer, &moved_newer).unwrap();
2340
2341        for (batch, from, to) in [
2342            ("batch-1", &older, &moved_older),
2343            ("batch-2", &newer, &moved_newer),
2344        ] {
2345            db.conn
2346                .execute(
2347                    "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2348                     VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2349                    params![
2350                        batch,
2351                        from.to_string_lossy().as_ref(),
2352                        to.to_string_lossy().as_ref()
2353                    ],
2354                )
2355                .unwrap();
2356        }
2357
2358        let undone = undo(&db).unwrap();
2359        assert_eq!(undone.restored, 1);
2360        assert!(newer.exists(), "the newest batch is the one undone");
2361        assert!(!older.exists());
2362    }
2363
2364    // ---- Database consistency ----
2365
2366    #[test]
2367    fn favourites_and_queue_state_follow_the_move() {
2368        let db = test_db();
2369        let tmp = TempDir::new().unwrap();
2370        let source = tmp.path().join("src/test.flac");
2371        add_track(&db, &source, "Airbag", 1);
2372        let source_str = source.to_string_lossy().into_owned();
2373
2374        queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, &source).unwrap();
2375        let item = PersistedQueueItem {
2376            path: source_str.clone(),
2377            title: "Airbag".into(),
2378            artist: "Radiohead".into(),
2379            album_artist: "Radiohead".into(),
2380            album: "OK Computer".into(),
2381            year: None,
2382            codec: None,
2383            track_number: Some(1),
2384            disc: Some(1),
2385            duration_ms: None,
2386            db_id: None,
2387        };
2388        queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
2389            .unwrap();
2390
2391        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2392        let dest = result.moves().next().unwrap().dest().to_path_buf();
2393        let dest_str = dest.to_string_lossy().into_owned();
2394
2395        let favourites =
2396            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2397        assert!(favourites.contains(&dest));
2398        assert!(!favourites.contains(&source));
2399
2400        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2401        assert_eq!(state.items[0].path, dest_str);
2402        assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2403
2404        assert_eq!(undo(&db).unwrap().restored, 1);
2405
2406        let favourites =
2407            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2408        assert!(favourites.contains(&source));
2409        assert!(!favourites.contains(&dest));
2410        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2411        assert_eq!(state.items[0].path, source_str);
2412        assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2413    }
2414
2415    #[test]
2416    fn scan_cache_follows_the_move() {
2417        let db = test_db();
2418        let tmp = TempDir::new().unwrap();
2419        let source = tmp.path().join("src/test.flac");
2420        let id = add_track(&db, &source, "Airbag", 1);
2421        db.conn
2422            .execute(
2423                "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2424                params![source.to_string_lossy().as_ref(), id],
2425            )
2426            .unwrap();
2427
2428        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2429        let dest = result
2430            .moves()
2431            .next()
2432            .unwrap()
2433            .dest()
2434            .to_string_lossy()
2435            .into_owned();
2436
2437        let cached: String = db
2438            .conn
2439            .query_row(
2440                "SELECT path FROM scan_cache WHERE track_id = ?1",
2441                params![id],
2442                |r| r.get(0),
2443            )
2444            .unwrap();
2445        assert_eq!(cached, dest);
2446    }
2447
2448    /// A failure partway through a batch must leave the rest of the run truthful: the
2449    /// files that moved are in the result and the log, the one that didn't is in neither.
2450    #[test]
2451    fn partial_failure_leaves_the_database_and_result_consistent() {
2452        let db = test_db();
2453        let tmp = TempDir::new().unwrap();
2454        let first = tmp.path().join("src/a.flac");
2455        let clash = tmp.path().join("src/b.flac");
2456        let third = tmp.path().join("src/c.flac");
2457        let first_id = add_track(&db, &first, "Airbag", 1);
2458        let clash_id = add_track(&db, &clash, "Airbag", 2);
2459        let third_id = add_track(&db, &third, "Karma Police", 3);
2460
2461        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2462
2463        assert_eq!(result.moved_count(), 2);
2464        assert_eq!(result.failures().count(), 1);
2465
2466        let logged = log_rows(&db);
2467        assert_eq!(logged.len(), 2);
2468        for file_move in result.moves() {
2469            assert!(file_move.dest().exists());
2470            assert!(
2471                logged
2472                    .iter()
2473                    .any(|(_, _, to)| Path::new(to) == file_move.dest())
2474            );
2475        }
2476
2477        // The failed file is untouched, in the filesystem and in the database.
2478        assert!(clash.exists());
2479        assert_eq!(
2480            db_path_of(&db, clash_id).as_deref(),
2481            Some(clash.to_str().unwrap())
2482        );
2483        assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2484        assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2485    }
2486
2487    /// The TUI organizes a selection of paths. Files the library doesn't know about
2488    /// still get a log entry, so the whole run can be undone.
2489    #[test]
2490    fn unknown_paths_are_logged_and_undoable() {
2491        let db = test_db();
2492        let tmp = TempDir::new().unwrap();
2493        let known = tmp.path().join("src/known.flac");
2494        add_track(&db, &known, "Airbag", 1);
2495
2496        let result = run(
2497            &db,
2498            Selection::Paths(std::slice::from_ref(&known)),
2499            "%album artist%/%album%/%title%",
2500            tmp.path(),
2501        )
2502        .unwrap();
2503
2504        assert_eq!(result.moved_count(), 1);
2505        let logged = log_rows(&db);
2506        assert_eq!(logged.len(), 1);
2507        assert!(logged[0].0.is_some());
2508
2509        assert_eq!(undo(&db).unwrap().restored, 1);
2510        assert!(known.exists());
2511    }
2512
2513    #[test]
2514    fn ancillary_files_move_with_the_album() {
2515        let db = test_db();
2516        let tmp = TempDir::new().unwrap();
2517        let source = tmp.path().join("src/test.flac");
2518        add_track(&db, &source, "Airbag", 1);
2519        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2520
2521        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2522        assert_eq!(result.moved_count(), 1);
2523        let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2524        assert!(dest_dir.join("cover.jpg").exists());
2525
2526        // Both the audio and the artwork are in the log, so undo restores both.
2527        assert_eq!(log_rows(&db).len(), 2);
2528        assert_eq!(undo(&db).unwrap().restored, 2);
2529        assert!(source.parent().unwrap().join("cover.jpg").exists());
2530    }
2531
2532    // ---- Extension handling ----
2533
2534    #[test]
2535    fn extension_not_clobbered_by_dots_in_title() {
2536        // Regression: with_extension() replaces after the LAST dot,
2537        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
2538        let db = test_db();
2539        let tmp = TempDir::new().unwrap();
2540        let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2541        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2542        std::fs::write(&source, b"fake").unwrap();
2543
2544        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2545        meta.track_number = Some(10);
2546        meta.date = Some("2025-11-21".into());
2547        meta.path = Some(source.to_string_lossy().into_owned());
2548        queries::upsert_track(&db.conn, &meta).unwrap();
2549
2550        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2551        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2552        assert_eq!(result.moved_count(), 1);
2553        assert_eq!(
2554            result
2555                .moves()
2556                .next()
2557                .unwrap()
2558                .dest()
2559                .file_name()
2560                .unwrap()
2561                .to_string_lossy(),
2562            "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2563        );
2564    }
2565
2566    #[test]
2567    fn extension_preserved_for_tracknumber_dot() {
2568        // "0111. Bicep - TANGZ II" must not become "0111.flac"
2569        let db = test_db();
2570        let tmp = TempDir::new().unwrap();
2571        let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2572        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2573        std::fs::write(&source, b"fake").unwrap();
2574
2575        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2576        meta.track_number = Some(11);
2577        meta.date = Some("2025-11-21".into());
2578        meta.path = Some(source.to_string_lossy().into_owned());
2579        queries::upsert_track(&db.conn, &meta).unwrap();
2580
2581        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2582        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2583        assert_eq!(result.moved_count(), 1);
2584        assert_eq!(
2585            result
2586                .moves()
2587                .next()
2588                .unwrap()
2589                .dest()
2590                .file_name()
2591                .unwrap()
2592                .to_string_lossy(),
2593            "0111. Bicep - CHROMA 012 TANGZ II.flac"
2594        );
2595    }
2596}