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            credits: Default::default(),
1664        }
1665    }
1666
1667    fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1668        TrackRow {
1669            id: 1,
1670            album_id: Some(1),
1671            artist_id: Some(1),
1672            artist_name: artist.into(),
1673            album_artist_name: artist.into(),
1674            album_title: album.into(),
1675            disc: Some(1),
1676            track_number: Some(1),
1677            title: title.into(),
1678            duration_ms: Some(240_000),
1679            path: Some("/music/test.flac".into()),
1680            codec: Some("FLAC".into()),
1681            sample_rate: Some(44100),
1682            bit_depth: Some(16),
1683            channels: Some(2),
1684            bitrate: Some(1000),
1685            genre: None,
1686            source: "local".into(),
1687            remote_id: None,
1688            cached_path: None,
1689        }
1690    }
1691
1692    /// Write a file with recognisable contents and register it in the library.
1693    fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1694        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1695        std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1696        let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1697        meta.track_number = Some(track_number);
1698        meta.path = Some(path.to_string_lossy().into_owned());
1699        queries::upsert_track(&db.conn, &meta).unwrap()
1700    }
1701
1702    fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1703        db.conn
1704            .query_row(
1705                "SELECT path FROM tracks WHERE id = ?1",
1706                params![track_id],
1707                |row| row.get(0),
1708            )
1709            .unwrap()
1710    }
1711
1712    fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1713        let mut stmt = db
1714            .conn
1715            .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1716            .unwrap();
1717        let rows = stmt
1718            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1719            .unwrap();
1720        rows.map(|r| r.unwrap()).collect()
1721    }
1722
1723    // ---- Metadata + sanitisation ----
1724
1725    #[test]
1726    fn track_metadata_provider_fields() {
1727        let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1728        track.track_number = Some(3);
1729        track.genre = Some("Alternative".into());
1730
1731        let album = AlbumFacts {
1732            date: Some("1997-06-16".into()),
1733            label: Some("Parlophone".into()),
1734        };
1735        let meta = TrackMetadata::from_track_row(&track, &album);
1736        assert_eq!(
1737            meta.get_field("title").as_deref(),
1738            Some("Subterranean Homesick Alien")
1739        );
1740        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1741        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1742        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1743        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1744        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1745        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1746        assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1747        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1748        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1749        assert_eq!(meta.get_field("nonexistent"), None);
1750    }
1751
1752    /// Both providers must populate the same field names, or a preview taken from one
1753    /// authorises a move planned by the other.
1754    #[test]
1755    fn both_metadata_sources_expose_the_same_fields() {
1756        let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1757        track.genre = Some("Rock".into());
1758        let album = AlbumFacts {
1759            date: Some("1997-06-16".into()),
1760            label: Some("Parlophone".into()),
1761        };
1762        let from_db = TrackMetadata::from_track_row(&track, &album);
1763
1764        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1765        meta.label = Some("Parlophone".into());
1766        let from_tags = TrackMetadata::from_file_meta(&meta);
1767
1768        let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1769        let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1770        db_fields.sort();
1771        tag_fields.sort();
1772        assert_eq!(db_fields, tag_fields);
1773    }
1774
1775    #[test]
1776    fn sanitize_replaces_illegal_chars() {
1777        assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1778        assert_eq!(sanitise_filename("What?"), "What_");
1779        assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1780        assert_eq!(sanitise_filename("normal"), "normal");
1781    }
1782
1783    #[test]
1784    fn sanitize_relative_path_splits() {
1785        assert_eq!(
1786            sanitize_relative_path("Artist/Album/Track").unwrap(),
1787            PathBuf::from("Artist/Album/Track")
1788        );
1789        assert_eq!(
1790            sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1791            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1792        );
1793    }
1794
1795    #[test]
1796    fn sanitize_relative_path_refuses_traversal_and_gaps() {
1797        // Reinterpreting these silently is what turns one bad pattern into a
1798        // directory full of overwritten files.
1799        assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1800        assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1801        assert!(sanitize_relative_path("./Artist/./Album").is_err());
1802        assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1803        assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1804        assert!(sanitize_relative_path("   /Airbag").is_err());
1805    }
1806
1807    #[test]
1808    fn acdc_artist_name_sanitized() {
1809        let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1810        let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1811        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1812        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1813        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1814    }
1815
1816    #[test]
1817    fn format_string_evaluation() {
1818        let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1819        let album = AlbumFacts {
1820            date: Some("1997-06-16".into()),
1821            label: None,
1822        };
1823        let meta = TrackMetadata::from_track_row(&track, &album);
1824        let pattern =
1825            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1826        assert_eq!(
1827            format::format(pattern, &meta).unwrap(),
1828            "Radiohead/(1997) OK Computer/01. Airbag"
1829        );
1830    }
1831
1832    #[test]
1833    fn ancillary_file_detection() {
1834        let tmp = TempDir::new().unwrap();
1835        let dir = tmp.path();
1836        std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1837        std::fs::write(dir.join("cover.png"), b"img").unwrap();
1838        std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1839        std::fs::write(dir.join("rip.log"), b"log").unwrap();
1840        std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1841
1842        let found = find_ancillary_files(dir);
1843        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1844        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1845        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1846        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1847        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1848    }
1849
1850    // ---- Preview / execute ----
1851
1852    #[test]
1853    fn preview_does_not_move_files() {
1854        let db = test_db();
1855        let tmp = TempDir::new().unwrap();
1856        let source = tmp.path().join("src/test.flac");
1857        add_track(&db, &source, "Airbag", 1);
1858
1859        let result = preview(
1860            &db,
1861            "%album artist%/%album%/%title%",
1862            Some(tmp.path()),
1863            true,
1864        )
1865        .unwrap();
1866        assert!(source.exists());
1867        assert_eq!(result.moved_count(), 1);
1868    }
1869
1870    #[test]
1871    fn execute_moves_files_and_undo_reverts() {
1872        let db = test_db();
1873        let tmp = TempDir::new().unwrap();
1874        let source = tmp.path().join("src/test.flac");
1875        let id = add_track(&db, &source, "Airbag", 1);
1876
1877        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1878        assert_eq!(result.moved_count(), 1);
1879        assert_eq!(result.failures().count(), 0);
1880        assert!(!source.exists());
1881        let dest = result.moves().next().unwrap().dest().to_path_buf();
1882        assert!(dest.exists());
1883        assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1884
1885        let undone = undo(&db).unwrap();
1886        assert_eq!(undone.restored, 1);
1887        assert!(undone.errors.is_empty());
1888        assert!(source.exists());
1889        assert!(!dest.exists());
1890        assert_eq!(
1891            db_path_of(&db, id).as_deref(),
1892            Some(source.to_str().unwrap())
1893        );
1894    }
1895
1896    /// The preview a user confirms and the moves that follow must agree. They read
1897    /// metadata through the same resolver, so a pattern using an album-level field
1898    /// (here `%label%`) resolves identically in both.
1899    #[test]
1900    fn preview_and_execute_agree_on_destinations() {
1901        let db = test_db();
1902        let tmp = TempDir::new().unwrap();
1903        let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1904
1905        let source = tmp.path().join("src/aphex.flac");
1906        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1907        std::fs::write(&source, b"audio").unwrap();
1908        let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1909        meta.label = Some("Warp Records".into());
1910        meta.path = Some(source.to_string_lossy().into_owned());
1911        queries::upsert_track(&db.conn, &meta).unwrap();
1912
1913        let previewed = preview(&db, pattern, Some(tmp.path()), true).unwrap();
1914        assert_eq!(previewed.moved_count(), 1);
1915        let expected = previewed.moves().next().unwrap().dest().to_path_buf();
1916        assert!(expected.starts_with(tmp.path().join("Warp Records")));
1917
1918        let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1919        assert_eq!(executed.moved_count(), 1);
1920        assert_eq!(executed.moves().next().unwrap().dest(), expected);
1921        assert!(expected.exists());
1922    }
1923
1924    /// The whole macOS flow, end to end: files land from outside the library,
1925    /// get rows where they lie, and organize is what puts them under the music
1926    /// tree. Nothing about the import knows where they will end up.
1927    #[test]
1928    fn imported_files_organize_into_the_library_folder() {
1929        let db = test_db();
1930        let tmp = TempDir::new().unwrap();
1931        let outside = tmp.path().join("Downloads/rip");
1932        let library = tmp.path().join("Music");
1933        std::fs::create_dir_all(&outside).unwrap();
1934        std::fs::create_dir_all(&library).unwrap();
1935
1936        let dropped = outside.join("track.wav");
1937        crate::test_utils::generate_wav(&dropped, 44100, 1, 0.2, 16);
1938
1939        let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&outside));
1940        assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1941
1942        let result = execute_for_tracks(
1943            &db,
1944            &imported.track_ids,
1945            "%album artist%/%album%/%title%",
1946            Some(&library),
1947        )
1948        .unwrap();
1949
1950        assert_eq!(result.moved_count(), 1);
1951        let dest = result.moves().next().unwrap().dest();
1952        assert!(dest.starts_with(&library), "landed at {}", dest.display());
1953        assert!(dest.exists());
1954        assert!(
1955            !dropped.exists(),
1956            "the original should have moved, not copied"
1957        );
1958
1959        // The row followed the file, so playing it afterwards still works.
1960        assert_eq!(
1961            db_path_of(&db, imported.track_ids[0]).as_deref(),
1962            dest.to_str()
1963        );
1964    }
1965
1966    /// Tags spell accents precomposed; a Mac-written folder holds them
1967    /// decomposed, and the filesystem opens either. A file organized into
1968    /// such a folder is stored as the directory spells it, or the next scan
1969    /// finds it under a path no row has and indexes it a second time.
1970    #[test]
1971    fn an_organized_file_is_stored_as_the_disk_spells_it() {
1972        use unicode_normalization::UnicodeNormalization;
1973        let db = test_db();
1974        let tmp = TempDir::new().unwrap();
1975        let library = tmp.path().join("Music");
1976        let artist_on_disk: String = "Björk".nfd().collect();
1977        std::fs::create_dir_all(library.join(&artist_on_disk)).unwrap();
1978
1979        let source = tmp.path().join("in/track.wav");
1980        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1981        crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
1982        let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
1983        assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
1984
1985        let result = execute_for_tracks(
1986            &db,
1987            &imported.track_ids,
1988            &format!("{}/%title%", "Björk".nfc().collect::<String>()),
1989            Some(&library),
1990        )
1991        .unwrap();
1992        assert_eq!(result.moved_count(), 1, "{:?}", result.failures().count());
1993
1994        crate::index::scanner::scan_folder(
1995            &db,
1996            &library,
1997            crate::index::scanner::ScanOptions::default(),
1998            None,
1999        );
2000        let paths: Vec<String> = db
2001            .conn
2002            .prepare("SELECT path FROM local_files")
2003            .unwrap()
2004            .query_map([], |r| r.get(0))
2005            .unwrap()
2006            .collect::<Result<_, _>>()
2007            .unwrap();
2008        assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2009        assert_eq!(
2010            db_path_of(&db, imported.track_ids[0]).as_ref(),
2011            paths.first()
2012        );
2013    }
2014
2015    /// The same for case, on a volume that does not tell `The Beatles` from
2016    /// `The beatles`.
2017    #[cfg(target_os = "macos")]
2018    #[test]
2019    fn an_organized_file_is_stored_in_the_folders_own_case() {
2020        let db = test_db();
2021        let tmp = TempDir::new().unwrap();
2022        let library = tmp.path().join("Music");
2023        std::fs::create_dir_all(library.join("The beatles")).unwrap();
2024        if !library.join("THE BEATLES").exists() {
2025            return;
2026        }
2027
2028        let source = tmp.path().join("in/track.wav");
2029        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2030        crate::test_utils::generate_wav(&source, 44100, 1, 0.2, 16);
2031        let imported = crate::index::scanner::import_paths(&db, std::slice::from_ref(&source));
2032        assert_eq!(imported.track_ids.len(), 1, "errors: {:?}", imported.errors);
2033
2034        let result = execute_for_tracks(
2035            &db,
2036            &imported.track_ids,
2037            "The Beatles/%title%",
2038            Some(&library),
2039        )
2040        .unwrap();
2041        assert_eq!(result.moved_count(), 1);
2042
2043        crate::index::scanner::scan_folder(
2044            &db,
2045            &library,
2046            crate::index::scanner::ScanOptions::default(),
2047            None,
2048        );
2049        let paths: Vec<String> = db
2050            .conn
2051            .prepare("SELECT path FROM local_files")
2052            .unwrap()
2053            .query_map([], |r| r.get(0))
2054            .unwrap()
2055            .collect::<Result<_, _>>()
2056            .unwrap();
2057        assert_eq!(paths.len(), 1, "one file, one row: {paths:?}");
2058        assert!(paths[0].contains("/The beatles/"), "{paths:?}");
2059    }
2060
2061    /// Generation is pure. It is what reruns on every keystroke, so if it ever
2062    /// starts touching the filesystem this is what says so: the destination is
2063    /// occupied and the source directory is full of cover art, and neither
2064    /// shows up until the disk is actually asked.
2065    #[test]
2066    fn generate_touches_no_files() {
2067        let db = test_db();
2068        let tmp = TempDir::new().unwrap();
2069        let source = tmp.path().join("src/track.flac");
2070        add_track(&db, &source, "Airbag", 1);
2071        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2072
2073        // Something is already sitting where the pattern points.
2074        let occupied = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2075        std::fs::create_dir_all(occupied.parent().unwrap()).unwrap();
2076        std::fs::write(&occupied, b"the good rip").unwrap();
2077
2078        let selection = resolve(&db, None).unwrap();
2079        let mut result = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2080
2081        // Pure pass: a move, no conflict, no ancillary — it has not looked.
2082        assert_eq!(result.moved_count(), 1);
2083        assert_eq!(result.conflicts().count(), 0);
2084        assert!(result.entries[0].ancillary.is_empty());
2085
2086        // Asking the disk is what finds both.
2087        check_against_disk(&mut result, true);
2088        assert_eq!(result.moved_count(), 0);
2089        assert_eq!(result.conflicts().count(), 1);
2090        assert_eq!(result.conflicts().next().unwrap().dest(), occupied);
2091    }
2092
2093    /// A caller with no library folder configured passes an empty base dir.
2094    /// Taken literally it means "relative to wherever this process happens to
2095    /// be", which plans and previews cleanly and then fails on every file, so
2096    /// it is refused before a plan exists to confirm.
2097    #[test]
2098    fn an_empty_base_dir_is_not_a_destination() {
2099        let db = test_db();
2100        add_track(&db, Path::new("/tmp/src/a.flac"), "Airbag", 1);
2101
2102        let err = preview(&db, "%title%", Some(Path::new("")), false).unwrap_err();
2103        assert!(matches!(err, OrganizeError::NoDestination));
2104
2105        let err = execute(&db, "%title%", Some(Path::new(""))).unwrap_err();
2106        assert!(matches!(err, OrganizeError::NoDestination));
2107    }
2108
2109    /// Resolving once and generating many times must agree with planning from
2110    /// scratch, or the preview would be lying about what execute will do.
2111    #[test]
2112    fn generate_agrees_with_a_full_plan() {
2113        let db = test_db();
2114        let tmp = TempDir::new().unwrap();
2115        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2116        add_track(&db, &tmp.path().join("src/b.flac"), "Karma Police", 2);
2117        let pattern = "%album artist%/%album%/%tracknumber%. %title%";
2118
2119        let selection = resolve(&db, None).unwrap();
2120        let mut generated = generate(&selection, pattern, tmp.path());
2121        check_against_disk(&mut generated, true);
2122        let planned = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2123
2124        assert_eq!(generated.entries.len(), planned.entries.len());
2125        for (a, b) in generated.entries.iter().zip(&planned.entries) {
2126            assert_eq!(a.from, b.from);
2127            assert_eq!(a.to, b.to);
2128            assert_eq!(a.outcome, b.outcome);
2129            assert_eq!(a.ancillary, b.ancillary);
2130        }
2131    }
2132
2133    /// One readdir per source directory, not one per file — the thing that made
2134    /// a preview over an album on a slow volume cost what it did.
2135    #[test]
2136    fn ancillary_is_scanned_once_per_directory() {
2137        let db = test_db();
2138        let tmp = TempDir::new().unwrap();
2139        for (i, title) in ["Airbag", "Karma Police", "Lucky"].iter().enumerate() {
2140            add_track(
2141                &db,
2142                &tmp.path().join(format!("src/{i}.flac")),
2143                title,
2144                i as i32 + 1,
2145            );
2146        }
2147        std::fs::write(tmp.path().join("src/cover.jpg"), b"art").unwrap();
2148
2149        let result = preview(
2150            &db,
2151            "%album artist%/%album%/%title%",
2152            Some(tmp.path()),
2153            true,
2154        )
2155        .unwrap();
2156
2157        // The cover travels with exactly one of them, not all three.
2158        let carrying: Vec<_> = result.moves().filter(|e| !e.ancillary.is_empty()).collect();
2159        assert_eq!(carrying.len(), 1);
2160        assert_eq!(carrying[0].ancillary.len(), 1);
2161    }
2162
2163    /// The disk pass must not mistake a file for its own obstacle. Nothing
2164    /// stops a caller planning against paths a previous run already moved, and
2165    /// a bare `exists()` on the destination says "occupied" for every one of
2166    /// them.
2167    #[test]
2168    fn a_file_already_at_its_destination_is_unchanged_not_a_conflict() {
2169        let db = test_db();
2170        let tmp = TempDir::new().unwrap();
2171        let pattern = "%album artist%/%album%/%title%";
2172        add_track(&db, &tmp.path().join("src/a.flac"), "Airbag", 1);
2173
2174        let moved = execute(&db, pattern, Some(tmp.path())).unwrap();
2175        assert_eq!(moved.moved_count(), 1);
2176
2177        // Plan again, from the rows as they now stand.
2178        let again = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2179        assert_eq!(again.conflicts().count(), 0);
2180        assert_eq!(again.unchanged_count(), 1);
2181    }
2182
2183    #[test]
2184    fn ancillary_files_stay_put_when_they_are_turned_off() {
2185        let db = test_db();
2186        let tmp = TempDir::new().unwrap();
2187        let source = tmp.path().join("src/a.flac");
2188        add_track(&db, &source, "Airbag", 1);
2189        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2190
2191        let selection = resolve(&db, None).unwrap();
2192        let mut off = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2193        check_against_disk(&mut off, false);
2194        assert!(off.moves().all(|e| e.ancillary.is_empty()));
2195
2196        let mut on = generate(&selection, "%album artist%/%album%/%title%", tmp.path());
2197        check_against_disk(&mut on, true);
2198        assert_eq!(on.moves().next().unwrap().ancillary.len(), 1);
2199    }
2200
2201    // ---- Collisions ----
2202
2203    #[test]
2204    fn colliding_destinations_leave_both_files_intact() {
2205        let db = test_db();
2206        let tmp = TempDir::new().unwrap();
2207        let first = tmp.path().join("src/a.flac");
2208        let second = tmp.path().join("src/b.flac");
2209        // Same title, different track numbers: two library rows, one destination.
2210        let first_id = add_track(&db, &first, "Airbag", 1);
2211        let second_id = add_track(&db, &second, "Airbag", 2);
2212        let second_bytes = std::fs::read(&second).unwrap();
2213
2214        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2215
2216        assert_eq!(result.moved_count(), 1);
2217
2218        // The loser is a row in the plan, flagged as a conflict and still
2219        // carrying the destination it lost — that is what a preview shows.
2220        let blocked = result.conflicts().next().unwrap();
2221        assert_eq!(result.conflicts().count(), 1);
2222        assert_eq!(blocked.from, second);
2223        assert_eq!(blocked.dest(), result.moves().next().unwrap().dest());
2224
2225        // The loser stays exactly where it was, byte for byte.
2226        assert!(second.exists());
2227        assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
2228        assert_eq!(
2229            db_path_of(&db, second_id).as_deref(),
2230            Some(second.to_str().unwrap())
2231        );
2232
2233        let dest = result.moves().next().unwrap().dest();
2234        assert_eq!(
2235            std::fs::read(dest).unwrap(),
2236            b"audio bytes for Airbag".to_vec()
2237        );
2238        assert_eq!(
2239            db_path_of(&db, first_id).as_deref(),
2240            Some(dest.to_str().unwrap())
2241        );
2242    }
2243
2244    #[test]
2245    fn existing_destination_is_never_overwritten() {
2246        let db = test_db();
2247        let tmp = TempDir::new().unwrap();
2248        let source = tmp.path().join("src/new.flac");
2249        add_track(&db, &source, "Airbag", 1);
2250
2251        // Something unrelated is already sitting at the destination.
2252        let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
2253        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2254        std::fs::write(&dest, b"the good rip").unwrap();
2255
2256        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2257        assert_eq!(result.moved_count(), 0);
2258
2259        // Flagged as a conflict against the occupied path, so a preview can say
2260        // what would have been overwritten before anyone presses the button.
2261        let blocked = result.conflicts().next().unwrap();
2262        assert_eq!(result.conflicts().count(), 1);
2263        assert_eq!(blocked.from, source);
2264        assert_eq!(blocked.dest(), dest);
2265        assert!(blocked.outcome.reason().unwrap().contains("overwritten"));
2266
2267        assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
2268        assert!(source.exists());
2269    }
2270
2271    /// `move_file` is the last line of defence: even handed a destination that exists,
2272    /// it refuses rather than replacing it.
2273    #[test]
2274    fn move_file_refuses_an_occupied_destination() {
2275        let tmp = TempDir::new().unwrap();
2276        let from = tmp.path().join("a.flac");
2277        let to = tmp.path().join("b.flac");
2278        std::fs::write(&from, b"source").unwrap();
2279        std::fs::write(&to, b"keep me").unwrap();
2280
2281        let err = move_file(&from, &to).unwrap_err();
2282        assert!(matches!(err, OrganizeError::DestinationExists(_)));
2283        assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
2284        assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
2285    }
2286
2287    #[cfg(any(target_os = "macos", target_os = "ios"))]
2288    #[test]
2289    fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
2290        let db = test_db();
2291        let tmp = TempDir::new().unwrap();
2292        let first = tmp.path().join("src/1.flac");
2293        let second = tmp.path().join("src/2.flac");
2294        std::fs::create_dir_all(first.parent().unwrap()).unwrap();
2295        for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
2296            std::fs::write(path, format!("audio bytes for {title}")).unwrap();
2297            let mut meta = sample_meta(title, "Radiohead", "OK Computer");
2298            meta.track_number = Some(number);
2299            meta.path = Some(path.to_string_lossy().into_owned());
2300            queries::upsert_track(&db.conn, &meta).unwrap();
2301        }
2302
2303        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2304        assert_eq!(result.moved_count(), 1);
2305        assert_eq!(result.failures().count(), 1);
2306        assert!(second.exists());
2307        assert_eq!(
2308            std::fs::read(&second).unwrap(),
2309            b"audio bytes for RAIN".to_vec()
2310        );
2311    }
2312
2313    /// A rename that only changes case has to go via a temporary name: reserving the
2314    /// destination would otherwise open the source file itself.
2315    /// Not on iOS: the simulator's sandboxed filesystem answers `stat` for
2316    /// `Rain.flac` with ENOENT while `open(O_CREAT|O_EXCL)` on the same name
2317    /// answers EEXIST — measured, both, in one directory. `paths_equal` reads
2318    /// `stat`, so the case-only rename it exists to catch cannot be detected
2319    /// there. Unverified on a device, where the volume is ordinary APFS.
2320    #[cfg(not(target_os = "ios"))]
2321    #[test]
2322    fn case_only_rename_keeps_the_file() {
2323        let tmp = TempDir::new().unwrap();
2324        let from = tmp.path().join("rain.flac");
2325        let to = tmp.path().join("Rain.flac");
2326        std::fs::write(&from, b"audio bytes").unwrap();
2327
2328        move_file(&from, &to).unwrap();
2329
2330        assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
2331        let names: Vec<String> = std::fs::read_dir(tmp.path())
2332            .unwrap()
2333            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2334            .collect();
2335        assert_eq!(names, vec!["Rain.flac".to_string()]);
2336    }
2337
2338    /// The cross-device path copies, flushes and verifies before unlinking the
2339    /// original, so an interrupted move can never leave a truncated file and no source.
2340    #[test]
2341    fn cross_device_copy_verifies_before_dropping_the_source() {
2342        let tmp = TempDir::new().unwrap();
2343        let from = tmp.path().join("a.flac");
2344        let to = tmp.path().join("b.flac");
2345        let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
2346        std::fs::write(&from, &bytes).unwrap();
2347        let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
2348
2349        copy_across_devices(&from, &to).unwrap();
2350
2351        assert!(!from.exists());
2352        assert_eq!(std::fs::read(&to).unwrap(), bytes);
2353        // Preserved, so scan_cache entries stay valid across a cross-device move.
2354        assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
2355        // No temporary left behind.
2356        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
2357            .unwrap()
2358            .filter(|e| {
2359                e.as_ref()
2360                    .unwrap()
2361                    .file_name()
2362                    .to_string_lossy()
2363                    .starts_with(".koan-")
2364            })
2365            .collect();
2366        assert!(leftovers.is_empty());
2367    }
2368
2369    #[test]
2370    fn free_space_check_ignores_same_device_moves() {
2371        let tmp = TempDir::new().unwrap();
2372        let from = tmp.path().join("a.flac");
2373        std::fs::write(&from, b"audio").unwrap();
2374        let moves = vec![FileMove {
2375            track_id: None,
2376            from,
2377            to: tmp.path().join("b.flac"),
2378            ancillary: Vec::new(),
2379        }];
2380        // A rename within one filesystem consumes no space.
2381        assert!(check_free_space(&moves, tmp.path()).is_ok());
2382    }
2383
2384    // ---- Bad patterns ----
2385
2386    #[test]
2387    fn unknown_function_refuses_the_move() {
2388        let db = test_db();
2389        let tmp = TempDir::new().unwrap();
2390        let source = tmp.path().join("src/test.flac");
2391        add_track(&db, &source, "Airbag", 1);
2392
2393        // $nun instead of $num.
2394        let result = execute(
2395            &db,
2396            "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
2397            Some(tmp.path()),
2398        )
2399        .unwrap();
2400        assert_eq!(result.moved_count(), 0);
2401        assert_eq!(result.failures().count(), 1);
2402        assert!(result.failure_messages()[0].contains("unknown function"));
2403        assert!(source.exists());
2404    }
2405
2406    /// An empty last component used to append the extension to the parent directory,
2407    /// pointing every track on an album at one file.
2408    #[test]
2409    fn empty_final_component_refuses_the_move() {
2410        let db = test_db();
2411        let tmp = TempDir::new().unwrap();
2412        let first = tmp.path().join("src/a.flac");
2413        let second = tmp.path().join("src/b.flac");
2414        add_track(&db, &first, "Airbag", 1);
2415        add_track(&db, &second, "Karma Police", 2);
2416
2417        // The conditional resolves to nothing, leaving a trailing separator.
2418        let result = execute(
2419            &db,
2420            "%album artist%/%album%/[%nonexistent field%]",
2421            Some(tmp.path()),
2422        )
2423        .unwrap();
2424
2425        assert_eq!(result.moved_count(), 0);
2426        assert_eq!(result.failures().count(), 2);
2427        assert!(first.exists());
2428        assert!(second.exists());
2429        assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
2430    }
2431
2432    #[test]
2433    fn long_title_is_truncated_rather_than_failing() {
2434        let db = test_db();
2435        let tmp = TempDir::new().unwrap();
2436        let source = tmp.path().join("src/test.flac");
2437        let title = "a".repeat(300);
2438        add_track(&db, &source, &title, 1);
2439
2440        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2441        assert_eq!(
2442            result.moved_count(),
2443            1,
2444            "errors: {:?}",
2445            result.failure_messages()
2446        );
2447        let name = result
2448            .moves()
2449            .next()
2450            .unwrap()
2451            .dest()
2452            .file_name()
2453            .unwrap()
2454            .to_string_lossy();
2455        assert!(name.len() <= MAX_FILE_NAME_BYTES);
2456        assert!(name.ends_with(".flac"));
2457        assert!(result.moves().next().unwrap().dest().exists());
2458    }
2459
2460    // ---- Directory cleanup ----
2461
2462    #[test]
2463    fn remove_empty_dirs_never_climbs_past_a_floor() {
2464        let tmp = TempDir::new().unwrap();
2465        let root = tmp.path().join("library");
2466        let nested = root.join("artist/album");
2467        std::fs::create_dir_all(&nested).unwrap();
2468
2469        remove_empty_dirs(&nested, std::slice::from_ref(&root));
2470
2471        assert!(!nested.exists());
2472        assert!(!root.join("artist").exists());
2473        assert!(root.exists(), "the library root must survive");
2474    }
2475
2476    #[test]
2477    fn remove_empty_dirs_stays_put_outside_any_floor() {
2478        let tmp = TempDir::new().unwrap();
2479        let outside = tmp.path().join("incoming/rip");
2480        std::fs::create_dir_all(&outside).unwrap();
2481
2482        remove_empty_dirs(&outside, &[tmp.path().join("library")]);
2483
2484        assert!(!outside.exists());
2485        assert!(
2486            tmp.path().join("incoming").exists(),
2487            "no floor means no climbing"
2488        );
2489    }
2490
2491    #[test]
2492    fn remove_empty_dirs_never_removes_a_floor_itself() {
2493        let tmp = TempDir::new().unwrap();
2494        let root = tmp.path().join("library");
2495        std::fs::create_dir_all(&root).unwrap();
2496
2497        remove_empty_dirs(&root, std::slice::from_ref(&root));
2498
2499        assert!(root.exists());
2500    }
2501
2502    // ---- Undo ----
2503
2504    #[test]
2505    fn undo_refuses_when_the_original_path_is_occupied() {
2506        let db = test_db();
2507        let tmp = TempDir::new().unwrap();
2508        let source = tmp.path().join("src/test.flac");
2509        add_track(&db, &source, "Airbag", 1);
2510
2511        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2512        let dest = result.moves().next().unwrap().dest().to_path_buf();
2513
2514        // A different rip lands at the vacated path before the undo.
2515        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2516        std::fs::write(&source, b"a completely different rip").unwrap();
2517
2518        let undone = undo(&db).unwrap();
2519        assert_eq!(undone.restored, 0);
2520        assert_eq!(undone.errors.len(), 1);
2521        assert_eq!(
2522            std::fs::read(&source).unwrap(),
2523            b"a completely different rip".to_vec()
2524        );
2525        assert!(dest.exists());
2526        // The entry stays in the log so it can be undone once the path is free.
2527        assert_eq!(log_rows(&db).len(), 1);
2528    }
2529
2530    #[test]
2531    fn undo_refuses_when_the_moved_file_has_been_replaced() {
2532        let db = test_db();
2533        let tmp = TempDir::new().unwrap();
2534        let source = tmp.path().join("src/test.flac");
2535        add_track(&db, &source, "Airbag", 1);
2536
2537        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2538        let dest = result.moves().next().unwrap().dest().to_path_buf();
2539        std::fs::write(&dest, b"replaced with something else entirely").unwrap();
2540
2541        let undone = undo(&db).unwrap();
2542        assert_eq!(undone.restored, 0);
2543        assert_eq!(undone.errors.len(), 1);
2544        assert!(!source.exists());
2545        assert!(dest.exists());
2546    }
2547
2548    /// A destination on a volume that has been unplugged cannot be told from
2549    /// one deleted, so its undo is kept for when the volume is back.
2550    #[test]
2551    fn undo_keeps_moves_onto_a_volume_that_is_away() {
2552        let db = test_db();
2553        let tmp = TempDir::new().unwrap();
2554        let source = tmp.path().join("src/test.flac");
2555        add_track(&db, &source, "Airbag", 1);
2556        let volume = tmp.path().join("volume");
2557        std::fs::create_dir_all(&volume).unwrap();
2558
2559        execute(&db, "%album artist%/%album%/%title%", Some(&volume)).unwrap();
2560        let away = tmp.path().join("away");
2561        std::fs::rename(&volume, &away).unwrap();
2562
2563        let floors = [volume.clone()];
2564        let undone = undo_within(&db, &floors).unwrap();
2565        assert_eq!((undone.restored, undone.errors.len()), (0, 1));
2566        assert_eq!(log_rows(&db).len(), 1, "kept for when the volume is back");
2567
2568        std::fs::rename(&away, &volume).unwrap();
2569        assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2570        assert!(source.exists());
2571    }
2572
2573    /// An organized album deleted by hand leaves nothing to undo, and must
2574    /// not hold the batches before it hostage.
2575    #[test]
2576    fn undo_forgets_an_album_deleted_by_hand() {
2577        let db = test_db();
2578        let tmp = TempDir::new().unwrap();
2579        let library = tmp.path().join("library");
2580        let older = tmp.path().join("src/older.flac");
2581        add_track(&db, &older, "Airbag", 1);
2582        std::fs::create_dir_all(library.join("keep")).unwrap();
2583        execute(&db, "%album artist%/%album%/%title%", Some(&library)).unwrap();
2584
2585        let newer = tmp.path().join("src/newer.flac");
2586        let mut meta = sample_meta("Lucky", "Radiohead", "OK Computer");
2587        meta.path = Some(newer.to_string_lossy().into_owned());
2588        // The first run emptied src/, and organize removes a directory it empties.
2589        std::fs::create_dir_all(newer.parent().unwrap()).unwrap();
2590        std::fs::write(&newer, b"lucky").unwrap();
2591        queries::upsert_track(&db.conn, &meta).unwrap();
2592        let result = run(
2593            &db,
2594            Selection::Paths(std::slice::from_ref(&newer)),
2595            "%album artist%/Elsewhere/%title%",
2596            &library,
2597        )
2598        .unwrap();
2599        let dest = result.moves().next().unwrap().dest().to_path_buf();
2600        std::fs::remove_dir_all(dest.parent().unwrap()).unwrap();
2601
2602        let floors = [library.clone()];
2603        let undone = undo_within(&db, &floors).unwrap();
2604        assert_eq!((undone.restored, undone.errors.len()), (0, 0));
2605        assert_eq!(undo_within(&db, &floors).unwrap().restored, 1);
2606        assert!(older.exists());
2607    }
2608
2609    /// A run that stopped after moving files but before rewriting the lists
2610    /// that name them leaves those lists as they were; undoing it must not
2611    /// rewrite them against where they were taken.
2612    #[test]
2613    fn undoing_an_interrupted_run_leaves_its_lists_intact() {
2614        let db = test_db();
2615        let tmp = TempDir::new().unwrap();
2616        let album = tmp.path().join("src/Album");
2617        add_track(&db, &album.join("01.flac"), "Airbag", 1);
2618        add_track(&db, &album.join("02.flac"), "Paranoid Android", 2);
2619        let list = album.join("album.m3u");
2620        std::fs::write(&list, "01.flac\n02.flac\n").unwrap();
2621
2622        // The first move, with the list beside it, and then nothing more.
2623        let base = tmp.path().join("library");
2624        let planned = plan(
2625            &db,
2626            Selection::All,
2627            "%album artist%/%album%/%title%",
2628            &base,
2629            true,
2630        )
2631        .unwrap();
2632        let first = planned
2633            .entries
2634            .iter()
2635            .filter_map(PlanEntry::as_move)
2636            .find(|m| m.ancillary.iter().any(|(from, _)| from == &list))
2637            .expect("the list travels with one of the moves");
2638        execute_single_move(
2639            &db,
2640            &first,
2641            "batch-interrupted",
2642            std::slice::from_ref(&base),
2643        )
2644        .unwrap();
2645
2646        let undone = undo_within(&db, &[base]).unwrap();
2647        assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2648        assert_eq!(
2649            std::fs::read_to_string(&list).unwrap(),
2650            "01.flac\n02.flac\n"
2651        );
2652    }
2653
2654    /// An M3U names its tracks by path. Moved with the album, or left where
2655    /// it is while the album moves, it is rewritten to follow, so the next
2656    /// scan finds the same playlist with every entry, and undo puts it back.
2657    #[test]
2658    fn playlist_files_follow_what_organize_moves() {
2659        use crate::index::playlist_files;
2660        let db = test_db();
2661        let tmp = TempDir::new().unwrap();
2662        let album = tmp.path().join("src/Album");
2663        let first = album.join("01.flac");
2664        let second = album.join("02.flac");
2665        let a = add_track(&db, &first, "Airbag", 1);
2666        let b = add_track(&db, &second, "Paranoid Android", 2);
2667        let beside = album.join("album.m3u");
2668        std::fs::write(&beside, "#EXTM3U\n01.flac\n02.flac\n").unwrap();
2669        let elsewhere = tmp.path().join("lists/mix.m3u8");
2670        std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();
2671        std::fs::write(&elsewhere, format!("{}\n", second.display())).unwrap();
2672        playlist_files::import(&db, &[beside.clone(), elsewhere.clone()], &[]);
2673
2674        let lists = |db: &Database| -> Vec<(i64, String, String, Vec<i64>)> {
2675            queries::list_playlists(&db.conn, queries::LOCAL_USER)
2676                .unwrap()
2677                .into_iter()
2678                .map(|p| {
2679                    let tracks = queries::playlist_track_ids(&db.conn, p.id).unwrap();
2680                    (p.id, p.uid, p.source_path.unwrap(), tracks)
2681                })
2682                .collect()
2683        };
2684        let before = lists(&db);
2685        assert_eq!(before.len(), 2);
2686
2687        let base = tmp.path().join("library");
2688        execute(&db, "%album artist%/%album%/%title%", Some(&base)).unwrap();
2689        let moved_list = base.join("Radiohead/OK Computer/album.m3u");
2690        assert_eq!(
2691            std::fs::read_to_string(&moved_list).unwrap(),
2692            "#EXTM3U\nAirbag.flac\nParanoid Android.flac\n"
2693        );
2694
2695        // What the next scan does with both files.
2696        playlist_files::import(
2697            &db,
2698            &[moved_list.clone(), elsewhere.clone()],
2699            &[tmp.path().to_path_buf()],
2700        );
2701        let after = lists(&db);
2702        assert_eq!(after.len(), 2, "{after:#?}");
2703        for (was, now) in before.iter().zip(&after) {
2704            assert_eq!((was.0, &was.1, &was.3), (now.0, &now.1, &now.3));
2705        }
2706        assert!(after.iter().any(|l| Path::new(&l.2) == moved_list));
2707        assert!(after.iter().any(|l| l.3 == [a, b]));
2708
2709        let undone = undo(&db).unwrap();
2710        assert!(undone.errors.is_empty(), "{:?}", undone.errors);
2711        assert_eq!(
2712            std::fs::read_to_string(&beside).unwrap(),
2713            "#EXTM3U\n01.flac\n02.flac\n"
2714        );
2715        assert_eq!(
2716            std::fs::read_to_string(&elsewhere).unwrap(),
2717            format!("{}\n", second.display())
2718        );
2719    }
2720
2721    /// `created_at` has one-second resolution, so batches are ordered by primary key.
2722    #[test]
2723    fn undo_takes_the_newest_batch_when_timestamps_tie() {
2724        let db = test_db();
2725        let tmp = TempDir::new().unwrap();
2726        let older = tmp.path().join("older.flac");
2727        let newer = tmp.path().join("newer.flac");
2728        std::fs::write(&older, b"older").unwrap();
2729        std::fs::write(&newer, b"newer").unwrap();
2730        let moved_older = tmp.path().join("moved-older.flac");
2731        let moved_newer = tmp.path().join("moved-newer.flac");
2732        std::fs::rename(&older, &moved_older).unwrap();
2733        std::fs::rename(&newer, &moved_newer).unwrap();
2734
2735        for (batch, from, to) in [
2736            ("batch-1", &older, &moved_older),
2737            ("batch-2", &newer, &moved_newer),
2738        ] {
2739            db.conn
2740                .execute(
2741                    "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
2742                     VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
2743                    params![
2744                        batch,
2745                        from.to_string_lossy().as_ref(),
2746                        to.to_string_lossy().as_ref()
2747                    ],
2748                )
2749                .unwrap();
2750        }
2751
2752        let undone = undo(&db).unwrap();
2753        assert_eq!(undone.restored, 1);
2754        assert!(newer.exists(), "the newest batch is the one undone");
2755        assert!(!older.exists());
2756    }
2757
2758    // ---- Database consistency ----
2759
2760    #[test]
2761    fn favourites_and_queue_state_follow_the_move() {
2762        let db = test_db();
2763        let tmp = TempDir::new().unwrap();
2764        let source = tmp.path().join("src/test.flac");
2765        add_track(&db, &source, "Airbag", 1);
2766        let source_str = source.to_string_lossy().into_owned();
2767
2768        let track = queries::track_id_by_path(&db.conn, &source_str)
2769            .unwrap()
2770            .unwrap();
2771        queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, track).unwrap();
2772        let item = PersistedQueueItem {
2773            uid: None,
2774            path: source_str.clone(),
2775            title: "Airbag".into(),
2776            artist: "Radiohead".into(),
2777            album_artist: "Radiohead".into(),
2778            album: "OK Computer".into(),
2779            year: None,
2780            codec: None,
2781            track_number: Some(1),
2782            disc: Some(1),
2783            duration_ms: None,
2784            db_id: None,
2785            played: None,
2786        };
2787        queries::save_playback_state(
2788            &db.conn,
2789            &[item],
2790            Default::default(),
2791            Some(&source_str),
2792            0,
2793            false,
2794        )
2795        .unwrap();
2796
2797        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2798        let dest = result.moves().next().unwrap().dest().to_path_buf();
2799        let dest_str = dest.to_string_lossy().into_owned();
2800
2801        let favourites =
2802            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2803        assert!(favourites.contains(&track));
2804        assert_eq!(
2805            queries::track_id_by_path(&db.conn, &dest_str).unwrap(),
2806            Some(track)
2807        );
2808
2809        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2810        assert_eq!(state.items[0].path, dest_str);
2811        assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
2812
2813        assert_eq!(undo(&db).unwrap().restored, 1);
2814
2815        let favourites =
2816            queries::load_favourites(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2817        assert!(favourites.contains(&track));
2818        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
2819        assert_eq!(state.items[0].path, source_str);
2820        assert_eq!(state.cursor_path.as_deref(), Some(source_str.as_str()));
2821    }
2822
2823    #[test]
2824    fn scan_cache_follows_the_move() {
2825        let db = test_db();
2826        let tmp = TempDir::new().unwrap();
2827        let source = tmp.path().join("src/test.flac");
2828        let id = add_track(&db, &source, "Airbag", 1);
2829        db.conn
2830            .execute(
2831                "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
2832                params![source.to_string_lossy().as_ref(), id],
2833            )
2834            .unwrap();
2835
2836        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2837        let dest = result
2838            .moves()
2839            .next()
2840            .unwrap()
2841            .dest()
2842            .to_string_lossy()
2843            .into_owned();
2844
2845        let cached: String = db
2846            .conn
2847            .query_row(
2848                "SELECT path FROM scan_cache WHERE track_id = ?1",
2849                params![id],
2850                |r| r.get(0),
2851            )
2852            .unwrap();
2853        assert_eq!(cached, dest);
2854    }
2855
2856    /// A failure partway through a batch must leave the rest of the run truthful: the
2857    /// files that moved are in the result and the log, the one that didn't is in neither.
2858    #[test]
2859    fn partial_failure_leaves_the_database_and_result_consistent() {
2860        let db = test_db();
2861        let tmp = TempDir::new().unwrap();
2862        let first = tmp.path().join("src/a.flac");
2863        let clash = tmp.path().join("src/b.flac");
2864        let third = tmp.path().join("src/c.flac");
2865        let first_id = add_track(&db, &first, "Airbag", 1);
2866        let clash_id = add_track(&db, &clash, "Airbag", 2);
2867        let third_id = add_track(&db, &third, "Karma Police", 3);
2868
2869        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2870
2871        assert_eq!(result.moved_count(), 2);
2872        assert_eq!(result.failures().count(), 1);
2873
2874        let logged = log_rows(&db);
2875        assert_eq!(logged.len(), 2);
2876        for file_move in result.moves() {
2877            assert!(file_move.dest().exists());
2878            assert!(
2879                logged
2880                    .iter()
2881                    .any(|(_, _, to)| Path::new(to) == file_move.dest())
2882            );
2883        }
2884
2885        // The failed file is untouched, in the filesystem and in the database.
2886        assert!(clash.exists());
2887        assert_eq!(
2888            db_path_of(&db, clash_id).as_deref(),
2889            Some(clash.to_str().unwrap())
2890        );
2891        assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
2892        assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
2893    }
2894
2895    /// The TUI organizes a selection of paths. Files the library doesn't know about
2896    /// still get a log entry, so the whole run can be undone.
2897    #[test]
2898    fn unknown_paths_are_logged_and_undoable() {
2899        let db = test_db();
2900        let tmp = TempDir::new().unwrap();
2901        let known = tmp.path().join("src/known.flac");
2902        add_track(&db, &known, "Airbag", 1);
2903
2904        let result = run(
2905            &db,
2906            Selection::Paths(std::slice::from_ref(&known)),
2907            "%album artist%/%album%/%title%",
2908            tmp.path(),
2909        )
2910        .unwrap();
2911
2912        assert_eq!(result.moved_count(), 1);
2913        let logged = log_rows(&db);
2914        assert_eq!(logged.len(), 1);
2915        assert!(logged[0].0.is_some());
2916
2917        assert_eq!(undo(&db).unwrap().restored, 1);
2918        assert!(known.exists());
2919    }
2920
2921    #[test]
2922    fn ancillary_files_move_with_the_album() {
2923        let db = test_db();
2924        let tmp = TempDir::new().unwrap();
2925        let source = tmp.path().join("src/test.flac");
2926        add_track(&db, &source, "Airbag", 1);
2927        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2928
2929        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2930        assert_eq!(result.moved_count(), 1);
2931        let dest_dir = result.moves().next().unwrap().dest().parent().unwrap();
2932        assert!(dest_dir.join("cover.jpg").exists());
2933
2934        // Both the audio and the artwork are in the log, so undo restores both.
2935        assert_eq!(log_rows(&db).len(), 2);
2936        assert_eq!(undo(&db).unwrap().restored, 2);
2937        assert!(source.parent().unwrap().join("cover.jpg").exists());
2938    }
2939
2940    // ---- Extension handling ----
2941
2942    #[test]
2943    fn extension_not_clobbered_by_dots_in_title() {
2944        // Regression: with_extension() replaces after the LAST dot,
2945        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
2946        let db = test_db();
2947        let tmp = TempDir::new().unwrap();
2948        let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2949        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2950        std::fs::write(&source, b"fake").unwrap();
2951
2952        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2953        meta.track_number = Some(10);
2954        meta.date = Some("2025-11-21".into());
2955        meta.path = Some(source.to_string_lossy().into_owned());
2956        queries::upsert_track(&db.conn, &meta).unwrap();
2957
2958        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2959        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2960        assert_eq!(result.moved_count(), 1);
2961        assert_eq!(
2962            result
2963                .moves()
2964                .next()
2965                .unwrap()
2966                .dest()
2967                .file_name()
2968                .unwrap()
2969                .to_string_lossy(),
2970            "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2971        );
2972    }
2973
2974    #[test]
2975    fn extension_preserved_for_tracknumber_dot() {
2976        // "0111. Bicep - TANGZ II" must not become "0111.flac"
2977        let db = test_db();
2978        let tmp = TempDir::new().unwrap();
2979        let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2980        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2981        std::fs::write(&source, b"fake").unwrap();
2982
2983        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2984        meta.track_number = Some(11);
2985        meta.date = Some("2025-11-21".into());
2986        meta.path = Some(source.to_string_lossy().into_owned());
2987        queries::upsert_track(&db.conn, &meta).unwrap();
2988
2989        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2990        let result = preview(&db, pattern, Some(tmp.path()), true).unwrap();
2991        assert_eq!(result.moved_count(), 1);
2992        assert_eq!(
2993            result
2994                .moves()
2995                .next()
2996                .unwrap()
2997                .dest()
2998                .file_name()
2999                .unwrap()
3000                .to_string_lossy(),
3001            "0111. Bicep - CHROMA 012 TANGZ II.flac"
3002        );
3003    }
3004}