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