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#[derive(Debug)]
57pub struct OrganizeResult {
58    pub moves: Vec<FileMove>,
59    pub errors: Vec<(PathBuf, String)>,
60    pub skipped: usize,
61}
62
63#[derive(Debug)]
64pub struct FileMove {
65    /// The library track this file belongs to, or `None` for a file the library
66    /// doesn't know about. Either way the move is logged and can be undone.
67    pub track_id: Option<i64>,
68    pub from: PathBuf,
69    pub to: PathBuf,
70    pub ancillary: Vec<(PathBuf, PathBuf)>,
71}
72
73/// One `organize_log` row: id, original path, moved-to path, and the size and
74/// modification time the file had when it was moved.
75type UndoEntry = (i64, String, String, Option<i64>, Option<i64>);
76
77#[derive(Debug, Default)]
78pub struct UndoResult {
79    pub restored: usize,
80    pub errors: Vec<(PathBuf, String)>,
81}
82
83/// Which files an organize run covers.
84enum Selection<'a> {
85    All,
86    TrackIds(&'a [i64]),
87    Paths(&'a [PathBuf]),
88}
89
90/// Album fields a track inherits: both come from the album row, not the track row.
91#[derive(Default, Clone)]
92struct AlbumFacts {
93    date: Option<String>,
94    label: Option<String>,
95}
96
97/// A source file with the metadata its destination will be built from.
98struct ResolvedTrack {
99    source: PathBuf,
100    track_id: Option<i64>,
101    metadata: Result<TrackMetadata, String>,
102}
103
104/// Metadata provider backed by a HashMap, for evaluating format strings against track data.
105struct TrackMetadata {
106    fields: HashMap<String, String>,
107}
108
109impl TrackMetadata {
110    fn from_track_row(track: &TrackRow, album: &AlbumFacts) -> Self {
111        let mut fields = HashMap::new();
112        // Sanitize all field values so they can't inject path separators or illegal chars.
113        let s = sanitise_filename;
114        fields.insert("title".into(), s(&track.title));
115        fields.insert("artist".into(), s(&track.artist_name));
116        fields.insert("album artist".into(), s(&track.album_artist_name));
117        fields.insert("album".into(), s(&track.album_title));
118        if let Some(n) = track.track_number {
119            fields.insert("tracknumber".into(), format!("{n:02}"));
120        }
121        if let Some(d) = track.disc {
122            fields.insert("discnumber".into(), d.to_string());
123        }
124        if let Some(ref date) = album.date {
125            fields.insert("date".into(), s(date));
126        }
127        if let Some(ref label) = album.label {
128            fields.insert("label".into(), s(label));
129        }
130        if let Some(ref codec) = track.codec {
131            fields.insert("codec".into(), s(codec));
132        }
133        if let Some(ref genre) = track.genre {
134            fields.insert("genre".into(), s(genre));
135        }
136        Self { fields }
137    }
138
139    /// Build metadata directly from file tags, for files the library doesn't know about.
140    /// Populates exactly the same field set as `from_track_row` so a preview and the
141    /// move it authorises can never resolve to different paths.
142    fn from_file_meta(meta: &queries::TrackMeta) -> Self {
143        let mut fields = HashMap::new();
144        let s = sanitise_filename;
145        fields.insert("title".into(), s(&meta.title));
146        fields.insert("artist".into(), s(&meta.artist));
147        fields.insert(
148            "album artist".into(),
149            s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
150        );
151        fields.insert("album".into(), s(&meta.album));
152        if let Some(n) = meta.track_number {
153            fields.insert("tracknumber".into(), format!("{n:02}"));
154        }
155        if let Some(d) = meta.disc {
156            fields.insert("discnumber".into(), d.to_string());
157        }
158        if let Some(ref date) = meta.date {
159            fields.insert("date".into(), s(date));
160        }
161        if let Some(ref label) = meta.label {
162            fields.insert("label".into(), s(label));
163        }
164        if let Some(ref codec) = meta.codec {
165            fields.insert("codec".into(), s(codec));
166        }
167        if let Some(ref genre) = meta.genre {
168            fields.insert("genre".into(), s(genre));
169        }
170        Self { fields }
171    }
172}
173
174impl MetadataProvider for TrackMetadata {
175    fn get_field(&self, name: &str) -> Option<String> {
176        self.fields.get(name).cloned()
177    }
178}
179
180/// Sanitize each component of a relative path independently.
181///
182/// An empty, `.` or `..` component is an error, not something to skip: dropping one
183/// silently collapses a whole album onto a single filename, and the tracks that land
184/// there overwrite each other.
185fn sanitize_relative_path(rel: &str) -> Result<PathBuf, String> {
186    let mut result = PathBuf::new();
187    for part in rel.split(['/', std::path::MAIN_SEPARATOR]) {
188        let sanitized = sanitise_filename(part);
189        if sanitized.is_empty() {
190            return Err(format!(
191                "format string produced an empty path component: {rel:?}"
192            ));
193        }
194        if sanitized == "." || sanitized == ".." {
195            return Err(format!(
196                "format string produced a relative path component: {rel:?}"
197            ));
198        }
199        result.push(sanitized);
200    }
201    if result.as_os_str().is_empty() {
202        return Err("format string produced an empty path".into());
203    }
204    Ok(result)
205}
206
207/// Load every album's date and label in one query — both are fields a format string
208/// can reference, and both live on the album row.
209fn load_album_facts(conn: &Connection) -> Result<HashMap<i64, AlbumFacts>, OrganizeError> {
210    let mut stmt = conn.prepare("SELECT id, date, label FROM albums")?;
211    let rows = stmt.query_map([], |row| {
212        Ok((
213            row.get::<_, i64>(0)?,
214            AlbumFacts {
215                date: row.get(1)?,
216                label: row.get(2)?,
217            },
218        ))
219    })?;
220    let mut map = HashMap::new();
221    for row in rows {
222        let (id, facts) = row?;
223        map.insert(id, facts);
224    }
225    Ok(map)
226}
227
228/// Find ancillary files in the same directory as a track.
229fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
230    let mut files = Vec::new();
231    let Ok(entries) = std::fs::read_dir(track_dir) else {
232        return files;
233    };
234
235    for entry in entries.flatten() {
236        let path = entry.path();
237        if !path.is_file() {
238            continue;
239        }
240        let name = path
241            .file_name()
242            .and_then(|n| n.to_str())
243            .unwrap_or_default()
244            .to_lowercase();
245
246        // Check exact name matches.
247        if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
248            files.push(path);
249            continue;
250        }
251        // Check extension matches.
252        if let Some(ext) = path.extension().and_then(|e| e.to_str())
253            && ANCILLARY_EXTENSIONS
254                .iter()
255                .any(|e| ext.eq_ignore_ascii_case(e))
256        {
257            files.push(path);
258        }
259    }
260    files.sort();
261    files
262}
263
264/// The destination names a run has already committed to, so two files can never be
265/// planned onto the same path.
266#[derive(Default)]
267struct DestinationLedger {
268    taken: HashSet<String>,
269}
270
271impl DestinationLedger {
272    /// macOS and Windows filesystems are case-insensitive by default, so `Rain.flac`
273    /// and `RAIN.flac` are one file there and must collide here too.
274    fn key(path: &Path) -> String {
275        let key = path.to_string_lossy().into_owned();
276        if cfg!(any(target_os = "macos", target_os = "windows")) {
277            key.to_lowercase()
278        } else {
279            key
280        }
281    }
282
283    /// Returns false if this destination is already spoken for.
284    fn claim(&mut self, path: &Path) -> bool {
285        self.taken.insert(Self::key(path))
286    }
287}
288
289/// Plan a single file move: format pattern, sanitize path, build dest, find ancillary files.
290///
291/// Returns `Ok(None)` when source == dest (skipped), `Ok(Some(FileMove))` for a planned move,
292/// or `Err(String)` for errors that should be collected by the caller.
293fn plan_single_move(
294    source: &Path,
295    track_id: Option<i64>,
296    metadata: &TrackMetadata,
297    pattern: &str,
298    base_dir: &Path,
299    dests: &mut DestinationLedger,
300    planned_ancillary: &mut HashSet<PathBuf>,
301) -> Result<Option<FileMove>, String> {
302    let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
303
304    if relative.is_empty() {
305        return Err("format string produced empty path".into());
306    }
307
308    let sanitized = sanitize_relative_path(&relative)?;
309
310    // Preserve the original file extension.
311    // Don't use with_extension() — it replaces after the LAST dot, which
312    // destroys titles containing dots (e.g. "0111. Bicep - TANGZ II" → "0111.flac").
313    let ext = source
314        .extension()
315        .and_then(|e| e.to_str())
316        .unwrap_or("flac");
317    let stem = sanitized
318        .file_name()
319        .and_then(|n| n.to_str())
320        .ok_or_else(|| "format string produced an unusable file name".to_string())?;
321    // Leave room for the extension, so a long title is shortened rather than
322    // previewing cleanly and failing with ENAMETOOLONG at move time.
323    let stem = truncate_bytes(stem, MAX_FILE_NAME_BYTES.saturating_sub(ext.len() + 1)).trim_end();
324    if stem.is_empty() {
325        return Err("format string produced an empty file name".into());
326    }
327    let mut dest = base_dir.to_path_buf();
328    if let Some(parent) = sanitized.parent() {
329        dest.push(parent);
330    }
331    dest.push(format!("{stem}.{ext}"));
332
333    // Safety: verify dest stays under base_dir (defense-in-depth against path traversal).
334    if !dest.starts_with(base_dir) {
335        return Err(format!(
336            "path traversal blocked: destination {} escapes base dir {}",
337            dest.display(),
338            base_dir.display()
339        ));
340    }
341
342    if source == dest {
343        // Already in place — claim the name anyway so nothing else targets it.
344        dests.claim(&dest);
345        return Ok(None);
346    }
347
348    if !dests.claim(&dest) {
349        return Err(format!(
350            "two files resolve to the same destination: {}",
351            dest.display()
352        ));
353    }
354
355    // A destination that resolves to the source itself is a case-only rename, which
356    // is a real move; anything else already there would be overwritten.
357    if dest.exists() && !paths_equal(source, &dest) {
358        return Err(format!(
359            "destination already exists: {} (moving here would overwrite it)",
360            dest.display()
361        ));
362    }
363
364    // Plan ancillary moves — move files in source dir to dest dir.
365    let source_dir = source.parent().unwrap_or(Path::new("."));
366    let dest_dir = dest.parent().unwrap_or(Path::new("."));
367    let mut ancillary = Vec::new();
368
369    if source_dir != dest_dir {
370        for anc_path in find_ancillary_files(source_dir) {
371            if planned_ancillary.contains(&anc_path) {
372                continue;
373            }
374            let Some(anc_name) = anc_path.file_name() else {
375                continue;
376            };
377            let anc_dest = dest_dir.join(anc_name);
378            // Artwork already at the destination is left alone rather than
379            // overwritten; the audio file is what matters here.
380            if anc_dest.exists() || !dests.claim(&anc_dest) {
381                continue;
382            }
383            planned_ancillary.insert(anc_path.clone());
384            ancillary.push((anc_path, anc_dest));
385        }
386    }
387
388    Ok(Some(FileMove {
389        track_id,
390        from: source.to_path_buf(),
391        to: dest,
392        ancillary,
393    }))
394}
395
396fn resolve_from_rows(rows: Vec<TrackRow>, albums: &HashMap<i64, AlbumFacts>) -> Vec<ResolvedTrack> {
397    let fallback = AlbumFacts::default();
398    rows.into_iter()
399        .filter_map(|track| {
400            let source = PathBuf::from(track.path.as_ref()?);
401            if !source.exists() {
402                return None; // file gone, skip
403            }
404            let facts = track
405                .album_id
406                .and_then(|id| albums.get(&id))
407                .unwrap_or(&fallback);
408            Some(ResolvedTrack {
409                source,
410                track_id: Some(track.id),
411                metadata: Ok(TrackMetadata::from_track_row(&track, facts)),
412            })
413        })
414        .collect()
415}
416
417fn read_tag_metadata(source: &Path) -> Result<TrackMetadata, String> {
418    if !source.exists() {
419        return Err("file not found".to_string());
420    }
421    crate::index::metadata::read_metadata(source)
422        .map(|m| TrackMetadata::from_file_meta(&m))
423        .map_err(|e| format!("metadata error: {e}"))
424}
425
426/// Resolve arbitrary paths: library rows where we have them, file tags otherwise.
427/// Preview and execute both come through here, so both see the same metadata.
428fn resolve_from_paths(
429    db: &Database,
430    paths: &[PathBuf],
431    albums: &HashMap<i64, AlbumFacts>,
432) -> Result<Vec<ResolvedTrack>, OrganizeError> {
433    use rayon::prelude::*;
434
435    let path_strings: Vec<String> = paths
436        .iter()
437        .map(|p| p.to_string_lossy().into_owned())
438        .collect();
439    let known = queries::tracks_by_paths(&db.conn, &path_strings)?;
440
441    // Tag reads are the expensive part, so only the unknown files pay for them.
442    let mut tagged: HashMap<PathBuf, Result<TrackMetadata, String>> = paths
443        .par_iter()
444        .filter(|p| !known.contains_key(p.to_string_lossy().as_ref()))
445        .map(|p| (p.clone(), read_tag_metadata(p)))
446        .collect();
447
448    let fallback = AlbumFacts::default();
449    let mut resolved = Vec::with_capacity(paths.len());
450    for (path, path_str) in paths.iter().zip(&path_strings) {
451        let entry = match known.get(path_str) {
452            Some(track) => {
453                let facts = track
454                    .album_id
455                    .and_then(|id| albums.get(&id))
456                    .unwrap_or(&fallback);
457                ResolvedTrack {
458                    source: path.clone(),
459                    track_id: Some(track.id),
460                    metadata: Ok(TrackMetadata::from_track_row(track, facts)),
461                }
462            }
463            None => ResolvedTrack {
464                source: path.clone(),
465                track_id: None,
466                metadata: tagged
467                    .remove(path)
468                    .unwrap_or_else(|| Err("duplicate path in selection".to_string())),
469            },
470        };
471        resolved.push(entry);
472    }
473    Ok(resolved)
474}
475
476/// Build the list of moves. Every entry point plans through here, so a preview and the
477/// execute that follows it produce the same destinations from the same metadata.
478fn plan(
479    db: &Database,
480    selection: Selection<'_>,
481    pattern: &str,
482    base_dir: &Path,
483) -> Result<OrganizeResult, OrganizeError> {
484    let albums = load_album_facts(&db.conn)?;
485
486    let resolved = match selection {
487        Selection::All => resolve_from_rows(queries::all_tracks(&db.conn)?, &albums),
488        Selection::TrackIds(ids) => {
489            let mut rows = Vec::with_capacity(ids.len());
490            for &id in ids {
491                if let Some(row) = queries::get_track_row(&db.conn, id)? {
492                    rows.push(row);
493                }
494            }
495            resolve_from_rows(rows, &albums)
496        }
497        Selection::Paths(paths) => resolve_from_paths(db, paths, &albums)?,
498    };
499
500    let mut moves = Vec::new();
501    let mut errors = Vec::new();
502    let mut skipped = 0;
503    let mut dests = DestinationLedger::default();
504    let mut planned_ancillary: HashSet<PathBuf> = HashSet::new();
505
506    for entry in resolved {
507        let metadata = match entry.metadata {
508            Ok(m) => m,
509            Err(msg) => {
510                errors.push((entry.source, msg));
511                continue;
512            }
513        };
514        match plan_single_move(
515            &entry.source,
516            entry.track_id,
517            &metadata,
518            pattern,
519            base_dir,
520            &mut dests,
521            &mut planned_ancillary,
522        ) {
523            Ok(Some(file_move)) => moves.push(file_move),
524            Ok(None) => skipped += 1,
525            Err(msg) => errors.push((entry.source, msg)),
526        }
527    }
528
529    Ok(OrganizeResult {
530        moves,
531        errors,
532        skipped,
533    })
534}
535
536/// Plan, then carry out the moves: each file's database rows and its rename land
537/// together or not at all.
538fn run(
539    db: &Database,
540    selection: Selection<'_>,
541    pattern: &str,
542    base_dir: &Path,
543) -> Result<OrganizeResult, OrganizeError> {
544    let mut result = plan(db, selection, pattern, base_dir)?;
545
546    if result.moves.is_empty() {
547        return Ok(result);
548    }
549
550    check_free_space(&result.moves, base_dir)?;
551
552    let batch_id = batch_id();
553    let floors = cleanup_floors(Some(base_dir));
554    let mut completed_moves = Vec::new();
555    let mut new_errors = Vec::new();
556
557    for file_move in result.moves.drain(..) {
558        match execute_single_move(db, &file_move, &batch_id, &floors) {
559            Ok(()) => match verify_move(&file_move) {
560                Ok(()) => completed_moves.push(file_move),
561                Err(msg) => new_errors.push((file_move.from, msg)),
562            },
563            Err(e) => {
564                new_errors.push((file_move.from, e.to_string()));
565            }
566        }
567    }
568
569    result.moves = completed_moves;
570    result.errors.extend(new_errors);
571    Ok(result)
572}
573
574/// Preview what would happen without moving files.
575pub fn preview(
576    db: &Database,
577    pattern: &str,
578    base_dir: Option<&Path>,
579) -> Result<OrganizeResult, OrganizeError> {
580    let base = resolve_base_dir(base_dir)?;
581    plan(db, Selection::All, pattern, &base)
582}
583
584/// Execute the moves: rename files, update DB, log for undo.
585pub fn execute(
586    db: &Database,
587    pattern: &str,
588    base_dir: Option<&Path>,
589) -> Result<OrganizeResult, OrganizeError> {
590    let base = resolve_base_dir(base_dir)?;
591    run(db, Selection::All, pattern, &base)
592}
593
594/// Preview organize for a specific set of tracks.
595pub fn preview_for_tracks(
596    db: &Database,
597    track_ids: &[i64],
598    pattern: &str,
599    base_dir: Option<&Path>,
600) -> Result<OrganizeResult, OrganizeError> {
601    let base = resolve_base_dir(base_dir)?;
602    plan(db, Selection::TrackIds(track_ids), pattern, &base)
603}
604
605/// Execute organize for a specific set of tracks.
606pub fn execute_for_tracks(
607    db: &Database,
608    track_ids: &[i64],
609    pattern: &str,
610    base_dir: Option<&Path>,
611) -> Result<OrganizeResult, OrganizeError> {
612    let base = resolve_base_dir(base_dir)?;
613    run(db, Selection::TrackIds(track_ids), pattern, &base)
614}
615
616/// Preview organize for file paths, which may or may not be in the library.
617pub fn preview_for_paths(
618    paths: &[PathBuf],
619    pattern: &str,
620    base_dir: Option<&Path>,
621) -> Result<OrganizeResult, OrganizeError> {
622    let db = Database::open_default()?;
623    let base = resolve_base_dir(base_dir)?;
624    plan(&db, Selection::Paths(paths), pattern, &base)
625}
626
627/// Execute organize for file paths. Requires the library database: without it there is
628/// nowhere to record the moves, and an organize that can't be undone isn't offered.
629pub fn execute_for_paths(
630    paths: &[PathBuf],
631    pattern: &str,
632    base_dir: Option<&Path>,
633) -> Result<OrganizeResult, OrganizeError> {
634    let db = Database::open_default()?;
635    let base = resolve_base_dir(base_dir)?;
636    run(&db, Selection::Paths(paths), pattern, &base)
637}
638
639/// Verify a move actually happened — dest exists and source is gone.
640fn verify_move(file_move: &FileMove) -> Result<(), String> {
641    if !file_move.to.exists() {
642        return Err(format!(
643            "destination not found after move: {}",
644            file_move.to.display()
645        ));
646    }
647    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
648        return Err(format!(
649            "source still exists after move: {}",
650            file_move.from.display()
651        ));
652    }
653    Ok(())
654}
655
656fn log_move(
657    conn: &Connection,
658    batch_id: &str,
659    track_id: Option<i64>,
660    from: &Path,
661    to: &Path,
662    size: Option<u64>,
663    mtime: Option<i64>,
664) -> Result<(), OrganizeError> {
665    conn.execute(
666        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, size_bytes, mtime)
667         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
668        params![
669            batch_id,
670            track_id,
671            from.to_string_lossy().as_ref(),
672            to.to_string_lossy().as_ref(),
673            size.map(|s| s as i64),
674            mtime,
675        ],
676    )?;
677    Ok(())
678}
679
680/// Point every path-keyed row at the file's new location.
681///
682/// `tracks.path` and `scan_cache.path` are UNIQUE, so a move onto a path another row
683/// already claims fails here — inside the caller's transaction, before the file itself
684/// is touched.
685fn rewrite_path_references(conn: &Connection, old: &Path, new: &Path) -> Result<(), OrganizeError> {
686    let old_lossy = old.to_string_lossy();
687    let new_lossy = new.to_string_lossy();
688    let old_path = old_lossy.as_ref();
689    let new_path = new_lossy.as_ref();
690
691    conn.execute(
692        "UPDATE tracks SET path = ?1 WHERE path = ?2",
693        params![new_path, old_path],
694    )?;
695    conn.execute(
696        "UPDATE tracks SET cached_path = ?1 WHERE cached_path = ?2",
697        params![new_path, old_path],
698    )?;
699    conn.execute(
700        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
701        params![new_path, old_path],
702    )?;
703    // The destination may already be starred from an earlier move; OR REPLACE
704    // leaves exactly one favourite row rather than failing on the primary key.
705    conn.execute(
706        "UPDATE OR REPLACE favourites SET track_path = ?1 WHERE track_path = ?2",
707        params![new_path, old_path],
708    )?;
709    conn.execute(
710        "UPDATE queue_snapshots SET cursor_path = ?1 WHERE cursor_path = ?2",
711        params![new_path, old_path],
712    )?;
713    conn.execute(
714        "UPDATE playback_state SET cursor_id = ?1 WHERE cursor_id = ?2",
715        params![new_path, old_path],
716    )?;
717    rewrite_queue_json(conn, "queue_snapshots", old_path, new_path)?;
718    rewrite_queue_json(conn, "playback_state", old_path, new_path)?;
719    Ok(())
720}
721
722/// Rewrite paths inside a table's serialized queue.
723fn rewrite_queue_json(
724    conn: &Connection,
725    table: &str,
726    old_path: &str,
727    new_path: &str,
728) -> Result<(), OrganizeError> {
729    let mut stmt = conn.prepare(&format!(
730        "SELECT id, queue_json FROM {table} WHERE instr(queue_json, ?1) > 0"
731    ))?;
732    let rows: Vec<(i64, String)> = stmt
733        .query_map(params![old_path], |row| Ok((row.get(0)?, row.get(1)?)))?
734        .collect::<Result<Vec<_>, _>>()?;
735    drop(stmt);
736
737    for (id, json) in rows {
738        let Ok(mut items) = serde_json::from_str::<Vec<PersistedQueueItem>>(&json) else {
739            continue;
740        };
741        let mut changed = false;
742        for item in &mut items {
743            if item.path == old_path {
744                item.path = new_path.to_string();
745                changed = true;
746            }
747        }
748        if !changed {
749            continue;
750        }
751        let Ok(updated) = serde_json::to_string(&items) else {
752            continue;
753        };
754        conn.execute(
755            &format!("UPDATE {table} SET queue_json = ?1 WHERE id = ?2"),
756            params![updated, id],
757        )?;
758    }
759    Ok(())
760}
761
762/// Execute a single file move: write the database rows first, then move the file.
763/// A constraint violation therefore aborts before anything on disk changes, and a
764/// failed rename rolls the rows back.
765fn execute_single_move(
766    db: &Database,
767    file_move: &FileMove,
768    batch_id: &str,
769    floors: &[PathBuf],
770) -> Result<(), OrganizeError> {
771    if let Some(parent) = file_move.to.parent() {
772        std::fs::create_dir_all(parent)?;
773    }
774
775    let source_meta = std::fs::metadata(&file_move.from)?;
776    let size = source_meta.len();
777    let mtime = mtime_secs(&source_meta);
778
779    let tx = db.conn.unchecked_transaction()?;
780    log_move(
781        &tx,
782        batch_id,
783        file_move.track_id,
784        &file_move.from,
785        &file_move.to,
786        Some(size),
787        mtime,
788    )?;
789    rewrite_path_references(&tx, &file_move.from, &file_move.to)?;
790
791    // Dropping `tx` on the way out of this `?` rolls the rows back.
792    move_file(&file_move.from, &file_move.to)?;
793
794    let mut moved_ancillary: Vec<(&PathBuf, &PathBuf)> = Vec::new();
795    let mut failure = None;
796    for (anc_from, anc_to) in &file_move.ancillary {
797        if let Some(parent) = anc_to.parent()
798            && std::fs::create_dir_all(parent).is_err()
799        {
800            continue;
801        }
802        // Best-effort — artwork that won't move doesn't hold up the audio file.
803        match move_file(anc_from, anc_to) {
804            Ok(()) => {
805                moved_ancillary.push((anc_from, anc_to));
806                let meta = std::fs::metadata(anc_to).ok();
807                if let Err(e) = log_move(
808                    &tx,
809                    batch_id,
810                    None,
811                    anc_from,
812                    anc_to,
813                    meta.as_ref().map(|m| m.len()),
814                    meta.as_ref().and_then(mtime_secs),
815                ) {
816                    failure = Some(e);
817                    break;
818                }
819            }
820            Err(e) => log::warn!(
821                "failed to move ancillary file {}: {}",
822                anc_from.display(),
823                e
824            ),
825        }
826    }
827
828    let outcome = match failure {
829        Some(e) => Err(e),
830        None => tx.commit().map_err(OrganizeError::from),
831    };
832
833    if let Err(e) = outcome {
834        // The rows rolled back, so nothing records these files as moved and nothing
835        // could undo them. Put them back.
836        for (anc_from, anc_to) in moved_ancillary {
837            let _ = move_file(anc_to, anc_from);
838        }
839        let _ = move_file(&file_move.to, &file_move.from);
840        return Err(e);
841    }
842
843    if let Some(source_dir) = file_move.from.parent() {
844        remove_empty_dirs(source_dir, floors);
845    }
846
847    Ok(())
848}
849
850/// Undo the most recent organize batch.
851///
852/// Each entry is restored only when the original path is still free and the moved file
853/// is still the one that was logged. Anything else is reported and left in the log, so
854/// a single blocked file doesn't strand the rest of the batch.
855pub fn undo(db: &Database) -> Result<UndoResult, OrganizeError> {
856    // Newest batch by primary key: created_at only has one-second resolution, so two
857    // batches in the same second would tie.
858    let batch_id: String = db
859        .conn
860        .query_row(
861            "SELECT batch_id FROM organize_log ORDER BY id DESC LIMIT 1",
862            [],
863            |row| row.get(0),
864        )
865        .map_err(|_| OrganizeError::NothingToUndo)?;
866
867    let mut stmt = db.conn.prepare(
868        "SELECT id, from_path, to_path, size_bytes, mtime FROM organize_log
869         WHERE batch_id = ?1 ORDER BY id DESC",
870    )?;
871
872    let entries: Vec<UndoEntry> = stmt
873        .query_map(params![batch_id], |row| {
874            Ok((
875                row.get(0)?,
876                row.get(1)?,
877                row.get(2)?,
878                row.get(3)?,
879                row.get(4)?,
880            ))
881        })?
882        .collect::<Result<Vec<_>, _>>()?;
883    drop(stmt);
884
885    let floors = cleanup_floors(None);
886    let mut result = UndoResult::default();
887
888    for (log_id, from_path, to_path, size, mtime) in &entries {
889        let to = Path::new(to_path);
890        let from = Path::new(from_path);
891
892        if !to.exists() {
893            // Already moved back or deleted — drop the log row.
894            db.conn
895                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
896            continue;
897        }
898
899        if from.exists() && !paths_equal(from, to) {
900            result.errors.push((
901                from.to_path_buf(),
902                format!(
903                    "another file now occupies the original path; {} left in place",
904                    to.display()
905                ),
906            ));
907            continue;
908        }
909
910        if let Err(msg) = matches_logged_file(to, *size, *mtime) {
911            result.errors.push((to.to_path_buf(), msg));
912            continue;
913        }
914
915        if let Some(parent) = from.parent()
916            && let Err(e) = std::fs::create_dir_all(parent)
917        {
918            result.errors.push((from.to_path_buf(), e.to_string()));
919            continue;
920        }
921
922        let tx = db.conn.unchecked_transaction()?;
923        if let Err(e) = rewrite_path_references(&tx, to, from) {
924            result.errors.push((to.to_path_buf(), e.to_string()));
925            continue;
926        }
927        if let Err(e) = move_file(to, from) {
928            result.errors.push((to.to_path_buf(), e.to_string()));
929            continue;
930        }
931        if let Err(e) = tx.execute("DELETE FROM organize_log WHERE id = ?1", params![log_id]) {
932            let _ = move_file(from, to);
933            result.errors.push((to.to_path_buf(), e.to_string()));
934            continue;
935        }
936        if let Err(e) = tx.commit() {
937            let _ = move_file(from, to);
938            result.errors.push((to.to_path_buf(), e.to_string()));
939            continue;
940        }
941
942        if let Some(parent) = to.parent() {
943            remove_empty_dirs(parent, &floors);
944        }
945
946        result.restored += 1;
947    }
948
949    Ok(result)
950}
951
952/// Confirm the file at a logged destination is still the file that was moved there.
953/// Rows written before size/mtime were recorded carry neither and are accepted.
954fn matches_logged_file(path: &Path, size: Option<i64>, mtime: Option<i64>) -> Result<(), String> {
955    let (Some(size), Some(mtime)) = (size, mtime) else {
956        return Ok(());
957    };
958    let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
959    if meta.len() != size as u64 {
960        return Err(format!(
961            "{} has changed since it was moved (size differs); left in place",
962            path.display()
963        ));
964    }
965    if mtime_secs(&meta).is_some_and(|current| current != mtime) {
966        return Err(format!(
967            "{} has changed since it was moved (modification time differs); left in place",
968            path.display()
969        ));
970    }
971    Ok(())
972}
973
974fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
975    meta.modified()
976        .ok()?
977        .duration_since(UNIX_EPOCH)
978        .ok()
979        .map(|d| d.as_secs() as i64)
980}
981
982/// Directories an empty-directory sweep must never remove or climb past.
983fn cleanup_floors(base: Option<&Path>) -> Vec<PathBuf> {
984    let mut floors: Vec<PathBuf> = base.map(Path::to_path_buf).into_iter().collect();
985    if let Ok(config) = crate::config::Config::load() {
986        floors.extend(config.library.folders);
987    }
988    floors
989}
990
991/// Remove the directory a file just left, and its now-empty parents — but never a
992/// configured library root, and never anything above one.
993fn remove_empty_dirs(start: &Path, floors: &[PathBuf]) {
994    let mut current = start.to_path_buf();
995    loop {
996        if floors.iter().any(|floor| floor == &current) {
997            break;
998        }
999        let empty = std::fs::read_dir(&current)
1000            .map(|mut d| d.next().is_none())
1001            .unwrap_or(false);
1002        if !empty || std::fs::remove_dir(&current).is_err() {
1003            break;
1004        }
1005        let Some(parent) = current.parent() else {
1006            break;
1007        };
1008        // Only keep climbing inside a directory the run was told about.
1009        if !floors
1010            .iter()
1011            .any(|floor| parent.starts_with(floor) && parent != floor.as_path())
1012        {
1013            break;
1014        }
1015        current = parent.to_path_buf();
1016    }
1017}
1018
1019/// Move a file, never overwriting whatever is already at the destination.
1020fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1021    if from == to {
1022        return Ok(());
1023    }
1024    if paths_equal(from, to) {
1025        // Same file under a different spelling — a case-only rename on a
1026        // case-insensitive filesystem. Reserving the destination would land on
1027        // the source itself, so it goes via a temporary name.
1028        return rename_via_temp(from, to);
1029    }
1030
1031    // Claim the name atomically: nothing can slip into the destination between
1032    // this check and the rename below.
1033    match std::fs::OpenOptions::new()
1034        .write(true)
1035        .create_new(true)
1036        .open(to)
1037    {
1038        Ok(_) => {}
1039        Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1040            return Err(OrganizeError::DestinationExists(to.to_path_buf()));
1041        }
1042        Err(e) => return Err(e.into()),
1043    }
1044
1045    match transfer(from, to) {
1046        Ok(()) => Ok(()),
1047        Err(e) => {
1048            // Don't leave the empty placeholder behind.
1049            let _ = std::fs::remove_file(to);
1050            Err(e)
1051        }
1052    }
1053}
1054
1055fn rename_via_temp(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1056    let temp = temp_sibling(to);
1057    std::fs::rename(from, &temp)?;
1058    match std::fs::rename(&temp, to) {
1059        Ok(()) => Ok(()),
1060        Err(e) => {
1061            let _ = std::fs::rename(&temp, from);
1062            Err(e.into())
1063        }
1064    }
1065}
1066
1067/// Rename, falling back to a verified copy when the destination is on another filesystem.
1068fn transfer(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1069    match std::fs::rename(from, to) {
1070        Ok(()) => Ok(()),
1071        // EXDEV (18): cross-device link.
1072        Err(e) if e.raw_os_error() == Some(18) => copy_across_devices(from, to),
1073        Err(e) => Err(e.into()),
1074    }
1075}
1076
1077/// Copy to a temporary file, flush it to disk, verify its length, and only then
1078/// drop the original. A crash at any point leaves the source intact.
1079fn copy_across_devices(from: &Path, to: &Path) -> Result<(), OrganizeError> {
1080    let source_meta = std::fs::metadata(from)?;
1081    let expected = source_meta.len();
1082    let temp = temp_sibling(to);
1083
1084    let copied = {
1085        let mut reader = std::fs::File::open(from)?;
1086        let mut writer = std::fs::OpenOptions::new()
1087            .write(true)
1088            .create_new(true)
1089            .open(&temp)?;
1090        let copied = std::io::copy(&mut reader, &mut writer)?;
1091        // std::io::copy returning Ok only means the bytes reached the page cache.
1092        writer.sync_all()?;
1093        if let Ok(modified) = source_meta.modified() {
1094            let _ = writer.set_modified(modified);
1095        }
1096        copied
1097    };
1098
1099    let written = std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
1100    if copied != expected || written != expected {
1101        let _ = std::fs::remove_file(&temp);
1102        return Err(OrganizeError::ShortCopy {
1103            path: from.to_path_buf(),
1104            expected,
1105            copied: copied.min(written),
1106        });
1107    }
1108
1109    if let Err(e) = std::fs::rename(&temp, to) {
1110        let _ = std::fs::remove_file(&temp);
1111        return Err(e.into());
1112    }
1113    std::fs::remove_file(from)?;
1114    Ok(())
1115}
1116
1117fn temp_sibling(path: &Path) -> PathBuf {
1118    let nanos = SystemTime::now()
1119        .duration_since(UNIX_EPOCH)
1120        .unwrap_or_default()
1121        .as_nanos();
1122    path.with_file_name(format!(".koan-{}-{}.tmp", std::process::id(), nanos))
1123}
1124
1125/// Compare paths for equality, including two spellings of one file on a
1126/// case-insensitive filesystem.
1127fn paths_equal(a: &Path, b: &Path) -> bool {
1128    if a == b {
1129        return true;
1130    }
1131    #[cfg(unix)]
1132    {
1133        use std::os::unix::fs::MetadataExt;
1134        if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1135            return ma.dev() == mb.dev() && ma.ino() == mb.ino();
1136        }
1137    }
1138    false
1139}
1140
1141/// Refuse a run that can't fit, rather than discovering it partway through.
1142/// Only files landing on a different filesystem need space.
1143fn check_free_space(moves: &[FileMove], base_dir: &Path) -> Result<(), OrganizeError> {
1144    let Some(target) = existing_ancestor(base_dir) else {
1145        return Ok(());
1146    };
1147    let Some(target_device) = device_id(&target) else {
1148        return Ok(());
1149    };
1150
1151    let mut needed = 0u64;
1152    for file_move in moves {
1153        if device_id(&file_move.from).is_some_and(|d| d == target_device) {
1154            continue;
1155        }
1156        if let Ok(meta) = std::fs::metadata(&file_move.from) {
1157            needed = needed.saturating_add(meta.len());
1158        }
1159    }
1160    if needed == 0 {
1161        return Ok(());
1162    }
1163
1164    match available_bytes(&target) {
1165        Some(available) if available < needed => {
1166            Err(OrganizeError::NotEnoughSpace { needed, available })
1167        }
1168        _ => Ok(()),
1169    }
1170}
1171
1172fn existing_ancestor(path: &Path) -> Option<PathBuf> {
1173    path.ancestors().find(|p| p.exists()).map(Path::to_path_buf)
1174}
1175
1176#[cfg(unix)]
1177fn device_id(path: &Path) -> Option<u64> {
1178    use std::os::unix::fs::MetadataExt;
1179    std::fs::metadata(path).ok().map(|m| m.dev())
1180}
1181
1182#[cfg(not(unix))]
1183fn device_id(_path: &Path) -> Option<u64> {
1184    None
1185}
1186
1187#[cfg(unix)]
1188fn available_bytes(path: &Path) -> Option<u64> {
1189    use std::os::unix::ffi::OsStrExt;
1190    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1191    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1192    if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
1193        return None;
1194    }
1195    // Widths of these fields differ between macOS and Linux.
1196    (stat.f_bavail as u64).checked_mul(stat.f_frsize as u64)
1197}
1198
1199#[cfg(not(unix))]
1200fn available_bytes(_path: &Path) -> Option<u64> {
1201    None
1202}
1203
1204fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
1205    if let Some(dir) = base_dir {
1206        return Ok(dir.to_path_buf());
1207    }
1208
1209    // Use first configured library folder.
1210    let config = crate::config::Config::load()
1211        .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
1212
1213    config.library.folders.into_iter().next().ok_or_else(|| {
1214        OrganizeError::Io(std::io::Error::other(
1215            "no library folders configured; use --base-dir",
1216        ))
1217    })
1218}
1219
1220fn batch_id() -> String {
1221    let now = SystemTime::now()
1222        .duration_since(UNIX_EPOCH)
1223        .unwrap_or_default();
1224    format!("batch-{}", now.as_nanos())
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230    use crate::db::queries::TrackMeta;
1231    use crate::db::schema;
1232    use tempfile::TempDir;
1233
1234    fn test_db() -> Database {
1235        let conn = rusqlite::Connection::open_in_memory().unwrap();
1236        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1237        schema::create_tables(&conn).unwrap();
1238        Database { conn }
1239    }
1240
1241    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
1242        TrackMeta {
1243            title: title.into(),
1244            artist: artist.into(),
1245            album_artist: Some(artist.into()),
1246            album: album.into(),
1247            date: Some("1997-06-16".into()),
1248            disc: Some(1),
1249            track_number: Some(1),
1250            genre: Some("Rock".into()),
1251            label: None,
1252            duration_ms: Some(240_000),
1253            codec: Some("FLAC".into()),
1254            sample_rate: Some(44100),
1255            bit_depth: Some(16),
1256            channels: Some(2),
1257            bitrate: Some(1000),
1258            size_bytes: Some(30_000_000),
1259            mtime: Some(1700000000),
1260            path: None,
1261            source: "local".into(),
1262            remote_id: None,
1263            remote_url: None,
1264        }
1265    }
1266
1267    fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1268        TrackRow {
1269            id: 1,
1270            album_id: Some(1),
1271            artist_id: Some(1),
1272            artist_name: artist.into(),
1273            album_artist_name: artist.into(),
1274            album_title: album.into(),
1275            disc: Some(1),
1276            track_number: Some(1),
1277            title: title.into(),
1278            duration_ms: Some(240_000),
1279            path: Some("/music/test.flac".into()),
1280            codec: Some("FLAC".into()),
1281            sample_rate: Some(44100),
1282            bit_depth: Some(16),
1283            channels: Some(2),
1284            bitrate: Some(1000),
1285            genre: None,
1286            source: "local".into(),
1287            remote_id: None,
1288            cached_path: None,
1289        }
1290    }
1291
1292    /// Write a file with recognisable contents and register it in the library.
1293    fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1294        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1295        std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1296        let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1297        meta.track_number = Some(track_number);
1298        meta.path = Some(path.to_string_lossy().into_owned());
1299        queries::upsert_track(&db.conn, &meta).unwrap()
1300    }
1301
1302    fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1303        db.conn
1304            .query_row(
1305                "SELECT path FROM tracks WHERE id = ?1",
1306                params![track_id],
1307                |row| row.get(0),
1308            )
1309            .unwrap()
1310    }
1311
1312    fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1313        let mut stmt = db
1314            .conn
1315            .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1316            .unwrap();
1317        let rows = stmt
1318            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1319            .unwrap();
1320        rows.map(|r| r.unwrap()).collect()
1321    }
1322
1323    // ---- Metadata + sanitisation ----
1324
1325    #[test]
1326    fn track_metadata_provider_fields() {
1327        let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1328        track.track_number = Some(3);
1329        track.genre = Some("Alternative".into());
1330
1331        let album = AlbumFacts {
1332            date: Some("1997-06-16".into()),
1333            label: Some("Parlophone".into()),
1334        };
1335        let meta = TrackMetadata::from_track_row(&track, &album);
1336        assert_eq!(
1337            meta.get_field("title").as_deref(),
1338            Some("Subterranean Homesick Alien")
1339        );
1340        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1341        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1342        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1343        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1344        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1345        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1346        assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1347        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1348        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1349        assert_eq!(meta.get_field("nonexistent"), None);
1350    }
1351
1352    /// Both providers must populate the same field names, or a preview taken from one
1353    /// authorises a move planned by the other.
1354    #[test]
1355    fn both_metadata_sources_expose_the_same_fields() {
1356        let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1357        track.genre = Some("Rock".into());
1358        let album = AlbumFacts {
1359            date: Some("1997-06-16".into()),
1360            label: Some("Parlophone".into()),
1361        };
1362        let from_db = TrackMetadata::from_track_row(&track, &album);
1363
1364        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1365        meta.label = Some("Parlophone".into());
1366        let from_tags = TrackMetadata::from_file_meta(&meta);
1367
1368        let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1369        let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1370        db_fields.sort();
1371        tag_fields.sort();
1372        assert_eq!(db_fields, tag_fields);
1373    }
1374
1375    #[test]
1376    fn sanitize_replaces_illegal_chars() {
1377        assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1378        assert_eq!(sanitise_filename("What?"), "What_");
1379        assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1380        assert_eq!(sanitise_filename("normal"), "normal");
1381    }
1382
1383    #[test]
1384    fn sanitize_relative_path_splits() {
1385        assert_eq!(
1386            sanitize_relative_path("Artist/Album/Track").unwrap(),
1387            PathBuf::from("Artist/Album/Track")
1388        );
1389        assert_eq!(
1390            sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1391            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1392        );
1393    }
1394
1395    #[test]
1396    fn sanitize_relative_path_refuses_traversal_and_gaps() {
1397        // Reinterpreting these silently is what turns one bad pattern into a
1398        // directory full of overwritten files.
1399        assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1400        assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1401        assert!(sanitize_relative_path("./Artist/./Album").is_err());
1402        assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1403        assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1404        assert!(sanitize_relative_path("   /Airbag").is_err());
1405    }
1406
1407    #[test]
1408    fn acdc_artist_name_sanitized() {
1409        let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1410        let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1411        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1412        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1413        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1414    }
1415
1416    #[test]
1417    fn format_string_evaluation() {
1418        let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1419        let album = AlbumFacts {
1420            date: Some("1997-06-16".into()),
1421            label: None,
1422        };
1423        let meta = TrackMetadata::from_track_row(&track, &album);
1424        let pattern =
1425            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1426        assert_eq!(
1427            format::format(pattern, &meta).unwrap(),
1428            "Radiohead/(1997) OK Computer/01. Airbag"
1429        );
1430    }
1431
1432    #[test]
1433    fn ancillary_file_detection() {
1434        let tmp = TempDir::new().unwrap();
1435        let dir = tmp.path();
1436        std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1437        std::fs::write(dir.join("cover.png"), b"img").unwrap();
1438        std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1439        std::fs::write(dir.join("rip.log"), b"log").unwrap();
1440        std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1441
1442        let found = find_ancillary_files(dir);
1443        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1444        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1445        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1446        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1447        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1448    }
1449
1450    // ---- Preview / execute ----
1451
1452    #[test]
1453    fn preview_does_not_move_files() {
1454        let db = test_db();
1455        let tmp = TempDir::new().unwrap();
1456        let source = tmp.path().join("src/test.flac");
1457        add_track(&db, &source, "Airbag", 1);
1458
1459        let result = preview(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1460        assert!(source.exists());
1461        assert_eq!(result.moves.len(), 1);
1462    }
1463
1464    #[test]
1465    fn execute_moves_files_and_undo_reverts() {
1466        let db = test_db();
1467        let tmp = TempDir::new().unwrap();
1468        let source = tmp.path().join("src/test.flac");
1469        let id = add_track(&db, &source, "Airbag", 1);
1470
1471        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1472        assert_eq!(result.moves.len(), 1);
1473        assert!(result.errors.is_empty());
1474        assert!(!source.exists());
1475        let dest = result.moves[0].to.clone();
1476        assert!(dest.exists());
1477        assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1478
1479        let undone = undo(&db).unwrap();
1480        assert_eq!(undone.restored, 1);
1481        assert!(undone.errors.is_empty());
1482        assert!(source.exists());
1483        assert!(!dest.exists());
1484        assert_eq!(
1485            db_path_of(&db, id).as_deref(),
1486            Some(source.to_str().unwrap())
1487        );
1488    }
1489
1490    /// The preview a user confirms and the moves that follow must agree. They read
1491    /// metadata through the same resolver, so a pattern using an album-level field
1492    /// (here `%label%`) resolves identically in both.
1493    #[test]
1494    fn preview_and_execute_agree_on_destinations() {
1495        let db = test_db();
1496        let tmp = TempDir::new().unwrap();
1497        let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1498
1499        let source = tmp.path().join("src/aphex.flac");
1500        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1501        std::fs::write(&source, b"audio").unwrap();
1502        let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1503        meta.label = Some("Warp Records".into());
1504        meta.path = Some(source.to_string_lossy().into_owned());
1505        queries::upsert_track(&db.conn, &meta).unwrap();
1506
1507        let previewed = preview(&db, pattern, Some(tmp.path())).unwrap();
1508        assert_eq!(previewed.moves.len(), 1);
1509        let expected = previewed.moves[0].to.clone();
1510        assert!(expected.starts_with(tmp.path().join("Warp Records")));
1511
1512        let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1513        assert_eq!(executed.moves.len(), 1);
1514        assert_eq!(executed.moves[0].to, expected);
1515        assert!(expected.exists());
1516    }
1517
1518    // ---- Collisions ----
1519
1520    #[test]
1521    fn colliding_destinations_leave_both_files_intact() {
1522        let db = test_db();
1523        let tmp = TempDir::new().unwrap();
1524        let first = tmp.path().join("src/a.flac");
1525        let second = tmp.path().join("src/b.flac");
1526        // Same title, different track numbers: two library rows, one destination.
1527        let first_id = add_track(&db, &first, "Airbag", 1);
1528        let second_id = add_track(&db, &second, "Airbag", 2);
1529        let second_bytes = std::fs::read(&second).unwrap();
1530
1531        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1532
1533        assert_eq!(result.moves.len(), 1);
1534        assert_eq!(result.errors.len(), 1);
1535        assert!(result.errors[0].1.contains("same destination"));
1536
1537        // The loser stays exactly where it was, byte for byte.
1538        assert!(second.exists());
1539        assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
1540        assert_eq!(
1541            db_path_of(&db, second_id).as_deref(),
1542            Some(second.to_str().unwrap())
1543        );
1544
1545        let dest = &result.moves[0].to;
1546        assert_eq!(
1547            std::fs::read(dest).unwrap(),
1548            b"audio bytes for Airbag".to_vec()
1549        );
1550        assert_eq!(
1551            db_path_of(&db, first_id).as_deref(),
1552            Some(dest.to_str().unwrap())
1553        );
1554    }
1555
1556    #[test]
1557    fn existing_destination_is_never_overwritten() {
1558        let db = test_db();
1559        let tmp = TempDir::new().unwrap();
1560        let source = tmp.path().join("src/new.flac");
1561        add_track(&db, &source, "Airbag", 1);
1562
1563        // Something unrelated is already sitting at the destination.
1564        let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1565        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1566        std::fs::write(&dest, b"the good rip").unwrap();
1567
1568        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1569        assert!(result.moves.is_empty());
1570        assert_eq!(result.errors.len(), 1);
1571        assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
1572        assert!(source.exists());
1573    }
1574
1575    /// `move_file` is the last line of defence: even handed a destination that exists,
1576    /// it refuses rather than replacing it.
1577    #[test]
1578    fn move_file_refuses_an_occupied_destination() {
1579        let tmp = TempDir::new().unwrap();
1580        let from = tmp.path().join("a.flac");
1581        let to = tmp.path().join("b.flac");
1582        std::fs::write(&from, b"source").unwrap();
1583        std::fs::write(&to, b"keep me").unwrap();
1584
1585        let err = move_file(&from, &to).unwrap_err();
1586        assert!(matches!(err, OrganizeError::DestinationExists(_)));
1587        assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
1588        assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
1589    }
1590
1591    #[cfg(target_os = "macos")]
1592    #[test]
1593    fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
1594        let db = test_db();
1595        let tmp = TempDir::new().unwrap();
1596        let first = tmp.path().join("src/1.flac");
1597        let second = tmp.path().join("src/2.flac");
1598        std::fs::create_dir_all(first.parent().unwrap()).unwrap();
1599        for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
1600            std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1601            let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1602            meta.track_number = Some(number);
1603            meta.path = Some(path.to_string_lossy().into_owned());
1604            queries::upsert_track(&db.conn, &meta).unwrap();
1605        }
1606
1607        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1608        assert_eq!(result.moves.len(), 1);
1609        assert_eq!(result.errors.len(), 1);
1610        assert!(second.exists());
1611        assert_eq!(
1612            std::fs::read(&second).unwrap(),
1613            b"audio bytes for RAIN".to_vec()
1614        );
1615    }
1616
1617    /// A rename that only changes case has to go via a temporary name: reserving the
1618    /// destination would otherwise open the source file itself.
1619    #[test]
1620    fn case_only_rename_keeps_the_file() {
1621        let tmp = TempDir::new().unwrap();
1622        let from = tmp.path().join("rain.flac");
1623        let to = tmp.path().join("Rain.flac");
1624        std::fs::write(&from, b"audio bytes").unwrap();
1625
1626        move_file(&from, &to).unwrap();
1627
1628        assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
1629        let names: Vec<String> = std::fs::read_dir(tmp.path())
1630            .unwrap()
1631            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1632            .collect();
1633        assert_eq!(names, vec!["Rain.flac".to_string()]);
1634    }
1635
1636    /// The cross-device path copies, flushes and verifies before unlinking the
1637    /// original, so an interrupted move can never leave a truncated file and no source.
1638    #[test]
1639    fn cross_device_copy_verifies_before_dropping_the_source() {
1640        let tmp = TempDir::new().unwrap();
1641        let from = tmp.path().join("a.flac");
1642        let to = tmp.path().join("b.flac");
1643        let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
1644        std::fs::write(&from, &bytes).unwrap();
1645        let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
1646
1647        copy_across_devices(&from, &to).unwrap();
1648
1649        assert!(!from.exists());
1650        assert_eq!(std::fs::read(&to).unwrap(), bytes);
1651        // Preserved, so scan_cache entries stay valid across a cross-device move.
1652        assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
1653        // No temporary left behind.
1654        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1655            .unwrap()
1656            .filter(|e| {
1657                e.as_ref()
1658                    .unwrap()
1659                    .file_name()
1660                    .to_string_lossy()
1661                    .starts_with(".koan-")
1662            })
1663            .collect();
1664        assert!(leftovers.is_empty());
1665    }
1666
1667    #[test]
1668    fn free_space_check_ignores_same_device_moves() {
1669        let tmp = TempDir::new().unwrap();
1670        let from = tmp.path().join("a.flac");
1671        std::fs::write(&from, b"audio").unwrap();
1672        let moves = vec![FileMove {
1673            track_id: None,
1674            from,
1675            to: tmp.path().join("b.flac"),
1676            ancillary: Vec::new(),
1677        }];
1678        // A rename within one filesystem consumes no space.
1679        assert!(check_free_space(&moves, tmp.path()).is_ok());
1680    }
1681
1682    // ---- Bad patterns ----
1683
1684    #[test]
1685    fn unknown_function_refuses_the_move() {
1686        let db = test_db();
1687        let tmp = TempDir::new().unwrap();
1688        let source = tmp.path().join("src/test.flac");
1689        add_track(&db, &source, "Airbag", 1);
1690
1691        // $nun instead of $num.
1692        let result = execute(
1693            &db,
1694            "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
1695            Some(tmp.path()),
1696        )
1697        .unwrap();
1698        assert!(result.moves.is_empty());
1699        assert_eq!(result.errors.len(), 1);
1700        assert!(result.errors[0].1.contains("unknown function"));
1701        assert!(source.exists());
1702    }
1703
1704    /// An empty last component used to append the extension to the parent directory,
1705    /// pointing every track on an album at one file.
1706    #[test]
1707    fn empty_final_component_refuses_the_move() {
1708        let db = test_db();
1709        let tmp = TempDir::new().unwrap();
1710        let first = tmp.path().join("src/a.flac");
1711        let second = tmp.path().join("src/b.flac");
1712        add_track(&db, &first, "Airbag", 1);
1713        add_track(&db, &second, "Karma Police", 2);
1714
1715        // The conditional resolves to nothing, leaving a trailing separator.
1716        let result = execute(
1717            &db,
1718            "%album artist%/%album%/[%nonexistent field%]",
1719            Some(tmp.path()),
1720        )
1721        .unwrap();
1722
1723        assert!(result.moves.is_empty());
1724        assert_eq!(result.errors.len(), 2);
1725        assert!(first.exists());
1726        assert!(second.exists());
1727        assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
1728    }
1729
1730    #[test]
1731    fn long_title_is_truncated_rather_than_failing() {
1732        let db = test_db();
1733        let tmp = TempDir::new().unwrap();
1734        let source = tmp.path().join("src/test.flac");
1735        let title = "a".repeat(300);
1736        add_track(&db, &source, &title, 1);
1737
1738        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1739        assert_eq!(result.moves.len(), 1, "errors: {:?}", result.errors);
1740        let name = result.moves[0].to.file_name().unwrap().to_string_lossy();
1741        assert!(name.len() <= MAX_FILE_NAME_BYTES);
1742        assert!(name.ends_with(".flac"));
1743        assert!(result.moves[0].to.exists());
1744    }
1745
1746    // ---- Directory cleanup ----
1747
1748    #[test]
1749    fn remove_empty_dirs_never_climbs_past_a_floor() {
1750        let tmp = TempDir::new().unwrap();
1751        let root = tmp.path().join("library");
1752        let nested = root.join("artist/album");
1753        std::fs::create_dir_all(&nested).unwrap();
1754
1755        remove_empty_dirs(&nested, std::slice::from_ref(&root));
1756
1757        assert!(!nested.exists());
1758        assert!(!root.join("artist").exists());
1759        assert!(root.exists(), "the library root must survive");
1760    }
1761
1762    #[test]
1763    fn remove_empty_dirs_stays_put_outside_any_floor() {
1764        let tmp = TempDir::new().unwrap();
1765        let outside = tmp.path().join("incoming/rip");
1766        std::fs::create_dir_all(&outside).unwrap();
1767
1768        remove_empty_dirs(&outside, &[tmp.path().join("library")]);
1769
1770        assert!(!outside.exists());
1771        assert!(
1772            tmp.path().join("incoming").exists(),
1773            "no floor means no climbing"
1774        );
1775    }
1776
1777    #[test]
1778    fn remove_empty_dirs_never_removes_a_floor_itself() {
1779        let tmp = TempDir::new().unwrap();
1780        let root = tmp.path().join("library");
1781        std::fs::create_dir_all(&root).unwrap();
1782
1783        remove_empty_dirs(&root, std::slice::from_ref(&root));
1784
1785        assert!(root.exists());
1786    }
1787
1788    // ---- Undo ----
1789
1790    #[test]
1791    fn undo_refuses_when_the_original_path_is_occupied() {
1792        let db = test_db();
1793        let tmp = TempDir::new().unwrap();
1794        let source = tmp.path().join("src/test.flac");
1795        add_track(&db, &source, "Airbag", 1);
1796
1797        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1798        let dest = result.moves[0].to.clone();
1799
1800        // A different rip lands at the vacated path before the undo.
1801        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1802        std::fs::write(&source, b"a completely different rip").unwrap();
1803
1804        let undone = undo(&db).unwrap();
1805        assert_eq!(undone.restored, 0);
1806        assert_eq!(undone.errors.len(), 1);
1807        assert_eq!(
1808            std::fs::read(&source).unwrap(),
1809            b"a completely different rip".to_vec()
1810        );
1811        assert!(dest.exists());
1812        // The entry stays in the log so it can be undone once the path is free.
1813        assert_eq!(log_rows(&db).len(), 1);
1814    }
1815
1816    #[test]
1817    fn undo_refuses_when_the_moved_file_has_been_replaced() {
1818        let db = test_db();
1819        let tmp = TempDir::new().unwrap();
1820        let source = tmp.path().join("src/test.flac");
1821        add_track(&db, &source, "Airbag", 1);
1822
1823        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1824        let dest = result.moves[0].to.clone();
1825        std::fs::write(&dest, b"replaced with something else entirely").unwrap();
1826
1827        let undone = undo(&db).unwrap();
1828        assert_eq!(undone.restored, 0);
1829        assert_eq!(undone.errors.len(), 1);
1830        assert!(!source.exists());
1831        assert!(dest.exists());
1832    }
1833
1834    /// `created_at` has one-second resolution, so batches are ordered by primary key.
1835    #[test]
1836    fn undo_takes_the_newest_batch_when_timestamps_tie() {
1837        let db = test_db();
1838        let tmp = TempDir::new().unwrap();
1839        let older = tmp.path().join("older.flac");
1840        let newer = tmp.path().join("newer.flac");
1841        std::fs::write(&older, b"older").unwrap();
1842        std::fs::write(&newer, b"newer").unwrap();
1843        let moved_older = tmp.path().join("moved-older.flac");
1844        let moved_newer = tmp.path().join("moved-newer.flac");
1845        std::fs::rename(&older, &moved_older).unwrap();
1846        std::fs::rename(&newer, &moved_newer).unwrap();
1847
1848        for (batch, from, to) in [
1849            ("batch-1", &older, &moved_older),
1850            ("batch-2", &newer, &moved_newer),
1851        ] {
1852            db.conn
1853                .execute(
1854                    "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
1855                     VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
1856                    params![
1857                        batch,
1858                        from.to_string_lossy().as_ref(),
1859                        to.to_string_lossy().as_ref()
1860                    ],
1861                )
1862                .unwrap();
1863        }
1864
1865        let undone = undo(&db).unwrap();
1866        assert_eq!(undone.restored, 1);
1867        assert!(newer.exists(), "the newest batch is the one undone");
1868        assert!(!older.exists());
1869    }
1870
1871    // ---- Database consistency ----
1872
1873    #[test]
1874    fn favourites_and_queue_state_follow_the_move() {
1875        let db = test_db();
1876        let tmp = TempDir::new().unwrap();
1877        let source = tmp.path().join("src/test.flac");
1878        add_track(&db, &source, "Airbag", 1);
1879        let source_str = source.to_string_lossy().into_owned();
1880
1881        queries::add_favourite(&db.conn, &source).unwrap();
1882        let item = PersistedQueueItem {
1883            path: source_str.clone(),
1884            title: "Airbag".into(),
1885            artist: "Radiohead".into(),
1886            album_artist: "Radiohead".into(),
1887            album: "OK Computer".into(),
1888            year: None,
1889            codec: None,
1890            track_number: Some(1),
1891            disc: Some(1),
1892            duration_ms: None,
1893            db_id: None,
1894        };
1895        queries::save_snapshot(
1896            &db.conn,
1897            "mine",
1898            std::slice::from_ref(&item),
1899            Some(&source_str),
1900            0,
1901        )
1902        .unwrap();
1903        queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0).unwrap();
1904
1905        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1906        let dest = result.moves[0].to.clone();
1907        let dest_str = dest.to_string_lossy().into_owned();
1908
1909        let favourites = queries::load_favourites(&db.conn).unwrap();
1910        assert!(favourites.contains(&dest));
1911        assert!(!favourites.contains(&source));
1912
1913        let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1914        assert_eq!(snapshot.items[0].path, dest_str);
1915        assert_eq!(snapshot.cursor_path.as_deref(), Some(dest_str.as_str()));
1916
1917        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
1918        assert_eq!(state.items[0].path, dest_str);
1919        assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
1920
1921        assert_eq!(undo(&db).unwrap().restored, 1);
1922
1923        let favourites = queries::load_favourites(&db.conn).unwrap();
1924        assert!(favourites.contains(&source));
1925        assert!(!favourites.contains(&dest));
1926        let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1927        assert_eq!(snapshot.items[0].path, source_str);
1928        assert_eq!(snapshot.cursor_path.as_deref(), Some(source_str.as_str()));
1929    }
1930
1931    #[test]
1932    fn scan_cache_follows_the_move() {
1933        let db = test_db();
1934        let tmp = TempDir::new().unwrap();
1935        let source = tmp.path().join("src/test.flac");
1936        let id = add_track(&db, &source, "Airbag", 1);
1937        db.conn
1938            .execute(
1939                "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
1940                params![source.to_string_lossy().as_ref(), id],
1941            )
1942            .unwrap();
1943
1944        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1945        let dest = result.moves[0].to.to_string_lossy().into_owned();
1946
1947        let cached: String = db
1948            .conn
1949            .query_row(
1950                "SELECT path FROM scan_cache WHERE track_id = ?1",
1951                params![id],
1952                |r| r.get(0),
1953            )
1954            .unwrap();
1955        assert_eq!(cached, dest);
1956    }
1957
1958    /// A failure partway through a batch must leave the rest of the run truthful: the
1959    /// files that moved are in the result and the log, the one that didn't is in neither.
1960    #[test]
1961    fn partial_failure_leaves_the_database_and_result_consistent() {
1962        let db = test_db();
1963        let tmp = TempDir::new().unwrap();
1964        let first = tmp.path().join("src/a.flac");
1965        let clash = tmp.path().join("src/b.flac");
1966        let third = tmp.path().join("src/c.flac");
1967        let first_id = add_track(&db, &first, "Airbag", 1);
1968        let clash_id = add_track(&db, &clash, "Airbag", 2);
1969        let third_id = add_track(&db, &third, "Karma Police", 3);
1970
1971        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1972
1973        assert_eq!(result.moves.len(), 2);
1974        assert_eq!(result.errors.len(), 1);
1975
1976        let logged = log_rows(&db);
1977        assert_eq!(logged.len(), 2);
1978        for file_move in &result.moves {
1979            assert!(file_move.to.exists());
1980            assert!(
1981                logged
1982                    .iter()
1983                    .any(|(_, _, to)| Path::new(to) == file_move.to)
1984            );
1985        }
1986
1987        // The failed file is untouched, in the filesystem and in the database.
1988        assert!(clash.exists());
1989        assert_eq!(
1990            db_path_of(&db, clash_id).as_deref(),
1991            Some(clash.to_str().unwrap())
1992        );
1993        assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
1994        assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
1995    }
1996
1997    /// The TUI organizes a selection of paths. Files the library doesn't know about
1998    /// still get a log entry, so the whole run can be undone.
1999    #[test]
2000    fn unknown_paths_are_logged_and_undoable() {
2001        let db = test_db();
2002        let tmp = TempDir::new().unwrap();
2003        let known = tmp.path().join("src/known.flac");
2004        add_track(&db, &known, "Airbag", 1);
2005
2006        let result = run(
2007            &db,
2008            Selection::Paths(std::slice::from_ref(&known)),
2009            "%album artist%/%album%/%title%",
2010            tmp.path(),
2011        )
2012        .unwrap();
2013
2014        assert_eq!(result.moves.len(), 1);
2015        let logged = log_rows(&db);
2016        assert_eq!(logged.len(), 1);
2017        assert!(logged[0].0.is_some());
2018
2019        assert_eq!(undo(&db).unwrap().restored, 1);
2020        assert!(known.exists());
2021    }
2022
2023    #[test]
2024    fn ancillary_files_move_with_the_album() {
2025        let db = test_db();
2026        let tmp = TempDir::new().unwrap();
2027        let source = tmp.path().join("src/test.flac");
2028        add_track(&db, &source, "Airbag", 1);
2029        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2030
2031        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2032        assert_eq!(result.moves.len(), 1);
2033        let dest_dir = result.moves[0].to.parent().unwrap();
2034        assert!(dest_dir.join("cover.jpg").exists());
2035
2036        // Both the audio and the artwork are in the log, so undo restores both.
2037        assert_eq!(log_rows(&db).len(), 2);
2038        assert_eq!(undo(&db).unwrap().restored, 2);
2039        assert!(source.parent().unwrap().join("cover.jpg").exists());
2040    }
2041
2042    // ---- Extension handling ----
2043
2044    #[test]
2045    fn extension_not_clobbered_by_dots_in_title() {
2046        // Regression: with_extension() replaces after the LAST dot,
2047        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
2048        let db = test_db();
2049        let tmp = TempDir::new().unwrap();
2050        let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2051        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2052        std::fs::write(&source, b"fake").unwrap();
2053
2054        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2055        meta.track_number = Some(10);
2056        meta.date = Some("2025-11-21".into());
2057        meta.path = Some(source.to_string_lossy().into_owned());
2058        queries::upsert_track(&db.conn, &meta).unwrap();
2059
2060        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2061        let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2062        assert_eq!(result.moves.len(), 1);
2063        assert_eq!(
2064            result.moves[0].to.file_name().unwrap().to_string_lossy(),
2065            "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2066        );
2067    }
2068
2069    #[test]
2070    fn extension_preserved_for_tracknumber_dot() {
2071        // "0111. Bicep - TANGZ II" must not become "0111.flac"
2072        let db = test_db();
2073        let tmp = TempDir::new().unwrap();
2074        let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2075        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2076        std::fs::write(&source, b"fake").unwrap();
2077
2078        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2079        meta.track_number = Some(11);
2080        meta.date = Some("2025-11-21".into());
2081        meta.path = Some(source.to_string_lossy().into_owned());
2082        queries::upsert_track(&db.conn, &meta).unwrap();
2083
2084        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2085        let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2086        assert_eq!(result.moves.len(), 1);
2087        assert_eq!(
2088            result.moves[0].to.file_name().unwrap().to_string_lossy(),
2089            "0111. Bicep - CHROMA 012 TANGZ II.flac"
2090        );
2091    }
2092}