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