Skip to main content

koan_core/
organize.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rusqlite::params;
5
6use crate::db::connection::{Database, DbError};
7use crate::db::queries::{self, TrackRow};
8use crate::format::{self, FormatError, MetadataProvider};
9
10/// Ancillary file patterns we move alongside audio files.
11const ANCILLARY_PATTERNS: &[&str] = &[
12    "cover.jpg",
13    "cover.png",
14    "cover.webp",
15    "folder.jpg",
16    "folder.png",
17    "front.jpg",
18    "front.png",
19];
20
21const ANCILLARY_EXTENSIONS: &[&str] = &["cue", "log", "m3u", "m3u8"];
22
23#[derive(Debug, thiserror::Error)]
24pub enum OrganizeError {
25    #[error("database error: {0}")]
26    Db(#[from] DbError),
27    #[error("sqlite error: {0}")]
28    Sqlite(#[from] rusqlite::Error),
29    #[error("format error: {0}")]
30    Format(#[from] FormatError),
31    #[error("io error: {0}")]
32    Io(#[from] std::io::Error),
33    #[error("no tracks with local paths found")]
34    NoLocalTracks,
35    #[error("no organize batches to undo")]
36    NothingToUndo,
37}
38
39#[derive(Debug)]
40pub struct OrganizeResult {
41    pub moves: Vec<FileMove>,
42    pub errors: Vec<(PathBuf, String)>,
43    pub skipped: usize,
44}
45
46#[derive(Debug)]
47pub struct FileMove {
48    pub track_id: i64,
49    pub from: PathBuf,
50    pub to: PathBuf,
51    pub ancillary: Vec<(PathBuf, PathBuf)>,
52}
53
54/// Metadata provider backed by a HashMap, for evaluating format strings against track data.
55struct TrackMetadata {
56    fields: HashMap<String, String>,
57}
58
59impl TrackMetadata {
60    fn from_track_row(track: &TrackRow, album_date: Option<&str>) -> Self {
61        let mut fields = HashMap::new();
62        // Sanitize all field values so they can't inject path separators or illegal chars.
63        let s = sanitize_path_component;
64        fields.insert("title".into(), s(&track.title));
65        fields.insert("artist".into(), s(&track.artist_name));
66        fields.insert("album artist".into(), s(&track.album_artist_name));
67        fields.insert("album".into(), s(&track.album_title));
68        if let Some(n) = track.track_number {
69            fields.insert("tracknumber".into(), format!("{n:02}"));
70        }
71        if let Some(d) = track.disc {
72            fields.insert("discnumber".into(), d.to_string());
73        }
74        if let Some(date) = album_date {
75            // Date is safe (digits + hyphens) but sanitize anyway for consistency.
76            fields.insert("date".into(), date.to_string());
77        }
78        if let Some(ref codec) = track.codec {
79            fields.insert("codec".into(), codec.clone());
80        }
81        if let Some(ref genre) = track.genre {
82            fields.insert("genre".into(), s(genre));
83        }
84        Self { fields }
85    }
86
87    /// Build metadata directly from file tags (no DB required).
88    fn from_file_meta(meta: &queries::TrackMeta) -> Self {
89        let mut fields = HashMap::new();
90        let s = sanitize_path_component;
91        fields.insert("title".into(), s(&meta.title));
92        fields.insert("artist".into(), s(&meta.artist));
93        fields.insert(
94            "album artist".into(),
95            s(meta.album_artist.as_deref().unwrap_or(&meta.artist)),
96        );
97        fields.insert("album".into(), s(&meta.album));
98        if let Some(n) = meta.track_number {
99            fields.insert("tracknumber".into(), format!("{n:02}"));
100        }
101        if let Some(d) = meta.disc {
102            fields.insert("discnumber".into(), d.to_string());
103        }
104        if let Some(ref date) = meta.date {
105            fields.insert("date".into(), date.clone());
106        }
107        if let Some(ref codec) = meta.codec {
108            fields.insert("codec".into(), codec.clone());
109        }
110        if let Some(ref genre) = meta.genre {
111            fields.insert("genre".into(), s(genre));
112        }
113        if let Some(ref label) = meta.label {
114            fields.insert("label".into(), s(label));
115        }
116        Self { fields }
117    }
118}
119
120impl MetadataProvider for TrackMetadata {
121    fn get_field(&self, name: &str) -> Option<String> {
122        self.fields.get(name).cloned()
123    }
124}
125
126/// Replace characters that are illegal in file/directory names.
127fn sanitize_path_component(s: &str) -> String {
128    s.chars()
129        .map(|c| match c {
130            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
131            _ => c,
132        })
133        .collect::<String>()
134        .trim()
135        .to_string()
136}
137
138/// Sanitize each component of a relative path independently.
139/// Rejects `..` and `.` components to prevent path traversal attacks
140/// (e.g. malicious metadata containing `../../../../etc/passwd`).
141fn sanitize_relative_path(rel: &str) -> PathBuf {
142    let parts: Vec<&str> = if rel.contains('/') {
143        rel.split('/')
144    } else {
145        rel.split(std::path::MAIN_SEPARATOR)
146    }
147    .collect();
148
149    let mut result = PathBuf::new();
150    for part in parts {
151        let sanitized = sanitize_path_component(part);
152        if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
153            continue;
154        }
155        result.push(sanitized);
156    }
157    result
158}
159
160/// Get the album date for a track via its album_id.
161fn album_date_for_track(
162    db: &Database,
163    track: &TrackRow,
164) -> Result<Option<String>, rusqlite::Error> {
165    let Some(album_id) = track.album_id else {
166        return Ok(None);
167    };
168    db.conn
169        .query_row(
170            "SELECT date FROM albums WHERE id = ?1",
171            params![album_id],
172            |row| row.get::<_, Option<String>>(0),
173        )
174        .or(Ok(None))
175}
176
177/// Find ancillary files in the same directory as a track.
178fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
179    let mut files = Vec::new();
180    let Ok(entries) = std::fs::read_dir(track_dir) else {
181        return files;
182    };
183
184    for entry in entries.flatten() {
185        let path = entry.path();
186        if !path.is_file() {
187            continue;
188        }
189        let name = path
190            .file_name()
191            .and_then(|n| n.to_str())
192            .unwrap_or_default()
193            .to_lowercase();
194
195        // Check exact name matches.
196        if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
197            files.push(path);
198            continue;
199        }
200        // Check extension matches.
201        if let Some(ext) = path.extension().and_then(|e| e.to_str())
202            && ANCILLARY_EXTENSIONS
203                .iter()
204                .any(|e| ext.eq_ignore_ascii_case(e))
205        {
206            files.push(path);
207        }
208    }
209    files
210}
211
212/// Plan a single file move: format pattern, sanitize path, build dest, find ancillary files.
213///
214/// Returns `Ok(None)` when source == dest (skipped), `Ok(Some(FileMove))` for a planned move,
215/// or `Err(String)` for errors that should be collected by the caller.
216fn plan_single_move(
217    source: &Path,
218    track_id: i64,
219    metadata: &TrackMetadata,
220    pattern: &str,
221    base_dir: &Path,
222    planned_ancillary: &mut std::collections::HashSet<PathBuf>,
223) -> Result<Option<FileMove>, String> {
224    let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
225
226    if relative.is_empty() {
227        return Err("format string produced empty path".into());
228    }
229
230    let sanitized = sanitize_relative_path(&relative);
231
232    if sanitized.as_os_str().is_empty() {
233        return Err("format string produced empty path after sanitization".into());
234    }
235
236    // Preserve the original file extension.
237    // Don't use with_extension() — it replaces after the LAST dot, which
238    // destroys titles containing dots (e.g. "0111. Bicep - TANGZ II" → "0111.flac").
239    let ext = source
240        .extension()
241        .and_then(|e| e.to_str())
242        .unwrap_or("flac");
243    let mut dest = base_dir.join(sanitized);
244    let stem = dest.as_os_str().to_os_string();
245    dest = PathBuf::from(format!("{}.{}", stem.to_string_lossy(), ext));
246
247    // Safety: verify dest stays under base_dir (defense-in-depth against path traversal).
248    if !dest.starts_with(base_dir) {
249        return Err(format!(
250            "path traversal blocked: destination {} escapes base dir {}",
251            dest.display(),
252            base_dir.display()
253        ));
254    }
255
256    if paths_equal(source, &dest) {
257        return Ok(None);
258    }
259
260    // Plan ancillary moves — move files in source dir to dest dir.
261    let source_dir = source.parent().unwrap_or(Path::new("."));
262    let dest_dir = dest.parent().unwrap_or(Path::new("."));
263    let mut ancillary = Vec::new();
264
265    if source_dir != dest_dir {
266        for anc_path in find_ancillary_files(source_dir) {
267            if planned_ancillary.contains(&anc_path) {
268                continue;
269            }
270            let Some(anc_name) = anc_path.file_name() else {
271                continue;
272            };
273            let anc_dest = dest_dir.join(anc_name);
274            planned_ancillary.insert(anc_path.clone());
275            ancillary.push((anc_path, anc_dest));
276        }
277    }
278
279    Ok(Some(FileMove {
280        track_id,
281        from: source.to_path_buf(),
282        to: dest,
283        ancillary,
284    }))
285}
286
287/// Build the list of moves for all local tracks (or a filtered subset).
288fn plan_moves(
289    db: &Database,
290    pattern: &str,
291    base_dir: &Path,
292    track_ids: Option<&[i64]>,
293) -> Result<OrganizeResult, OrganizeError> {
294    let tracks = match track_ids {
295        Some(ids) => {
296            let mut tracks = Vec::with_capacity(ids.len());
297            for &id in ids {
298                if let Some(row) = queries::get_track_row(&db.conn, id)? {
299                    tracks.push(row);
300                }
301            }
302            tracks
303        }
304        None => queries::all_tracks(&db.conn)?,
305    };
306    let mut moves = Vec::new();
307    let mut errors = Vec::new();
308    let mut skipped = 0;
309
310    // Track which ancillary files we've already planned to move (dedup across tracks in same dir).
311    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
312        std::collections::HashSet::new();
313
314    for track in &tracks {
315        let Some(ref path_str) = track.path else {
316            continue; // remote-only, skip
317        };
318
319        let source = PathBuf::from(path_str);
320        if !source.exists() {
321            continue; // file gone, skip
322        }
323
324        let album_date = match album_date_for_track(db, track) {
325            Ok(d) => d,
326            Err(e) => {
327                errors.push((source, format!("failed to get album date: {e}")));
328                continue;
329            }
330        };
331
332        let metadata = TrackMetadata::from_track_row(track, album_date.as_deref());
333
334        match plan_single_move(
335            &source,
336            track.id,
337            &metadata,
338            pattern,
339            base_dir,
340            &mut planned_ancillary,
341        ) {
342            Ok(Some(file_move)) => moves.push(file_move),
343            Ok(None) => skipped += 1,
344            Err(msg) => errors.push((source, msg)),
345        }
346    }
347
348    Ok(OrganizeResult {
349        moves,
350        errors,
351        skipped,
352    })
353}
354
355/// Build the list of moves from file paths directly (no DB required).
356/// Reads metadata from tags in parallel (rayon), then plans moves serially
357/// (ancillary dedup requires ordered processing).
358fn plan_moves_from_paths(
359    paths: &[PathBuf],
360    pattern: &str,
361    base_dir: &Path,
362) -> Result<OrganizeResult, OrganizeError> {
363    use rayon::prelude::*;
364
365    // Phase 1: Read metadata in parallel — this is the expensive part (disk I/O + tag parsing).
366    let meta_results: Vec<_> = paths
367        .par_iter()
368        .map(|source| {
369            if !source.exists() {
370                return (source.clone(), Err("file not found".to_string()));
371            }
372            match crate::index::metadata::read_metadata(source) {
373                Ok(m) => (source.clone(), Ok(TrackMetadata::from_file_meta(&m))),
374                Err(e) => (source.clone(), Err(format!("metadata error: {e}"))),
375            }
376        })
377        .collect();
378
379    // Phase 2: Plan moves serially (ancillary dedup needs ordered HashSet access).
380    let mut moves = Vec::new();
381    let mut errors = Vec::new();
382    let mut skipped = 0;
383    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
384        std::collections::HashSet::new();
385
386    for (source, meta_result) in meta_results {
387        let metadata = match meta_result {
388            Ok(m) => m,
389            Err(msg) => {
390                errors.push((source, msg));
391                continue;
392            }
393        };
394
395        match plan_single_move(
396            &source,
397            0,
398            &metadata,
399            pattern,
400            base_dir,
401            &mut planned_ancillary,
402        ) {
403            Ok(Some(file_move)) => moves.push(file_move),
404            Ok(None) => skipped += 1,
405            Err(msg) => errors.push((source, msg)),
406        }
407    }
408
409    Ok(OrganizeResult {
410        moves,
411        errors,
412        skipped,
413    })
414}
415
416/// Preview organize for file paths. Uses the DB for metadata when available
417/// (instant), falls back to parallel disk reads for files not in the DB.
418pub fn preview_for_paths(
419    paths: &[PathBuf],
420    pattern: &str,
421    base_dir: Option<&Path>,
422) -> Result<OrganizeResult, OrganizeError> {
423    use rayon::prelude::*;
424
425    let base = resolve_base_dir(base_dir)?;
426
427    // Try to load metadata from the DB (single query, instant for 50k+ tracks).
428    let db_cache: std::collections::HashMap<String, TrackRow> = Database::open_default()
429        .ok()
430        .and_then(|db| queries::all_tracks_by_path(&db.conn).ok())
431        .unwrap_or_default();
432
433    // Build album date cache from DB to avoid per-track queries.
434    let album_dates: std::collections::HashMap<i64, Option<String>> = if !db_cache.is_empty() {
435        let db = Database::open_default().ok();
436        db.map(|db| {
437            let mut dates = std::collections::HashMap::new();
438            for track in db_cache.values() {
439                if let Some(album_id) = track.album_id {
440                    dates
441                        .entry(album_id)
442                        .or_insert_with(|| queries::album_date(&db.conn, album_id).ok().flatten());
443                }
444            }
445            dates
446        })
447        .unwrap_or_default()
448    } else {
449        std::collections::HashMap::new()
450    };
451
452    // Phase 1: Resolve metadata — DB hits are instant, misses go to disk in parallel.
453    let meta_results: Vec<_> = paths
454        .par_iter()
455        .map(|source| {
456            let path_str = source.to_string_lossy();
457
458            // Try DB first.
459            if let Some(track) = db_cache.get(path_str.as_ref()) {
460                let album_date = track
461                    .album_id
462                    .and_then(|aid| album_dates.get(&aid).and_then(|d| d.as_deref()));
463                return (
464                    source.clone(),
465                    track.id,
466                    Ok(TrackMetadata::from_track_row(track, album_date)),
467                );
468            }
469
470            // Fallback: read from disk.
471            if !source.exists() {
472                return (source.clone(), 0, Err("file not found".to_string()));
473            }
474            match crate::index::metadata::read_metadata(source) {
475                Ok(m) => (source.clone(), 0, Ok(TrackMetadata::from_file_meta(&m))),
476                Err(e) => (source.clone(), 0, Err(format!("metadata error: {e}"))),
477            }
478        })
479        .collect();
480
481    // Phase 2: Plan moves serially (ancillary dedup needs ordered HashSet access).
482    let mut moves = Vec::new();
483    let mut errors = Vec::new();
484    let mut skipped = 0;
485    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
486        std::collections::HashSet::new();
487
488    for (source, track_id, meta_result) in meta_results {
489        let metadata = match meta_result {
490            Ok(m) => m,
491            Err(msg) => {
492                errors.push((source, msg));
493                continue;
494            }
495        };
496
497        match plan_single_move(
498            &source,
499            track_id,
500            &metadata,
501            pattern,
502            &base,
503            &mut planned_ancillary,
504        ) {
505            Ok(Some(file_move)) => moves.push(file_move),
506            Ok(None) => skipped += 1,
507            Err(msg) => errors.push((source, msg)),
508        }
509    }
510
511    Ok(OrganizeResult {
512        moves,
513        errors,
514        skipped,
515    })
516}
517
518/// Execute organize for file paths (no DB required — reads tags, moves files).
519/// Does NOT log to organize_log or update DB paths (since tracks may not be in DB).
520pub fn execute_for_paths(
521    paths: &[PathBuf],
522    pattern: &str,
523    base_dir: Option<&Path>,
524) -> Result<OrganizeResult, OrganizeError> {
525    let base = resolve_base_dir(base_dir)?;
526    let mut result = plan_moves_from_paths(paths, pattern, &base)?;
527
528    if result.moves.is_empty() {
529        return Ok(result);
530    }
531
532    let mut completed_moves = Vec::new();
533    let mut new_errors = Vec::new();
534
535    for file_move in result.moves.drain(..) {
536        match execute_single_move_no_db(&file_move) {
537            Ok(()) => match verify_move(&file_move) {
538                Ok(()) => completed_moves.push(file_move),
539                Err(msg) => new_errors.push((file_move.from, msg)),
540            },
541            Err(e) => {
542                new_errors.push((file_move.from, e.to_string()));
543            }
544        }
545    }
546
547    result.moves = completed_moves;
548    result.errors.extend(new_errors);
549    Ok(result)
550}
551
552/// Verify a move actually happened — dest exists and source is gone.
553fn verify_move(file_move: &FileMove) -> Result<(), String> {
554    if !file_move.to.exists() {
555        return Err(format!(
556            "destination not found after move: {}",
557            file_move.to.display()
558        ));
559    }
560    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
561        return Err(format!(
562            "source still exists after move: {}",
563            file_move.from.display()
564        ));
565    }
566    Ok(())
567}
568
569/// Execute a single file move without DB logging (for non-library tracks).
570fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
571    if let Some(parent) = file_move.to.parent() {
572        std::fs::create_dir_all(parent)?;
573    }
574
575    move_file(&file_move.from, &file_move.to)?;
576
577    // Move ancillary files (best-effort).
578    for (anc_from, anc_to) in &file_move.ancillary {
579        if let Some(parent) = anc_to.parent() {
580            std::fs::create_dir_all(parent).ok();
581        }
582        if let Err(e) = move_file(anc_from, anc_to) {
583            log::warn!(
584                "failed to move ancillary file {}: {}",
585                anc_from.display(),
586                e
587            );
588        }
589    }
590
591    if let Some(source_dir) = file_move.from.parent() {
592        remove_empty_dirs(source_dir);
593    }
594
595    Ok(())
596}
597
598/// Preview what would happen without moving files.
599pub fn preview(
600    db: &Database,
601    pattern: &str,
602    base_dir: Option<&Path>,
603) -> Result<OrganizeResult, OrganizeError> {
604    let base = resolve_base_dir(base_dir)?;
605    plan_moves(db, pattern, &base, None)
606}
607
608/// Execute the moves: rename files, update DB, log for undo.
609pub fn execute(
610    db: &Database,
611    pattern: &str,
612    base_dir: Option<&Path>,
613) -> Result<OrganizeResult, OrganizeError> {
614    let base = resolve_base_dir(base_dir)?;
615    let mut result = plan_moves(db, pattern, &base, None)?;
616
617    if result.moves.is_empty() {
618        return Ok(result);
619    }
620
621    // Generate a batch ID from timestamp.
622    let batch_id = chrono_batch_id();
623
624    let mut completed_moves = Vec::new();
625    let mut new_errors = Vec::new();
626
627    for file_move in result.moves.drain(..) {
628        match execute_single_move(db, &file_move, &batch_id) {
629            Ok(()) => match verify_move(&file_move) {
630                Ok(()) => completed_moves.push(file_move),
631                Err(msg) => new_errors.push((file_move.from, msg)),
632            },
633            Err(e) => {
634                new_errors.push((file_move.from, e.to_string()));
635            }
636        }
637    }
638
639    result.moves = completed_moves;
640    result.errors.extend(new_errors);
641    Ok(result)
642}
643
644/// Preview organize for a specific set of tracks.
645pub fn preview_for_tracks(
646    db: &Database,
647    track_ids: &[i64],
648    pattern: &str,
649    base_dir: Option<&Path>,
650) -> Result<OrganizeResult, OrganizeError> {
651    let base = resolve_base_dir(base_dir)?;
652    plan_moves(db, pattern, &base, Some(track_ids))
653}
654
655/// Execute organize for a specific set of tracks.
656pub fn execute_for_tracks(
657    db: &Database,
658    track_ids: &[i64],
659    pattern: &str,
660    base_dir: Option<&Path>,
661) -> Result<OrganizeResult, OrganizeError> {
662    let base = resolve_base_dir(base_dir)?;
663    let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
664
665    if result.moves.is_empty() {
666        return Ok(result);
667    }
668
669    let batch_id = chrono_batch_id();
670    let mut completed_moves = Vec::new();
671    let mut new_errors = Vec::new();
672
673    for file_move in result.moves.drain(..) {
674        match execute_single_move(db, &file_move, &batch_id) {
675            Ok(()) => match verify_move(&file_move) {
676                Ok(()) => completed_moves.push(file_move),
677                Err(msg) => new_errors.push((file_move.from, msg)),
678            },
679            Err(e) => {
680                new_errors.push((file_move.from, e.to_string()));
681            }
682        }
683    }
684
685    result.moves = completed_moves;
686    result.errors.extend(new_errors);
687    Ok(result)
688}
689
690/// Execute a single file move: create dirs, move file + ancillary, update DB, write log.
691fn execute_single_move(
692    db: &Database,
693    file_move: &FileMove,
694    batch_id: &str,
695) -> Result<(), OrganizeError> {
696    // Create destination directory.
697    if let Some(parent) = file_move.to.parent() {
698        std::fs::create_dir_all(parent)?;
699    }
700
701    // Move the audio file (falls back to copy+delete across filesystems).
702    move_file(&file_move.from, &file_move.to)?;
703
704    // Log the move.
705    db.conn.execute(
706        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
707        params![
708            batch_id,
709            file_move.track_id,
710            file_move.from.to_string_lossy().as_ref(),
711            file_move.to.to_string_lossy().as_ref(),
712        ],
713    )?;
714
715    // Update the track's path in the database.
716    db.conn.execute(
717        "UPDATE tracks SET path = ?1 WHERE id = ?2",
718        params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
719    )?;
720
721    // Update scan_cache if it exists for the old path.
722    db.conn.execute(
723        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
724        params![
725            file_move.to.to_string_lossy().as_ref(),
726            file_move.from.to_string_lossy().as_ref(),
727        ],
728    )?;
729
730    // Move ancillary files.
731    for (anc_from, anc_to) in &file_move.ancillary {
732        if let Some(parent) = anc_to.parent() {
733            std::fs::create_dir_all(parent)?;
734        }
735        // Best-effort — don't fail the whole move if ancillary fails.
736        if move_file(anc_from, anc_to).is_ok() {
737            db.conn.execute(
738                "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
739                params![
740                    batch_id,
741                    anc_from.to_string_lossy().as_ref(),
742                    anc_to.to_string_lossy().as_ref(),
743                ],
744            )?;
745        }
746    }
747
748    // Try to remove empty source directories.
749    if let Some(source_dir) = file_move.from.parent() {
750        remove_empty_dirs(source_dir);
751    }
752
753    Ok(())
754}
755
756/// Undo the most recent organize batch.
757pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
758    // Find the most recent batch.
759    let batch_id: String = db
760        .conn
761        .query_row(
762            "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
763            [],
764            |row| row.get(0),
765        )
766        .map_err(|_| OrganizeError::NothingToUndo)?;
767
768    // Get all moves in this batch, in reverse order.
769    let mut stmt = db.conn.prepare(
770        "SELECT id, track_id, from_path, to_path FROM organize_log
771         WHERE batch_id = ?1 ORDER BY id DESC",
772    )?;
773
774    let entries: Vec<(i64, Option<i64>, String, String)> = stmt
775        .query_map(params![batch_id], |row| {
776            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
777        })?
778        .collect::<Result<Vec<_>, _>>()?;
779
780    let mut count = 0;
781
782    for (log_id, track_id, from_path, to_path) in &entries {
783        let to = Path::new(to_path);
784        let from = Path::new(from_path);
785
786        if !to.exists() {
787            // Already moved back or deleted — skip.
788            db.conn
789                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
790            continue;
791        }
792
793        // Create original parent dir.
794        if let Some(parent) = from.parent() {
795            std::fs::create_dir_all(parent)?;
796        }
797
798        // Move back (falls back to copy+delete across filesystems).
799        move_file(to, from)?;
800
801        // Update track path in DB if this was a track (not ancillary).
802        if let Some(tid) = track_id {
803            db.conn.execute(
804                "UPDATE tracks SET path = ?1 WHERE id = ?2",
805                params![from_path, tid],
806            )?;
807            db.conn.execute(
808                "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
809                params![from_path, to_path],
810            )?;
811        }
812
813        // Remove empty dirs at the (now old) destination.
814        if let Some(parent) = to.parent() {
815            remove_empty_dirs(parent);
816        }
817
818        db.conn
819            .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
820
821        count += 1;
822    }
823
824    Ok(count)
825}
826
827/// Walk up directories removing empty ones, stopping at first non-empty.
828fn remove_empty_dirs(dir: &Path) {
829    let mut current = dir.to_path_buf();
830    loop {
831        if std::fs::read_dir(&current)
832            .map(|mut d| d.next().is_none())
833            .unwrap_or(false)
834        {
835            if std::fs::remove_dir(&current).is_err() {
836                break;
837            }
838            match current.parent() {
839                Some(p) => current = p.to_path_buf(),
840                None => break,
841            }
842        } else {
843            break;
844        }
845    }
846}
847
848/// Move a file, falling back to copy+delete if rename fails (cross-device).
849fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
850    match std::fs::rename(from, to) {
851        Ok(()) => Ok(()),
852        Err(e) if e.raw_os_error() == Some(18) => {
853            // EXDEV (18): cross-device link — copy then delete original.
854            std::fs::copy(from, to)?;
855            std::fs::remove_file(from)?;
856            Ok(())
857        }
858        Err(e) => Err(e.into()),
859    }
860}
861
862/// Compare paths for equality, canonicalizing if possible (handles macOS case-insensitive FS).
863fn paths_equal(a: &Path, b: &Path) -> bool {
864    if a == b {
865        return true;
866    }
867    // Try canonicalizing both — handles symlinks, case differences, etc.
868    if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
869        return ca == cb;
870    }
871    false
872}
873
874fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
875    if let Some(dir) = base_dir {
876        return Ok(dir.to_path_buf());
877    }
878
879    // Use first configured library folder.
880    let config = crate::config::Config::load()
881        .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
882
883    config.library.folders.into_iter().next().ok_or_else(|| {
884        OrganizeError::Io(std::io::Error::other(
885            "no library folders configured; use --base-dir",
886        ))
887    })
888}
889
890fn chrono_batch_id() -> String {
891    let now = std::time::SystemTime::now()
892        .duration_since(std::time::UNIX_EPOCH)
893        .unwrap_or_default();
894    format!("batch-{}", now.as_nanos())
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use crate::db::connection::Database;
901    use crate::db::queries::TrackMeta;
902    use crate::db::schema;
903
904    fn test_db() -> Database {
905        let conn = rusqlite::Connection::open_in_memory().unwrap();
906        conn.pragma_update(None, "foreign_keys", "on").unwrap();
907        schema::create_tables(&conn).unwrap();
908        Database { conn }
909    }
910
911    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
912        TrackMeta {
913            title: title.into(),
914            artist: artist.into(),
915            album_artist: Some(artist.into()),
916            album: album.into(),
917            date: Some("1997-06-16".into()),
918            disc: Some(1),
919            track_number: Some(1),
920            genre: Some("Rock".into()),
921            label: None,
922            duration_ms: Some(240_000),
923            codec: Some("FLAC".into()),
924            sample_rate: Some(44100),
925            bit_depth: Some(16),
926            channels: Some(2),
927            bitrate: Some(1000),
928            size_bytes: Some(30_000_000),
929            mtime: Some(1700000000),
930            path: None,
931            source: "local".into(),
932            remote_id: None,
933            remote_url: None,
934        }
935    }
936
937    #[test]
938    fn track_metadata_provider_fields() {
939        let track = TrackRow {
940            id: 1,
941            album_id: Some(1),
942            artist_id: Some(1),
943            artist_name: "Radiohead".into(),
944            album_artist_name: "Radiohead".into(),
945            album_title: "OK Computer".into(),
946            disc: Some(1),
947            track_number: Some(3),
948            title: "Subterranean Homesick Alien".into(),
949            duration_ms: Some(240_000),
950            path: Some("/music/ok_computer/03.flac".into()),
951            codec: Some("FLAC".into()),
952            sample_rate: Some(44100),
953            bit_depth: Some(16),
954            channels: Some(2),
955            bitrate: Some(1000),
956            genre: Some("Alternative".into()),
957            source: "local".into(),
958            remote_id: None,
959            cached_path: None,
960        };
961
962        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
963        assert_eq!(
964            meta.get_field("title").as_deref(),
965            Some("Subterranean Homesick Alien")
966        );
967        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
968        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
969        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
970        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
971        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
972        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
973        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
974        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
975        assert_eq!(meta.get_field("nonexistent"), None);
976    }
977
978    #[test]
979    fn sanitize_replaces_illegal_chars() {
980        assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
981        assert_eq!(sanitize_path_component("What?"), "What_");
982        assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
983        assert_eq!(sanitize_path_component("normal"), "normal");
984    }
985
986    #[test]
987    fn sanitize_relative_path_splits() {
988        let result = sanitize_relative_path("Artist/Album/Track");
989        assert_eq!(result, PathBuf::from("Artist/Album/Track"));
990
991        let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
992        assert_eq!(
993            result,
994            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
995        );
996    }
997
998    #[test]
999    fn sanitize_relative_path_rejects_traversal() {
1000        // ".." components should be stripped to prevent path traversal.
1001        let result = sanitize_relative_path("../../../../etc/passwd");
1002        assert_eq!(result, PathBuf::from("etc/passwd"));
1003
1004        let result = sanitize_relative_path("Artist/../../../outside");
1005        assert_eq!(result, PathBuf::from("Artist/outside"));
1006
1007        // "." components should also be stripped.
1008        let result = sanitize_relative_path("./Artist/./Album");
1009        assert_eq!(result, PathBuf::from("Artist/Album"));
1010
1011        // Normal paths remain unchanged.
1012        let result = sanitize_relative_path("Artist/Album/Track");
1013        assert_eq!(result, PathBuf::from("Artist/Album/Track"));
1014    }
1015
1016    #[test]
1017    fn acdc_artist_name_sanitized() {
1018        // "AC/DC" should become "AC_DC" through field sanitization.
1019        let track = TrackRow {
1020            id: 1,
1021            album_id: Some(1),
1022            artist_id: Some(1),
1023            artist_name: "AC/DC".into(),
1024            album_artist_name: "AC/DC".into(),
1025            album_title: "Highway to Hell".into(),
1026            disc: Some(1),
1027            track_number: Some(1),
1028            title: "Highway to Hell".into(),
1029            duration_ms: None,
1030            path: Some("/music/test.flac".into()),
1031            codec: None,
1032            sample_rate: None,
1033            bit_depth: None,
1034            channels: None,
1035            bitrate: None,
1036            genre: None,
1037            source: "local".into(),
1038            remote_id: None,
1039            cached_path: None,
1040        };
1041        let meta = TrackMetadata::from_track_row(&track, Some("1979"));
1042        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
1043        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
1044        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
1045    }
1046
1047    #[test]
1048    fn format_string_evaluation() {
1049        let track = TrackRow {
1050            id: 1,
1051            album_id: Some(1),
1052            artist_id: Some(1),
1053            artist_name: "Radiohead".into(),
1054            album_artist_name: "Radiohead".into(),
1055            album_title: "OK Computer".into(),
1056            disc: Some(1),
1057            track_number: Some(1),
1058            title: "Airbag".into(),
1059            duration_ms: Some(240_000),
1060            path: Some("/music/test.flac".into()),
1061            codec: Some("FLAC".into()),
1062            sample_rate: Some(44100),
1063            bit_depth: Some(16),
1064            channels: Some(2),
1065            bitrate: Some(1000),
1066            genre: None,
1067            source: "local".into(),
1068            remote_id: None,
1069            cached_path: None,
1070        };
1071
1072        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
1073        let pattern =
1074            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
1075        let result = format::format(pattern, &meta).unwrap();
1076        assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
1077    }
1078
1079    #[test]
1080    fn preview_does_not_move_files() {
1081        let db = test_db();
1082        let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
1083        std::fs::create_dir_all(&tmp).unwrap();
1084
1085        // Create a test file.
1086        let test_file = tmp.join("test.flac");
1087        std::fs::write(&test_file, b"fake audio data").unwrap();
1088
1089        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1090        meta.path = Some(test_file.to_string_lossy().into());
1091        queries::upsert_track(&db.conn, &meta).unwrap();
1092
1093        let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1094        // The file should still be at the original path.
1095        assert!(test_file.exists());
1096        assert!(!result.moves.is_empty());
1097
1098        // Cleanup.
1099        std::fs::remove_dir_all(&tmp).ok();
1100    }
1101
1102    #[test]
1103    fn execute_moves_files_and_undo_reverts() {
1104        let db = test_db();
1105        let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
1106        let src_dir = tmp.join("src");
1107        std::fs::create_dir_all(&src_dir).unwrap();
1108
1109        // Create test files.
1110        let test_file = src_dir.join("test.flac");
1111        std::fs::write(&test_file, b"fake audio data").unwrap();
1112
1113        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1114        meta.path = Some(test_file.to_string_lossy().into());
1115        queries::upsert_track(&db.conn, &meta).unwrap();
1116
1117        // Execute.
1118        let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1119        assert_eq!(result.moves.len(), 1);
1120        assert!(!test_file.exists()); // original gone
1121        let dest = &result.moves[0].to;
1122        assert!(dest.exists()); // new location exists
1123
1124        // Undo.
1125        let undone = undo(&db).unwrap();
1126        assert_eq!(undone, 1);
1127        assert!(test_file.exists()); // back to original
1128        assert!(!dest.exists()); // new location gone
1129
1130        // Cleanup.
1131        std::fs::remove_dir_all(&tmp).ok();
1132    }
1133
1134    #[test]
1135    fn ancillary_file_detection() {
1136        let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1137        std::fs::create_dir_all(&tmp).unwrap();
1138
1139        std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1140        std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1141        std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1142        std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1143        std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); // not ancillary
1144
1145        let found = find_ancillary_files(&tmp);
1146        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1147        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1148        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1149        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1150        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1151
1152        std::fs::remove_dir_all(&tmp).ok();
1153    }
1154
1155    #[test]
1156    fn extension_not_clobbered_by_dots_in_title() {
1157        // Regression: with_extension() replaces after the LAST dot,
1158        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
1159        let db = test_db();
1160        let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1161        let src_dir = tmp.join("src");
1162        std::fs::create_dir_all(&src_dir).unwrap();
1163
1164        // Track with dots in title (A.L.O.E II) + tracknumber dot.
1165        let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1166        std::fs::write(&test_file, b"fake").unwrap();
1167
1168        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1169        meta.track_number = Some(10);
1170        meta.disc = Some(1);
1171        meta.date = Some("2025-11-21".into());
1172        meta.codec = Some("FLAC".into());
1173        meta.album_artist = Some("Bicep".into());
1174        meta.path = Some(test_file.to_string_lossy().into());
1175        queries::upsert_track(&db.conn, &meta).unwrap();
1176
1177        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1178        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1179        assert_eq!(result.moves.len(), 1);
1180
1181        let dest_name = result.moves[0]
1182            .to
1183            .file_name()
1184            .unwrap()
1185            .to_string_lossy()
1186            .to_string();
1187        // Must preserve full title including dots — NOT truncated by set_extension.
1188        assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1189
1190        std::fs::remove_dir_all(&tmp).ok();
1191    }
1192
1193    #[test]
1194    fn extension_preserved_for_tracknumber_dot() {
1195        // "0111. Bicep - TANGZ II" must not become "0111.flac"
1196        let db = test_db();
1197        let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1198        let src_dir = tmp.join("src");
1199        std::fs::create_dir_all(&src_dir).unwrap();
1200
1201        let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1202        std::fs::write(&test_file, b"fake").unwrap();
1203
1204        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1205        meta.track_number = Some(11);
1206        meta.disc = Some(1);
1207        meta.date = Some("2025-11-21".into());
1208        meta.codec = Some("FLAC".into());
1209        meta.album_artist = Some("Bicep".into());
1210        meta.path = Some(test_file.to_string_lossy().into());
1211        queries::upsert_track(&db.conn, &meta).unwrap();
1212
1213        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1214        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1215        assert_eq!(result.moves.len(), 1);
1216
1217        let dest_name = result.moves[0]
1218            .to
1219            .file_name()
1220            .unwrap()
1221            .to_string_lossy()
1222            .to_string();
1223        assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1224
1225        std::fs::remove_dir_all(&tmp).ok();
1226    }
1227}