Skip to main content

koan_core/
organize.rs

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