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. Uses track_id=0 for all entries (no DB identity).
357fn plan_moves_from_paths(
358    paths: &[PathBuf],
359    pattern: &str,
360    base_dir: &Path,
361) -> Result<OrganizeResult, OrganizeError> {
362    let mut moves = Vec::new();
363    let mut errors = Vec::new();
364    let mut skipped = 0;
365
366    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
367        std::collections::HashSet::new();
368
369    for source in paths {
370        if !source.exists() {
371            errors.push((source.clone(), "file not found".into()));
372            continue;
373        }
374
375        let meta = match crate::index::metadata::read_metadata(source) {
376            Ok(m) => m,
377            Err(e) => {
378                errors.push((source.clone(), format!("metadata error: {e}")));
379                continue;
380            }
381        };
382
383        let metadata = TrackMetadata::from_file_meta(&meta);
384
385        match plan_single_move(
386            source,
387            0,
388            &metadata,
389            pattern,
390            base_dir,
391            &mut planned_ancillary,
392        ) {
393            Ok(Some(file_move)) => moves.push(file_move),
394            Ok(None) => skipped += 1,
395            Err(msg) => errors.push((source.clone(), msg)),
396        }
397    }
398
399    Ok(OrganizeResult {
400        moves,
401        errors,
402        skipped,
403    })
404}
405
406/// Preview organize for file paths (no DB required — reads tags directly).
407pub fn preview_for_paths(
408    paths: &[PathBuf],
409    pattern: &str,
410    base_dir: Option<&Path>,
411) -> Result<OrganizeResult, OrganizeError> {
412    let base = resolve_base_dir(base_dir)?;
413    plan_moves_from_paths(paths, pattern, &base)
414}
415
416/// Execute organize for file paths (no DB required — reads tags, moves files).
417/// Does NOT log to organize_log or update DB paths (since tracks may not be in DB).
418pub fn execute_for_paths(
419    paths: &[PathBuf],
420    pattern: &str,
421    base_dir: Option<&Path>,
422) -> Result<OrganizeResult, OrganizeError> {
423    let base = resolve_base_dir(base_dir)?;
424    let mut result = plan_moves_from_paths(paths, pattern, &base)?;
425
426    if result.moves.is_empty() {
427        return Ok(result);
428    }
429
430    let mut completed_moves = Vec::new();
431    let mut new_errors = Vec::new();
432
433    for file_move in result.moves.drain(..) {
434        match execute_single_move_no_db(&file_move) {
435            Ok(()) => match verify_move(&file_move) {
436                Ok(()) => completed_moves.push(file_move),
437                Err(msg) => new_errors.push((file_move.from, msg)),
438            },
439            Err(e) => {
440                new_errors.push((file_move.from, e.to_string()));
441            }
442        }
443    }
444
445    result.moves = completed_moves;
446    result.errors.extend(new_errors);
447    Ok(result)
448}
449
450/// Verify a move actually happened — dest exists and source is gone.
451fn verify_move(file_move: &FileMove) -> Result<(), String> {
452    if !file_move.to.exists() {
453        return Err(format!(
454            "destination not found after move: {}",
455            file_move.to.display()
456        ));
457    }
458    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
459        return Err(format!(
460            "source still exists after move: {}",
461            file_move.from.display()
462        ));
463    }
464    Ok(())
465}
466
467/// Execute a single file move without DB logging (for non-library tracks).
468fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
469    if let Some(parent) = file_move.to.parent() {
470        std::fs::create_dir_all(parent)?;
471    }
472
473    move_file(&file_move.from, &file_move.to)?;
474
475    // Move ancillary files (best-effort).
476    for (anc_from, anc_to) in &file_move.ancillary {
477        if let Some(parent) = anc_to.parent() {
478            std::fs::create_dir_all(parent).ok();
479        }
480        if let Err(e) = move_file(anc_from, anc_to) {
481            log::warn!(
482                "failed to move ancillary file {}: {}",
483                anc_from.display(),
484                e
485            );
486        }
487    }
488
489    if let Some(source_dir) = file_move.from.parent() {
490        remove_empty_dirs(source_dir);
491    }
492
493    Ok(())
494}
495
496/// Preview what would happen without moving files.
497pub fn preview(
498    db: &Database,
499    pattern: &str,
500    base_dir: Option<&Path>,
501) -> Result<OrganizeResult, OrganizeError> {
502    let base = resolve_base_dir(base_dir)?;
503    plan_moves(db, pattern, &base, None)
504}
505
506/// Execute the moves: rename files, update DB, log for undo.
507pub fn execute(
508    db: &Database,
509    pattern: &str,
510    base_dir: Option<&Path>,
511) -> Result<OrganizeResult, OrganizeError> {
512    let base = resolve_base_dir(base_dir)?;
513    let mut result = plan_moves(db, pattern, &base, None)?;
514
515    if result.moves.is_empty() {
516        return Ok(result);
517    }
518
519    // Generate a batch ID from timestamp.
520    let batch_id = chrono_batch_id();
521
522    let mut completed_moves = Vec::new();
523    let mut new_errors = Vec::new();
524
525    for file_move in result.moves.drain(..) {
526        match execute_single_move(db, &file_move, &batch_id) {
527            Ok(()) => match verify_move(&file_move) {
528                Ok(()) => completed_moves.push(file_move),
529                Err(msg) => new_errors.push((file_move.from, msg)),
530            },
531            Err(e) => {
532                new_errors.push((file_move.from, e.to_string()));
533            }
534        }
535    }
536
537    result.moves = completed_moves;
538    result.errors.extend(new_errors);
539    Ok(result)
540}
541
542/// Preview organize for a specific set of tracks.
543pub fn preview_for_tracks(
544    db: &Database,
545    track_ids: &[i64],
546    pattern: &str,
547    base_dir: Option<&Path>,
548) -> Result<OrganizeResult, OrganizeError> {
549    let base = resolve_base_dir(base_dir)?;
550    plan_moves(db, pattern, &base, Some(track_ids))
551}
552
553/// Execute organize for a specific set of tracks.
554pub fn execute_for_tracks(
555    db: &Database,
556    track_ids: &[i64],
557    pattern: &str,
558    base_dir: Option<&Path>,
559) -> Result<OrganizeResult, OrganizeError> {
560    let base = resolve_base_dir(base_dir)?;
561    let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
562
563    if result.moves.is_empty() {
564        return Ok(result);
565    }
566
567    let batch_id = chrono_batch_id();
568    let mut completed_moves = Vec::new();
569    let mut new_errors = Vec::new();
570
571    for file_move in result.moves.drain(..) {
572        match execute_single_move(db, &file_move, &batch_id) {
573            Ok(()) => match verify_move(&file_move) {
574                Ok(()) => completed_moves.push(file_move),
575                Err(msg) => new_errors.push((file_move.from, msg)),
576            },
577            Err(e) => {
578                new_errors.push((file_move.from, e.to_string()));
579            }
580        }
581    }
582
583    result.moves = completed_moves;
584    result.errors.extend(new_errors);
585    Ok(result)
586}
587
588/// Execute a single file move: create dirs, move file + ancillary, update DB, write log.
589fn execute_single_move(
590    db: &Database,
591    file_move: &FileMove,
592    batch_id: &str,
593) -> Result<(), OrganizeError> {
594    // Create destination directory.
595    if let Some(parent) = file_move.to.parent() {
596        std::fs::create_dir_all(parent)?;
597    }
598
599    // Move the audio file (falls back to copy+delete across filesystems).
600    move_file(&file_move.from, &file_move.to)?;
601
602    // Log the move.
603    db.conn.execute(
604        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
605        params![
606            batch_id,
607            file_move.track_id,
608            file_move.from.to_string_lossy().as_ref(),
609            file_move.to.to_string_lossy().as_ref(),
610        ],
611    )?;
612
613    // Update the track's path in the database.
614    db.conn.execute(
615        "UPDATE tracks SET path = ?1 WHERE id = ?2",
616        params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
617    )?;
618
619    // Update scan_cache if it exists for the old path.
620    db.conn.execute(
621        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
622        params![
623            file_move.to.to_string_lossy().as_ref(),
624            file_move.from.to_string_lossy().as_ref(),
625        ],
626    )?;
627
628    // Move ancillary files.
629    for (anc_from, anc_to) in &file_move.ancillary {
630        if let Some(parent) = anc_to.parent() {
631            std::fs::create_dir_all(parent)?;
632        }
633        // Best-effort — don't fail the whole move if ancillary fails.
634        if move_file(anc_from, anc_to).is_ok() {
635            db.conn.execute(
636                "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
637                params![
638                    batch_id,
639                    anc_from.to_string_lossy().as_ref(),
640                    anc_to.to_string_lossy().as_ref(),
641                ],
642            )?;
643        }
644    }
645
646    // Try to remove empty source directories.
647    if let Some(source_dir) = file_move.from.parent() {
648        remove_empty_dirs(source_dir);
649    }
650
651    Ok(())
652}
653
654/// Undo the most recent organize batch.
655pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
656    // Find the most recent batch.
657    let batch_id: String = db
658        .conn
659        .query_row(
660            "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
661            [],
662            |row| row.get(0),
663        )
664        .map_err(|_| OrganizeError::NothingToUndo)?;
665
666    // Get all moves in this batch, in reverse order.
667    let mut stmt = db.conn.prepare(
668        "SELECT id, track_id, from_path, to_path FROM organize_log
669         WHERE batch_id = ?1 ORDER BY id DESC",
670    )?;
671
672    let entries: Vec<(i64, Option<i64>, String, String)> = stmt
673        .query_map(params![batch_id], |row| {
674            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
675        })?
676        .collect::<Result<Vec<_>, _>>()?;
677
678    let mut count = 0;
679
680    for (log_id, track_id, from_path, to_path) in &entries {
681        let to = Path::new(to_path);
682        let from = Path::new(from_path);
683
684        if !to.exists() {
685            // Already moved back or deleted — skip.
686            db.conn
687                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
688            continue;
689        }
690
691        // Create original parent dir.
692        if let Some(parent) = from.parent() {
693            std::fs::create_dir_all(parent)?;
694        }
695
696        // Move back (falls back to copy+delete across filesystems).
697        move_file(to, from)?;
698
699        // Update track path in DB if this was a track (not ancillary).
700        if let Some(tid) = track_id {
701            db.conn.execute(
702                "UPDATE tracks SET path = ?1 WHERE id = ?2",
703                params![from_path, tid],
704            )?;
705            db.conn.execute(
706                "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
707                params![from_path, to_path],
708            )?;
709        }
710
711        // Remove empty dirs at the (now old) destination.
712        if let Some(parent) = to.parent() {
713            remove_empty_dirs(parent);
714        }
715
716        db.conn
717            .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
718
719        count += 1;
720    }
721
722    Ok(count)
723}
724
725/// Walk up directories removing empty ones, stopping at first non-empty.
726fn remove_empty_dirs(dir: &Path) {
727    let mut current = dir.to_path_buf();
728    loop {
729        if std::fs::read_dir(&current)
730            .map(|mut d| d.next().is_none())
731            .unwrap_or(false)
732        {
733            if std::fs::remove_dir(&current).is_err() {
734                break;
735            }
736            match current.parent() {
737                Some(p) => current = p.to_path_buf(),
738                None => break,
739            }
740        } else {
741            break;
742        }
743    }
744}
745
746/// Move a file, falling back to copy+delete if rename fails (cross-device).
747fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
748    match std::fs::rename(from, to) {
749        Ok(()) => Ok(()),
750        Err(e) if e.raw_os_error() == Some(18) => {
751            // EXDEV (18): cross-device link — copy then delete original.
752            std::fs::copy(from, to)?;
753            std::fs::remove_file(from)?;
754            Ok(())
755        }
756        Err(e) => Err(e.into()),
757    }
758}
759
760/// Compare paths for equality, canonicalizing if possible (handles macOS case-insensitive FS).
761fn paths_equal(a: &Path, b: &Path) -> bool {
762    if a == b {
763        return true;
764    }
765    // Try canonicalizing both — handles symlinks, case differences, etc.
766    if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
767        return ca == cb;
768    }
769    false
770}
771
772fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
773    if let Some(dir) = base_dir {
774        return Ok(dir.to_path_buf());
775    }
776
777    // Use first configured library folder.
778    let config = crate::config::Config::load()
779        .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
780
781    config.library.folders.into_iter().next().ok_or_else(|| {
782        OrganizeError::Io(std::io::Error::other(
783            "no library folders configured; use --base-dir",
784        ))
785    })
786}
787
788fn chrono_batch_id() -> String {
789    let now = std::time::SystemTime::now()
790        .duration_since(std::time::UNIX_EPOCH)
791        .unwrap_or_default();
792    format!("batch-{}", now.as_nanos())
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::db::connection::Database;
799    use crate::db::queries::TrackMeta;
800    use crate::db::schema;
801
802    fn test_db() -> Database {
803        let conn = rusqlite::Connection::open_in_memory().unwrap();
804        conn.pragma_update(None, "foreign_keys", "on").unwrap();
805        schema::create_tables(&conn).unwrap();
806        Database { conn }
807    }
808
809    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
810        TrackMeta {
811            title: title.into(),
812            artist: artist.into(),
813            album_artist: Some(artist.into()),
814            album: album.into(),
815            date: Some("1997-06-16".into()),
816            disc: Some(1),
817            track_number: Some(1),
818            genre: Some("Rock".into()),
819            label: None,
820            duration_ms: Some(240_000),
821            codec: Some("FLAC".into()),
822            sample_rate: Some(44100),
823            bit_depth: Some(16),
824            channels: Some(2),
825            bitrate: Some(1000),
826            size_bytes: Some(30_000_000),
827            mtime: Some(1700000000),
828            path: None,
829            source: "local".into(),
830            remote_id: None,
831            remote_url: None,
832        }
833    }
834
835    #[test]
836    fn track_metadata_provider_fields() {
837        let track = TrackRow {
838            id: 1,
839            album_id: Some(1),
840            artist_id: Some(1),
841            artist_name: "Radiohead".into(),
842            album_artist_name: "Radiohead".into(),
843            album_title: "OK Computer".into(),
844            disc: Some(1),
845            track_number: Some(3),
846            title: "Subterranean Homesick Alien".into(),
847            duration_ms: Some(240_000),
848            path: Some("/music/ok_computer/03.flac".into()),
849            codec: Some("FLAC".into()),
850            sample_rate: Some(44100),
851            bit_depth: Some(16),
852            channels: Some(2),
853            bitrate: Some(1000),
854            genre: Some("Alternative".into()),
855            source: "local".into(),
856            remote_id: None,
857            cached_path: None,
858        };
859
860        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
861        assert_eq!(
862            meta.get_field("title").as_deref(),
863            Some("Subterranean Homesick Alien")
864        );
865        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
866        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
867        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
868        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
869        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
870        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
871        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
872        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
873        assert_eq!(meta.get_field("nonexistent"), None);
874    }
875
876    #[test]
877    fn sanitize_replaces_illegal_chars() {
878        assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
879        assert_eq!(sanitize_path_component("What?"), "What_");
880        assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
881        assert_eq!(sanitize_path_component("normal"), "normal");
882    }
883
884    #[test]
885    fn sanitize_relative_path_splits() {
886        let result = sanitize_relative_path("Artist/Album/Track");
887        assert_eq!(result, PathBuf::from("Artist/Album/Track"));
888
889        let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
890        assert_eq!(
891            result,
892            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
893        );
894    }
895
896    #[test]
897    fn sanitize_relative_path_rejects_traversal() {
898        // ".." components should be stripped to prevent path traversal.
899        let result = sanitize_relative_path("../../../../etc/passwd");
900        assert_eq!(result, PathBuf::from("etc/passwd"));
901
902        let result = sanitize_relative_path("Artist/../../../outside");
903        assert_eq!(result, PathBuf::from("Artist/outside"));
904
905        // "." components should also be stripped.
906        let result = sanitize_relative_path("./Artist/./Album");
907        assert_eq!(result, PathBuf::from("Artist/Album"));
908
909        // Normal paths remain unchanged.
910        let result = sanitize_relative_path("Artist/Album/Track");
911        assert_eq!(result, PathBuf::from("Artist/Album/Track"));
912    }
913
914    #[test]
915    fn acdc_artist_name_sanitized() {
916        // "AC/DC" should become "AC_DC" through field sanitization.
917        let track = TrackRow {
918            id: 1,
919            album_id: Some(1),
920            artist_id: Some(1),
921            artist_name: "AC/DC".into(),
922            album_artist_name: "AC/DC".into(),
923            album_title: "Highway to Hell".into(),
924            disc: Some(1),
925            track_number: Some(1),
926            title: "Highway to Hell".into(),
927            duration_ms: None,
928            path: Some("/music/test.flac".into()),
929            codec: None,
930            sample_rate: None,
931            bit_depth: None,
932            channels: None,
933            bitrate: None,
934            genre: None,
935            source: "local".into(),
936            remote_id: None,
937            cached_path: None,
938        };
939        let meta = TrackMetadata::from_track_row(&track, Some("1979"));
940        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
941        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
942        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
943    }
944
945    #[test]
946    fn format_string_evaluation() {
947        let track = TrackRow {
948            id: 1,
949            album_id: Some(1),
950            artist_id: Some(1),
951            artist_name: "Radiohead".into(),
952            album_artist_name: "Radiohead".into(),
953            album_title: "OK Computer".into(),
954            disc: Some(1),
955            track_number: Some(1),
956            title: "Airbag".into(),
957            duration_ms: Some(240_000),
958            path: Some("/music/test.flac".into()),
959            codec: Some("FLAC".into()),
960            sample_rate: Some(44100),
961            bit_depth: Some(16),
962            channels: Some(2),
963            bitrate: Some(1000),
964            genre: None,
965            source: "local".into(),
966            remote_id: None,
967            cached_path: None,
968        };
969
970        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
971        let pattern =
972            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
973        let result = format::format(pattern, &meta).unwrap();
974        assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
975    }
976
977    #[test]
978    fn preview_does_not_move_files() {
979        let db = test_db();
980        let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
981        std::fs::create_dir_all(&tmp).unwrap();
982
983        // Create a test file.
984        let test_file = tmp.join("test.flac");
985        std::fs::write(&test_file, b"fake audio data").unwrap();
986
987        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
988        meta.path = Some(test_file.to_string_lossy().into());
989        queries::upsert_track(&db.conn, &meta).unwrap();
990
991        let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
992        // The file should still be at the original path.
993        assert!(test_file.exists());
994        assert!(!result.moves.is_empty());
995
996        // Cleanup.
997        std::fs::remove_dir_all(&tmp).ok();
998    }
999
1000    #[test]
1001    fn execute_moves_files_and_undo_reverts() {
1002        let db = test_db();
1003        let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
1004        let src_dir = tmp.join("src");
1005        std::fs::create_dir_all(&src_dir).unwrap();
1006
1007        // Create test files.
1008        let test_file = src_dir.join("test.flac");
1009        std::fs::write(&test_file, b"fake audio data").unwrap();
1010
1011        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
1012        meta.path = Some(test_file.to_string_lossy().into());
1013        queries::upsert_track(&db.conn, &meta).unwrap();
1014
1015        // Execute.
1016        let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
1017        assert_eq!(result.moves.len(), 1);
1018        assert!(!test_file.exists()); // original gone
1019        let dest = &result.moves[0].to;
1020        assert!(dest.exists()); // new location exists
1021
1022        // Undo.
1023        let undone = undo(&db).unwrap();
1024        assert_eq!(undone, 1);
1025        assert!(test_file.exists()); // back to original
1026        assert!(!dest.exists()); // new location gone
1027
1028        // Cleanup.
1029        std::fs::remove_dir_all(&tmp).ok();
1030    }
1031
1032    #[test]
1033    fn ancillary_file_detection() {
1034        let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1035        std::fs::create_dir_all(&tmp).unwrap();
1036
1037        std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1038        std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1039        std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1040        std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1041        std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); // not ancillary
1042
1043        let found = find_ancillary_files(&tmp);
1044        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1045        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1046        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1047        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1048        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1049
1050        std::fs::remove_dir_all(&tmp).ok();
1051    }
1052
1053    #[test]
1054    fn extension_not_clobbered_by_dots_in_title() {
1055        // Regression: with_extension() replaces after the LAST dot,
1056        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
1057        let db = test_db();
1058        let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1059        let src_dir = tmp.join("src");
1060        std::fs::create_dir_all(&src_dir).unwrap();
1061
1062        // Track with dots in title (A.L.O.E II) + tracknumber dot.
1063        let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1064        std::fs::write(&test_file, b"fake").unwrap();
1065
1066        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1067        meta.track_number = Some(10);
1068        meta.disc = Some(1);
1069        meta.date = Some("2025-11-21".into());
1070        meta.codec = Some("FLAC".into());
1071        meta.album_artist = Some("Bicep".into());
1072        meta.path = Some(test_file.to_string_lossy().into());
1073        queries::upsert_track(&db.conn, &meta).unwrap();
1074
1075        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1076        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1077        assert_eq!(result.moves.len(), 1);
1078
1079        let dest_name = result.moves[0]
1080            .to
1081            .file_name()
1082            .unwrap()
1083            .to_string_lossy()
1084            .to_string();
1085        // Must preserve full title including dots — NOT truncated by set_extension.
1086        assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1087
1088        std::fs::remove_dir_all(&tmp).ok();
1089    }
1090
1091    #[test]
1092    fn extension_preserved_for_tracknumber_dot() {
1093        // "0111. Bicep - TANGZ II" must not become "0111.flac"
1094        let db = test_db();
1095        let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1096        let src_dir = tmp.join("src");
1097        std::fs::create_dir_all(&src_dir).unwrap();
1098
1099        let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1100        std::fs::write(&test_file, b"fake").unwrap();
1101
1102        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1103        meta.track_number = Some(11);
1104        meta.disc = Some(1);
1105        meta.date = Some("2025-11-21".into());
1106        meta.codec = Some("FLAC".into());
1107        meta.album_artist = Some("Bicep".into());
1108        meta.path = Some(test_file.to_string_lossy().into());
1109        queries::upsert_track(&db.conn, &meta).unwrap();
1110
1111        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1112        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1113        assert_eq!(result.moves.len(), 1);
1114
1115        let dest_name = result.moves[0]
1116            .to
1117            .file_name()
1118            .unwrap()
1119            .to_string_lossy()
1120            .to_string();
1121        assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1122
1123        std::fs::remove_dir_all(&tmp).ok();
1124    }
1125}