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            album_remote_id: None,
1265            artist_remote_id: None,
1266            album_added_at: None,
1267        }
1268    }
1269
1270    fn sample_track_row(title: &str, artist: &str, album: &str) -> TrackRow {
1271        TrackRow {
1272            id: 1,
1273            album_id: Some(1),
1274            artist_id: Some(1),
1275            artist_name: artist.into(),
1276            album_artist_name: artist.into(),
1277            album_title: album.into(),
1278            disc: Some(1),
1279            track_number: Some(1),
1280            title: title.into(),
1281            duration_ms: Some(240_000),
1282            path: Some("/music/test.flac".into()),
1283            codec: Some("FLAC".into()),
1284            sample_rate: Some(44100),
1285            bit_depth: Some(16),
1286            channels: Some(2),
1287            bitrate: Some(1000),
1288            genre: None,
1289            source: "local".into(),
1290            remote_id: None,
1291            cached_path: None,
1292        }
1293    }
1294
1295    /// Write a file with recognisable contents and register it in the library.
1296    fn add_track(db: &Database, path: &Path, title: &str, track_number: i32) -> i64 {
1297        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1298        std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1299        let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1300        meta.track_number = Some(track_number);
1301        meta.path = Some(path.to_string_lossy().into_owned());
1302        queries::upsert_track(&db.conn, &meta).unwrap()
1303    }
1304
1305    fn db_path_of(db: &Database, track_id: i64) -> Option<String> {
1306        db.conn
1307            .query_row(
1308                "SELECT path FROM tracks WHERE id = ?1",
1309                params![track_id],
1310                |row| row.get(0),
1311            )
1312            .unwrap()
1313    }
1314
1315    fn log_rows(db: &Database) -> Vec<(Option<i64>, String, String)> {
1316        let mut stmt = db
1317            .conn
1318            .prepare("SELECT track_id, from_path, to_path FROM organize_log ORDER BY id")
1319            .unwrap();
1320        let rows = stmt
1321            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1322            .unwrap();
1323        rows.map(|r| r.unwrap()).collect()
1324    }
1325
1326    // ---- Metadata + sanitisation ----
1327
1328    #[test]
1329    fn track_metadata_provider_fields() {
1330        let mut track = sample_track_row("Subterranean Homesick Alien", "Radiohead", "OK Computer");
1331        track.track_number = Some(3);
1332        track.genre = Some("Alternative".into());
1333
1334        let album = AlbumFacts {
1335            date: Some("1997-06-16".into()),
1336            label: Some("Parlophone".into()),
1337        };
1338        let meta = TrackMetadata::from_track_row(&track, &album);
1339        assert_eq!(
1340            meta.get_field("title").as_deref(),
1341            Some("Subterranean Homesick Alien")
1342        );
1343        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
1344        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
1345        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
1346        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
1347        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
1348        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
1349        assert_eq!(meta.get_field("label").as_deref(), Some("Parlophone"));
1350        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
1351        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
1352        assert_eq!(meta.get_field("nonexistent"), None);
1353    }
1354
1355    /// Both providers must populate the same field names, or a preview taken from one
1356    /// authorises a move planned by the other.
1357    #[test]
1358    fn both_metadata_sources_expose_the_same_fields() {
1359        let mut track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1360        track.genre = Some("Rock".into());
1361        let album = AlbumFacts {
1362            date: Some("1997-06-16".into()),
1363            label: Some("Parlophone".into()),
1364        };
1365        let from_db = TrackMetadata::from_track_row(&track, &album);
1366
1367        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1368        meta.label = Some("Parlophone".into());
1369        let from_tags = TrackMetadata::from_file_meta(&meta);
1370
1371        let mut db_fields: Vec<&String> = from_db.fields.keys().collect();
1372        let mut tag_fields: Vec<&String> = from_tags.fields.keys().collect();
1373        db_fields.sort();
1374        tag_fields.sort();
1375        assert_eq!(db_fields, tag_fields);
1376    }
1377
1378    #[test]
1379    fn sanitize_replaces_illegal_chars() {
1380        assert_eq!(sanitise_filename("AC/DC"), "AC_DC");
1381        assert_eq!(sanitise_filename("What?"), "What_");
1382        assert_eq!(sanitise_filename("a:b*c"), "a_b_c");
1383        assert_eq!(sanitise_filename("normal"), "normal");
1384    }
1385
1386    #[test]
1387    fn sanitize_relative_path_splits() {
1388        assert_eq!(
1389            sanitize_relative_path("Artist/Album/Track").unwrap(),
1390            PathBuf::from("Artist/Album/Track")
1391        );
1392        assert_eq!(
1393            sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag").unwrap(),
1394            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
1395        );
1396    }
1397
1398    #[test]
1399    fn sanitize_relative_path_refuses_traversal_and_gaps() {
1400        // Reinterpreting these silently is what turns one bad pattern into a
1401        // directory full of overwritten files.
1402        assert!(sanitize_relative_path("../../../../etc/passwd").is_err());
1403        assert!(sanitize_relative_path("Artist/../../../outside").is_err());
1404        assert!(sanitize_relative_path("./Artist/./Album").is_err());
1405        assert!(sanitize_relative_path("Radiohead/OK Computer/").is_err());
1406        assert!(sanitize_relative_path("Radiohead//Airbag").is_err());
1407        assert!(sanitize_relative_path("   /Airbag").is_err());
1408    }
1409
1410    #[test]
1411    fn acdc_artist_name_sanitized() {
1412        let track = sample_track_row("Highway to Hell", "AC/DC", "Highway to Hell");
1413        let meta = TrackMetadata::from_track_row(&track, &AlbumFacts::default());
1414        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1415        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1416        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1417    }
1418
1419    #[test]
1420    fn format_string_evaluation() {
1421        let track = sample_track_row("Airbag", "Radiohead", "OK Computer");
1422        let album = AlbumFacts {
1423            date: Some("1997-06-16".into()),
1424            label: None,
1425        };
1426        let meta = TrackMetadata::from_track_row(&track, &album);
1427        let pattern =
1428            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1429        assert_eq!(
1430            format::format(pattern, &meta).unwrap(),
1431            "Radiohead/(1997) OK Computer/01. Airbag"
1432        );
1433    }
1434
1435    #[test]
1436    fn ancillary_file_detection() {
1437        let tmp = TempDir::new().unwrap();
1438        let dir = tmp.path();
1439        std::fs::write(dir.join("cover.jpg"), b"img").unwrap();
1440        std::fs::write(dir.join("cover.png"), b"img").unwrap();
1441        std::fs::write(dir.join("album.cue"), b"cue").unwrap();
1442        std::fs::write(dir.join("rip.log"), b"log").unwrap();
1443        std::fs::write(dir.join("track.flac"), b"audio").unwrap();
1444
1445        let found = find_ancillary_files(dir);
1446        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1447        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1448        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1449        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1450        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1451    }
1452
1453    // ---- Preview / execute ----
1454
1455    #[test]
1456    fn preview_does_not_move_files() {
1457        let db = test_db();
1458        let tmp = TempDir::new().unwrap();
1459        let source = tmp.path().join("src/test.flac");
1460        add_track(&db, &source, "Airbag", 1);
1461
1462        let result = preview(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1463        assert!(source.exists());
1464        assert_eq!(result.moves.len(), 1);
1465    }
1466
1467    #[test]
1468    fn execute_moves_files_and_undo_reverts() {
1469        let db = test_db();
1470        let tmp = TempDir::new().unwrap();
1471        let source = tmp.path().join("src/test.flac");
1472        let id = add_track(&db, &source, "Airbag", 1);
1473
1474        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1475        assert_eq!(result.moves.len(), 1);
1476        assert!(result.errors.is_empty());
1477        assert!(!source.exists());
1478        let dest = result.moves[0].to.clone();
1479        assert!(dest.exists());
1480        assert_eq!(db_path_of(&db, id).as_deref(), Some(dest.to_str().unwrap()));
1481
1482        let undone = undo(&db).unwrap();
1483        assert_eq!(undone.restored, 1);
1484        assert!(undone.errors.is_empty());
1485        assert!(source.exists());
1486        assert!(!dest.exists());
1487        assert_eq!(
1488            db_path_of(&db, id).as_deref(),
1489            Some(source.to_str().unwrap())
1490        );
1491    }
1492
1493    /// The preview a user confirms and the moves that follow must agree. They read
1494    /// metadata through the same resolver, so a pattern using an album-level field
1495    /// (here `%label%`) resolves identically in both.
1496    #[test]
1497    fn preview_and_execute_agree_on_destinations() {
1498        let db = test_db();
1499        let tmp = TempDir::new().unwrap();
1500        let pattern = "$if2(%label%,%album artist%)/%album%/[$num(%tracknumber%,2). ]%title%";
1501
1502        let source = tmp.path().join("src/aphex.flac");
1503        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1504        std::fs::write(&source, b"audio").unwrap();
1505        let mut meta = sample_meta("Xtal", "Aphex Twin", "Selected Ambient Works");
1506        meta.label = Some("Warp Records".into());
1507        meta.path = Some(source.to_string_lossy().into_owned());
1508        queries::upsert_track(&db.conn, &meta).unwrap();
1509
1510        let previewed = preview(&db, pattern, Some(tmp.path())).unwrap();
1511        assert_eq!(previewed.moves.len(), 1);
1512        let expected = previewed.moves[0].to.clone();
1513        assert!(expected.starts_with(tmp.path().join("Warp Records")));
1514
1515        let executed = execute(&db, pattern, Some(tmp.path())).unwrap();
1516        assert_eq!(executed.moves.len(), 1);
1517        assert_eq!(executed.moves[0].to, expected);
1518        assert!(expected.exists());
1519    }
1520
1521    // ---- Collisions ----
1522
1523    #[test]
1524    fn colliding_destinations_leave_both_files_intact() {
1525        let db = test_db();
1526        let tmp = TempDir::new().unwrap();
1527        let first = tmp.path().join("src/a.flac");
1528        let second = tmp.path().join("src/b.flac");
1529        // Same title, different track numbers: two library rows, one destination.
1530        let first_id = add_track(&db, &first, "Airbag", 1);
1531        let second_id = add_track(&db, &second, "Airbag", 2);
1532        let second_bytes = std::fs::read(&second).unwrap();
1533
1534        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1535
1536        assert_eq!(result.moves.len(), 1);
1537        assert_eq!(result.errors.len(), 1);
1538        assert!(result.errors[0].1.contains("same destination"));
1539
1540        // The loser stays exactly where it was, byte for byte.
1541        assert!(second.exists());
1542        assert_eq!(std::fs::read(&second).unwrap(), second_bytes);
1543        assert_eq!(
1544            db_path_of(&db, second_id).as_deref(),
1545            Some(second.to_str().unwrap())
1546        );
1547
1548        let dest = &result.moves[0].to;
1549        assert_eq!(
1550            std::fs::read(dest).unwrap(),
1551            b"audio bytes for Airbag".to_vec()
1552        );
1553        assert_eq!(
1554            db_path_of(&db, first_id).as_deref(),
1555            Some(dest.to_str().unwrap())
1556        );
1557    }
1558
1559    #[test]
1560    fn existing_destination_is_never_overwritten() {
1561        let db = test_db();
1562        let tmp = TempDir::new().unwrap();
1563        let source = tmp.path().join("src/new.flac");
1564        add_track(&db, &source, "Airbag", 1);
1565
1566        // Something unrelated is already sitting at the destination.
1567        let dest = tmp.path().join("Radiohead/OK Computer/Airbag.flac");
1568        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1569        std::fs::write(&dest, b"the good rip").unwrap();
1570
1571        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1572        assert!(result.moves.is_empty());
1573        assert_eq!(result.errors.len(), 1);
1574        assert_eq!(std::fs::read(&dest).unwrap(), b"the good rip".to_vec());
1575        assert!(source.exists());
1576    }
1577
1578    /// `move_file` is the last line of defence: even handed a destination that exists,
1579    /// it refuses rather than replacing it.
1580    #[test]
1581    fn move_file_refuses_an_occupied_destination() {
1582        let tmp = TempDir::new().unwrap();
1583        let from = tmp.path().join("a.flac");
1584        let to = tmp.path().join("b.flac");
1585        std::fs::write(&from, b"source").unwrap();
1586        std::fs::write(&to, b"keep me").unwrap();
1587
1588        let err = move_file(&from, &to).unwrap_err();
1589        assert!(matches!(err, OrganizeError::DestinationExists(_)));
1590        assert_eq!(std::fs::read(&to).unwrap(), b"keep me".to_vec());
1591        assert_eq!(std::fs::read(&from).unwrap(), b"source".to_vec());
1592    }
1593
1594    #[cfg(target_os = "macos")]
1595    #[test]
1596    fn case_only_difference_collides_on_a_case_insensitive_filesystem() {
1597        let db = test_db();
1598        let tmp = TempDir::new().unwrap();
1599        let first = tmp.path().join("src/1.flac");
1600        let second = tmp.path().join("src/2.flac");
1601        std::fs::create_dir_all(first.parent().unwrap()).unwrap();
1602        for (path, title, number) in [(&first, "Rain", 1i32), (&second, "RAIN", 2)] {
1603            std::fs::write(path, format!("audio bytes for {title}")).unwrap();
1604            let mut meta = sample_meta(title, "Radiohead", "OK Computer");
1605            meta.track_number = Some(number);
1606            meta.path = Some(path.to_string_lossy().into_owned());
1607            queries::upsert_track(&db.conn, &meta).unwrap();
1608        }
1609
1610        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1611        assert_eq!(result.moves.len(), 1);
1612        assert_eq!(result.errors.len(), 1);
1613        assert!(second.exists());
1614        assert_eq!(
1615            std::fs::read(&second).unwrap(),
1616            b"audio bytes for RAIN".to_vec()
1617        );
1618    }
1619
1620    /// A rename that only changes case has to go via a temporary name: reserving the
1621    /// destination would otherwise open the source file itself.
1622    #[test]
1623    fn case_only_rename_keeps_the_file() {
1624        let tmp = TempDir::new().unwrap();
1625        let from = tmp.path().join("rain.flac");
1626        let to = tmp.path().join("Rain.flac");
1627        std::fs::write(&from, b"audio bytes").unwrap();
1628
1629        move_file(&from, &to).unwrap();
1630
1631        assert_eq!(std::fs::read(&to).unwrap(), b"audio bytes".to_vec());
1632        let names: Vec<String> = std::fs::read_dir(tmp.path())
1633            .unwrap()
1634            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1635            .collect();
1636        assert_eq!(names, vec!["Rain.flac".to_string()]);
1637    }
1638
1639    /// The cross-device path copies, flushes and verifies before unlinking the
1640    /// original, so an interrupted move can never leave a truncated file and no source.
1641    #[test]
1642    fn cross_device_copy_verifies_before_dropping_the_source() {
1643        let tmp = TempDir::new().unwrap();
1644        let from = tmp.path().join("a.flac");
1645        let to = tmp.path().join("b.flac");
1646        let bytes: Vec<u8> = (0..64_000u32).map(|i| (i % 251) as u8).collect();
1647        std::fs::write(&from, &bytes).unwrap();
1648        let mtime = std::fs::metadata(&from).unwrap().modified().unwrap();
1649
1650        copy_across_devices(&from, &to).unwrap();
1651
1652        assert!(!from.exists());
1653        assert_eq!(std::fs::read(&to).unwrap(), bytes);
1654        // Preserved, so scan_cache entries stay valid across a cross-device move.
1655        assert_eq!(std::fs::metadata(&to).unwrap().modified().unwrap(), mtime);
1656        // No temporary left behind.
1657        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
1658            .unwrap()
1659            .filter(|e| {
1660                e.as_ref()
1661                    .unwrap()
1662                    .file_name()
1663                    .to_string_lossy()
1664                    .starts_with(".koan-")
1665            })
1666            .collect();
1667        assert!(leftovers.is_empty());
1668    }
1669
1670    #[test]
1671    fn free_space_check_ignores_same_device_moves() {
1672        let tmp = TempDir::new().unwrap();
1673        let from = tmp.path().join("a.flac");
1674        std::fs::write(&from, b"audio").unwrap();
1675        let moves = vec![FileMove {
1676            track_id: None,
1677            from,
1678            to: tmp.path().join("b.flac"),
1679            ancillary: Vec::new(),
1680        }];
1681        // A rename within one filesystem consumes no space.
1682        assert!(check_free_space(&moves, tmp.path()).is_ok());
1683    }
1684
1685    // ---- Bad patterns ----
1686
1687    #[test]
1688    fn unknown_function_refuses_the_move() {
1689        let db = test_db();
1690        let tmp = TempDir::new().unwrap();
1691        let source = tmp.path().join("src/test.flac");
1692        add_track(&db, &source, "Airbag", 1);
1693
1694        // $nun instead of $num.
1695        let result = execute(
1696            &db,
1697            "%album artist%/%album%/$nun(%tracknumber%,2). %title%",
1698            Some(tmp.path()),
1699        )
1700        .unwrap();
1701        assert!(result.moves.is_empty());
1702        assert_eq!(result.errors.len(), 1);
1703        assert!(result.errors[0].1.contains("unknown function"));
1704        assert!(source.exists());
1705    }
1706
1707    /// An empty last component used to append the extension to the parent directory,
1708    /// pointing every track on an album at one file.
1709    #[test]
1710    fn empty_final_component_refuses_the_move() {
1711        let db = test_db();
1712        let tmp = TempDir::new().unwrap();
1713        let first = tmp.path().join("src/a.flac");
1714        let second = tmp.path().join("src/b.flac");
1715        add_track(&db, &first, "Airbag", 1);
1716        add_track(&db, &second, "Karma Police", 2);
1717
1718        // The conditional resolves to nothing, leaving a trailing separator.
1719        let result = execute(
1720            &db,
1721            "%album artist%/%album%/[%nonexistent field%]",
1722            Some(tmp.path()),
1723        )
1724        .unwrap();
1725
1726        assert!(result.moves.is_empty());
1727        assert_eq!(result.errors.len(), 2);
1728        assert!(first.exists());
1729        assert!(second.exists());
1730        assert!(!tmp.path().join("Radiohead/OK Computer.flac").exists());
1731    }
1732
1733    #[test]
1734    fn long_title_is_truncated_rather_than_failing() {
1735        let db = test_db();
1736        let tmp = TempDir::new().unwrap();
1737        let source = tmp.path().join("src/test.flac");
1738        let title = "a".repeat(300);
1739        add_track(&db, &source, &title, 1);
1740
1741        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1742        assert_eq!(result.moves.len(), 1, "errors: {:?}", result.errors);
1743        let name = result.moves[0].to.file_name().unwrap().to_string_lossy();
1744        assert!(name.len() <= MAX_FILE_NAME_BYTES);
1745        assert!(name.ends_with(".flac"));
1746        assert!(result.moves[0].to.exists());
1747    }
1748
1749    // ---- Directory cleanup ----
1750
1751    #[test]
1752    fn remove_empty_dirs_never_climbs_past_a_floor() {
1753        let tmp = TempDir::new().unwrap();
1754        let root = tmp.path().join("library");
1755        let nested = root.join("artist/album");
1756        std::fs::create_dir_all(&nested).unwrap();
1757
1758        remove_empty_dirs(&nested, std::slice::from_ref(&root));
1759
1760        assert!(!nested.exists());
1761        assert!(!root.join("artist").exists());
1762        assert!(root.exists(), "the library root must survive");
1763    }
1764
1765    #[test]
1766    fn remove_empty_dirs_stays_put_outside_any_floor() {
1767        let tmp = TempDir::new().unwrap();
1768        let outside = tmp.path().join("incoming/rip");
1769        std::fs::create_dir_all(&outside).unwrap();
1770
1771        remove_empty_dirs(&outside, &[tmp.path().join("library")]);
1772
1773        assert!(!outside.exists());
1774        assert!(
1775            tmp.path().join("incoming").exists(),
1776            "no floor means no climbing"
1777        );
1778    }
1779
1780    #[test]
1781    fn remove_empty_dirs_never_removes_a_floor_itself() {
1782        let tmp = TempDir::new().unwrap();
1783        let root = tmp.path().join("library");
1784        std::fs::create_dir_all(&root).unwrap();
1785
1786        remove_empty_dirs(&root, std::slice::from_ref(&root));
1787
1788        assert!(root.exists());
1789    }
1790
1791    // ---- Undo ----
1792
1793    #[test]
1794    fn undo_refuses_when_the_original_path_is_occupied() {
1795        let db = test_db();
1796        let tmp = TempDir::new().unwrap();
1797        let source = tmp.path().join("src/test.flac");
1798        add_track(&db, &source, "Airbag", 1);
1799
1800        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1801        let dest = result.moves[0].to.clone();
1802
1803        // A different rip lands at the vacated path before the undo.
1804        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1805        std::fs::write(&source, b"a completely different rip").unwrap();
1806
1807        let undone = undo(&db).unwrap();
1808        assert_eq!(undone.restored, 0);
1809        assert_eq!(undone.errors.len(), 1);
1810        assert_eq!(
1811            std::fs::read(&source).unwrap(),
1812            b"a completely different rip".to_vec()
1813        );
1814        assert!(dest.exists());
1815        // The entry stays in the log so it can be undone once the path is free.
1816        assert_eq!(log_rows(&db).len(), 1);
1817    }
1818
1819    #[test]
1820    fn undo_refuses_when_the_moved_file_has_been_replaced() {
1821        let db = test_db();
1822        let tmp = TempDir::new().unwrap();
1823        let source = tmp.path().join("src/test.flac");
1824        add_track(&db, &source, "Airbag", 1);
1825
1826        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1827        let dest = result.moves[0].to.clone();
1828        std::fs::write(&dest, b"replaced with something else entirely").unwrap();
1829
1830        let undone = undo(&db).unwrap();
1831        assert_eq!(undone.restored, 0);
1832        assert_eq!(undone.errors.len(), 1);
1833        assert!(!source.exists());
1834        assert!(dest.exists());
1835    }
1836
1837    /// `created_at` has one-second resolution, so batches are ordered by primary key.
1838    #[test]
1839    fn undo_takes_the_newest_batch_when_timestamps_tie() {
1840        let db = test_db();
1841        let tmp = TempDir::new().unwrap();
1842        let older = tmp.path().join("older.flac");
1843        let newer = tmp.path().join("newer.flac");
1844        std::fs::write(&older, b"older").unwrap();
1845        std::fs::write(&newer, b"newer").unwrap();
1846        let moved_older = tmp.path().join("moved-older.flac");
1847        let moved_newer = tmp.path().join("moved-newer.flac");
1848        std::fs::rename(&older, &moved_older).unwrap();
1849        std::fs::rename(&newer, &moved_newer).unwrap();
1850
1851        for (batch, from, to) in [
1852            ("batch-1", &older, &moved_older),
1853            ("batch-2", &newer, &moved_newer),
1854        ] {
1855            db.conn
1856                .execute(
1857                    "INSERT INTO organize_log (batch_id, track_id, from_path, to_path, created_at)
1858                     VALUES (?1, NULL, ?2, ?3, '2025-01-01 00:00:00')",
1859                    params![
1860                        batch,
1861                        from.to_string_lossy().as_ref(),
1862                        to.to_string_lossy().as_ref()
1863                    ],
1864                )
1865                .unwrap();
1866        }
1867
1868        let undone = undo(&db).unwrap();
1869        assert_eq!(undone.restored, 1);
1870        assert!(newer.exists(), "the newest batch is the one undone");
1871        assert!(!older.exists());
1872    }
1873
1874    // ---- Database consistency ----
1875
1876    #[test]
1877    fn favourites_and_queue_state_follow_the_move() {
1878        let db = test_db();
1879        let tmp = TempDir::new().unwrap();
1880        let source = tmp.path().join("src/test.flac");
1881        add_track(&db, &source, "Airbag", 1);
1882        let source_str = source.to_string_lossy().into_owned();
1883
1884        queries::add_favourite(&db.conn, &source).unwrap();
1885        let item = PersistedQueueItem {
1886            path: source_str.clone(),
1887            title: "Airbag".into(),
1888            artist: "Radiohead".into(),
1889            album_artist: "Radiohead".into(),
1890            album: "OK Computer".into(),
1891            year: None,
1892            codec: None,
1893            track_number: Some(1),
1894            disc: Some(1),
1895            duration_ms: None,
1896            db_id: None,
1897        };
1898        queries::save_snapshot(
1899            &db.conn,
1900            "mine",
1901            std::slice::from_ref(&item),
1902            Some(&source_str),
1903            0,
1904        )
1905        .unwrap();
1906        queries::save_playback_state(&db.conn, &[item], Some(&source_str), 0, false, false)
1907            .unwrap();
1908
1909        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1910        let dest = result.moves[0].to.clone();
1911        let dest_str = dest.to_string_lossy().into_owned();
1912
1913        let favourites = queries::load_favourites(&db.conn).unwrap();
1914        assert!(favourites.contains(&dest));
1915        assert!(!favourites.contains(&source));
1916
1917        let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1918        assert_eq!(snapshot.items[0].path, dest_str);
1919        assert_eq!(snapshot.cursor_path.as_deref(), Some(dest_str.as_str()));
1920
1921        let state = queries::load_playback_state(&db.conn).unwrap().unwrap();
1922        assert_eq!(state.items[0].path, dest_str);
1923        assert_eq!(state.cursor_path.as_deref(), Some(dest_str.as_str()));
1924
1925        assert_eq!(undo(&db).unwrap().restored, 1);
1926
1927        let favourites = queries::load_favourites(&db.conn).unwrap();
1928        assert!(favourites.contains(&source));
1929        assert!(!favourites.contains(&dest));
1930        let snapshot = queries::load_snapshot(&db.conn, "mine").unwrap().unwrap();
1931        assert_eq!(snapshot.items[0].path, source_str);
1932        assert_eq!(snapshot.cursor_path.as_deref(), Some(source_str.as_str()));
1933    }
1934
1935    #[test]
1936    fn scan_cache_follows_the_move() {
1937        let db = test_db();
1938        let tmp = TempDir::new().unwrap();
1939        let source = tmp.path().join("src/test.flac");
1940        let id = add_track(&db, &source, "Airbag", 1);
1941        db.conn
1942            .execute(
1943                "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
1944                params![source.to_string_lossy().as_ref(), id],
1945            )
1946            .unwrap();
1947
1948        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1949        let dest = result.moves[0].to.to_string_lossy().into_owned();
1950
1951        let cached: String = db
1952            .conn
1953            .query_row(
1954                "SELECT path FROM scan_cache WHERE track_id = ?1",
1955                params![id],
1956                |r| r.get(0),
1957            )
1958            .unwrap();
1959        assert_eq!(cached, dest);
1960    }
1961
1962    /// A failure partway through a batch must leave the rest of the run truthful: the
1963    /// files that moved are in the result and the log, the one that didn't is in neither.
1964    #[test]
1965    fn partial_failure_leaves_the_database_and_result_consistent() {
1966        let db = test_db();
1967        let tmp = TempDir::new().unwrap();
1968        let first = tmp.path().join("src/a.flac");
1969        let clash = tmp.path().join("src/b.flac");
1970        let third = tmp.path().join("src/c.flac");
1971        let first_id = add_track(&db, &first, "Airbag", 1);
1972        let clash_id = add_track(&db, &clash, "Airbag", 2);
1973        let third_id = add_track(&db, &third, "Karma Police", 3);
1974
1975        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
1976
1977        assert_eq!(result.moves.len(), 2);
1978        assert_eq!(result.errors.len(), 1);
1979
1980        let logged = log_rows(&db);
1981        assert_eq!(logged.len(), 2);
1982        for file_move in &result.moves {
1983            assert!(file_move.to.exists());
1984            assert!(
1985                logged
1986                    .iter()
1987                    .any(|(_, _, to)| Path::new(to) == file_move.to)
1988            );
1989        }
1990
1991        // The failed file is untouched, in the filesystem and in the database.
1992        assert!(clash.exists());
1993        assert_eq!(
1994            db_path_of(&db, clash_id).as_deref(),
1995            Some(clash.to_str().unwrap())
1996        );
1997        assert_ne!(db_path_of(&db, first_id).as_deref(), first.to_str());
1998        assert_ne!(db_path_of(&db, third_id).as_deref(), third.to_str());
1999    }
2000
2001    /// The TUI organizes a selection of paths. Files the library doesn't know about
2002    /// still get a log entry, so the whole run can be undone.
2003    #[test]
2004    fn unknown_paths_are_logged_and_undoable() {
2005        let db = test_db();
2006        let tmp = TempDir::new().unwrap();
2007        let known = tmp.path().join("src/known.flac");
2008        add_track(&db, &known, "Airbag", 1);
2009
2010        let result = run(
2011            &db,
2012            Selection::Paths(std::slice::from_ref(&known)),
2013            "%album artist%/%album%/%title%",
2014            tmp.path(),
2015        )
2016        .unwrap();
2017
2018        assert_eq!(result.moves.len(), 1);
2019        let logged = log_rows(&db);
2020        assert_eq!(logged.len(), 1);
2021        assert!(logged[0].0.is_some());
2022
2023        assert_eq!(undo(&db).unwrap().restored, 1);
2024        assert!(known.exists());
2025    }
2026
2027    #[test]
2028    fn ancillary_files_move_with_the_album() {
2029        let db = test_db();
2030        let tmp = TempDir::new().unwrap();
2031        let source = tmp.path().join("src/test.flac");
2032        add_track(&db, &source, "Airbag", 1);
2033        std::fs::write(source.parent().unwrap().join("cover.jpg"), b"art").unwrap();
2034
2035        let result = execute(&db, "%album artist%/%album%/%title%", Some(tmp.path())).unwrap();
2036        assert_eq!(result.moves.len(), 1);
2037        let dest_dir = result.moves[0].to.parent().unwrap();
2038        assert!(dest_dir.join("cover.jpg").exists());
2039
2040        // Both the audio and the artwork are in the log, so undo restores both.
2041        assert_eq!(log_rows(&db).len(), 2);
2042        assert_eq!(undo(&db).unwrap().restored, 2);
2043        assert!(source.parent().unwrap().join("cover.jpg").exists());
2044    }
2045
2046    // ---- Extension handling ----
2047
2048    #[test]
2049    fn extension_not_clobbered_by_dots_in_title() {
2050        // Regression: with_extension() replaces after the LAST dot,
2051        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
2052        let db = test_db();
2053        let tmp = TempDir::new().unwrap();
2054        let source = tmp.path().join("src/CHROMA 011 A.L.O.E II.flac");
2055        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2056        std::fs::write(&source, b"fake").unwrap();
2057
2058        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
2059        meta.track_number = Some(10);
2060        meta.date = Some("2025-11-21".into());
2061        meta.path = Some(source.to_string_lossy().into_owned());
2062        queries::upsert_track(&db.conn, &meta).unwrap();
2063
2064        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2065        let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2066        assert_eq!(result.moves.len(), 1);
2067        assert_eq!(
2068            result.moves[0].to.file_name().unwrap().to_string_lossy(),
2069            "0110. Bicep - CHROMA 011 A.L.O.E II.flac"
2070        );
2071    }
2072
2073    #[test]
2074    fn extension_preserved_for_tracknumber_dot() {
2075        // "0111. Bicep - TANGZ II" must not become "0111.flac"
2076        let db = test_db();
2077        let tmp = TempDir::new().unwrap();
2078        let source = tmp.path().join("src/CHROMA 012 TANGZ II.flac");
2079        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
2080        std::fs::write(&source, b"fake").unwrap();
2081
2082        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
2083        meta.track_number = Some(11);
2084        meta.date = Some("2025-11-21".into());
2085        meta.path = Some(source.to_string_lossy().into_owned());
2086        queries::upsert_track(&db.conn, &meta).unwrap();
2087
2088        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
2089        let result = preview(&db, pattern, Some(tmp.path())).unwrap();
2090        assert_eq!(result.moves.len(), 1);
2091        assert_eq!(
2092            result.moves[0].to.file_name().unwrap().to_string_lossy(),
2093            "0111. Bicep - CHROMA 012 TANGZ II.flac"
2094        );
2095    }
2096}