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.
139fn sanitize_relative_path(rel: &str) -> PathBuf {
140    let parts: Vec<&str> = if rel.contains('/') {
141        rel.split('/')
142    } else {
143        rel.split(std::path::MAIN_SEPARATOR)
144    }
145    .collect();
146
147    let mut result = PathBuf::new();
148    for part in parts {
149        let sanitized = sanitize_path_component(part);
150        if !sanitized.is_empty() {
151            result.push(sanitized);
152        }
153    }
154    result
155}
156
157/// Get the album date for a track via its album_id.
158fn album_date_for_track(
159    db: &Database,
160    track: &TrackRow,
161) -> Result<Option<String>, rusqlite::Error> {
162    let Some(album_id) = track.album_id else {
163        return Ok(None);
164    };
165    db.conn
166        .query_row(
167            "SELECT date FROM albums WHERE id = ?1",
168            params![album_id],
169            |row| row.get::<_, Option<String>>(0),
170        )
171        .or(Ok(None))
172}
173
174/// Find ancillary files in the same directory as a track.
175fn find_ancillary_files(track_dir: &Path) -> Vec<PathBuf> {
176    let mut files = Vec::new();
177    let Ok(entries) = std::fs::read_dir(track_dir) else {
178        return files;
179    };
180
181    for entry in entries.flatten() {
182        let path = entry.path();
183        if !path.is_file() {
184            continue;
185        }
186        let name = path
187            .file_name()
188            .and_then(|n| n.to_str())
189            .unwrap_or_default()
190            .to_lowercase();
191
192        // Check exact name matches.
193        if ANCILLARY_PATTERNS.iter().any(|p| name == *p) {
194            files.push(path);
195            continue;
196        }
197        // Check extension matches.
198        if let Some(ext) = path.extension().and_then(|e| e.to_str())
199            && ANCILLARY_EXTENSIONS
200                .iter()
201                .any(|e| ext.eq_ignore_ascii_case(e))
202        {
203            files.push(path);
204        }
205    }
206    files
207}
208
209/// Plan a single file move: format pattern, sanitize path, build dest, find ancillary files.
210///
211/// Returns `Ok(None)` when source == dest (skipped), `Ok(Some(FileMove))` for a planned move,
212/// or `Err(String)` for errors that should be collected by the caller.
213fn plan_single_move(
214    source: &Path,
215    track_id: i64,
216    metadata: &TrackMetadata,
217    pattern: &str,
218    base_dir: &Path,
219    planned_ancillary: &mut std::collections::HashSet<PathBuf>,
220) -> Result<Option<FileMove>, String> {
221    let relative = format::format(pattern, metadata).map_err(|e| format!("format error: {e}"))?;
222
223    if relative.is_empty() {
224        return Err("format string produced empty path".into());
225    }
226
227    let sanitized = sanitize_relative_path(&relative);
228
229    // Preserve the original file extension.
230    // Don't use with_extension() — it replaces after the LAST dot, which
231    // destroys titles containing dots (e.g. "0111. Bicep - TANGZ II" → "0111.flac").
232    let ext = source
233        .extension()
234        .and_then(|e| e.to_str())
235        .unwrap_or("flac");
236    let mut dest = base_dir.join(sanitized);
237    let stem = dest.as_os_str().to_os_string();
238    dest = PathBuf::from(format!("{}.{}", stem.to_string_lossy(), ext));
239
240    if paths_equal(source, &dest) {
241        return Ok(None);
242    }
243
244    // Plan ancillary moves — move files in source dir to dest dir.
245    let source_dir = source.parent().unwrap_or(Path::new("."));
246    let dest_dir = dest.parent().unwrap_or(Path::new("."));
247    let mut ancillary = Vec::new();
248
249    if source_dir != dest_dir {
250        for anc_path in find_ancillary_files(source_dir) {
251            if planned_ancillary.contains(&anc_path) {
252                continue;
253            }
254            let Some(anc_name) = anc_path.file_name() else {
255                continue;
256            };
257            let anc_dest = dest_dir.join(anc_name);
258            planned_ancillary.insert(anc_path.clone());
259            ancillary.push((anc_path, anc_dest));
260        }
261    }
262
263    Ok(Some(FileMove {
264        track_id,
265        from: source.to_path_buf(),
266        to: dest,
267        ancillary,
268    }))
269}
270
271/// Build the list of moves for all local tracks (or a filtered subset).
272fn plan_moves(
273    db: &Database,
274    pattern: &str,
275    base_dir: &Path,
276    track_ids: Option<&[i64]>,
277) -> Result<OrganizeResult, OrganizeError> {
278    let tracks = match track_ids {
279        Some(ids) => {
280            let mut tracks = Vec::with_capacity(ids.len());
281            for &id in ids {
282                if let Some(row) = queries::get_track_row(&db.conn, id)? {
283                    tracks.push(row);
284                }
285            }
286            tracks
287        }
288        None => queries::all_tracks(&db.conn)?,
289    };
290    let mut moves = Vec::new();
291    let mut errors = Vec::new();
292    let mut skipped = 0;
293
294    // Track which ancillary files we've already planned to move (dedup across tracks in same dir).
295    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
296        std::collections::HashSet::new();
297
298    for track in &tracks {
299        let Some(ref path_str) = track.path else {
300            continue; // remote-only, skip
301        };
302
303        let source = PathBuf::from(path_str);
304        if !source.exists() {
305            continue; // file gone, skip
306        }
307
308        let album_date = match album_date_for_track(db, track) {
309            Ok(d) => d,
310            Err(e) => {
311                errors.push((source, format!("failed to get album date: {e}")));
312                continue;
313            }
314        };
315
316        let metadata = TrackMetadata::from_track_row(track, album_date.as_deref());
317
318        match plan_single_move(
319            &source,
320            track.id,
321            &metadata,
322            pattern,
323            base_dir,
324            &mut planned_ancillary,
325        ) {
326            Ok(Some(file_move)) => moves.push(file_move),
327            Ok(None) => skipped += 1,
328            Err(msg) => errors.push((source, msg)),
329        }
330    }
331
332    Ok(OrganizeResult {
333        moves,
334        errors,
335        skipped,
336    })
337}
338
339/// Build the list of moves from file paths directly (no DB required).
340/// Reads metadata from tags. Uses track_id=0 for all entries (no DB identity).
341fn plan_moves_from_paths(
342    paths: &[PathBuf],
343    pattern: &str,
344    base_dir: &Path,
345) -> Result<OrganizeResult, OrganizeError> {
346    let mut moves = Vec::new();
347    let mut errors = Vec::new();
348    let mut skipped = 0;
349
350    let mut planned_ancillary: std::collections::HashSet<PathBuf> =
351        std::collections::HashSet::new();
352
353    for source in paths {
354        if !source.exists() {
355            errors.push((source.clone(), "file not found".into()));
356            continue;
357        }
358
359        let meta = match crate::index::metadata::read_metadata(source) {
360            Ok(m) => m,
361            Err(e) => {
362                errors.push((source.clone(), format!("metadata error: {e}")));
363                continue;
364            }
365        };
366
367        let metadata = TrackMetadata::from_file_meta(&meta);
368
369        match plan_single_move(
370            source,
371            0,
372            &metadata,
373            pattern,
374            base_dir,
375            &mut planned_ancillary,
376        ) {
377            Ok(Some(file_move)) => moves.push(file_move),
378            Ok(None) => skipped += 1,
379            Err(msg) => errors.push((source.clone(), msg)),
380        }
381    }
382
383    Ok(OrganizeResult {
384        moves,
385        errors,
386        skipped,
387    })
388}
389
390/// Preview organize for file paths (no DB required — reads tags directly).
391pub fn preview_for_paths(
392    paths: &[PathBuf],
393    pattern: &str,
394    base_dir: Option<&Path>,
395) -> Result<OrganizeResult, OrganizeError> {
396    let base = resolve_base_dir(base_dir)?;
397    plan_moves_from_paths(paths, pattern, &base)
398}
399
400/// Execute organize for file paths (no DB required — reads tags, moves files).
401/// Does NOT log to organize_log or update DB paths (since tracks may not be in DB).
402pub fn execute_for_paths(
403    paths: &[PathBuf],
404    pattern: &str,
405    base_dir: Option<&Path>,
406) -> Result<OrganizeResult, OrganizeError> {
407    let base = resolve_base_dir(base_dir)?;
408    let mut result = plan_moves_from_paths(paths, pattern, &base)?;
409
410    if result.moves.is_empty() {
411        return Ok(result);
412    }
413
414    let mut completed_moves = Vec::new();
415    let mut new_errors = Vec::new();
416
417    for file_move in result.moves.drain(..) {
418        match execute_single_move_no_db(&file_move) {
419            Ok(()) => match verify_move(&file_move) {
420                Ok(()) => completed_moves.push(file_move),
421                Err(msg) => new_errors.push((file_move.from, msg)),
422            },
423            Err(e) => {
424                new_errors.push((file_move.from, e.to_string()));
425            }
426        }
427    }
428
429    result.moves = completed_moves;
430    result.errors.extend(new_errors);
431    Ok(result)
432}
433
434/// Verify a move actually happened — dest exists and source is gone.
435fn verify_move(file_move: &FileMove) -> Result<(), String> {
436    if !file_move.to.exists() {
437        return Err(format!(
438            "destination not found after move: {}",
439            file_move.to.display()
440        ));
441    }
442    if file_move.from.exists() && !paths_equal(&file_move.from, &file_move.to) {
443        return Err(format!(
444            "source still exists after move: {}",
445            file_move.from.display()
446        ));
447    }
448    Ok(())
449}
450
451/// Execute a single file move without DB logging (for non-library tracks).
452fn execute_single_move_no_db(file_move: &FileMove) -> Result<(), OrganizeError> {
453    if let Some(parent) = file_move.to.parent() {
454        std::fs::create_dir_all(parent)?;
455    }
456
457    move_file(&file_move.from, &file_move.to)?;
458
459    // Move ancillary files (best-effort).
460    for (anc_from, anc_to) in &file_move.ancillary {
461        if let Some(parent) = anc_to.parent() {
462            std::fs::create_dir_all(parent).ok();
463        }
464        if let Err(e) = move_file(anc_from, anc_to) {
465            log::warn!(
466                "failed to move ancillary file {}: {}",
467                anc_from.display(),
468                e
469            );
470        }
471    }
472
473    if let Some(source_dir) = file_move.from.parent() {
474        remove_empty_dirs(source_dir);
475    }
476
477    Ok(())
478}
479
480/// Preview what would happen without moving files.
481pub fn preview(
482    db: &Database,
483    pattern: &str,
484    base_dir: Option<&Path>,
485) -> Result<OrganizeResult, OrganizeError> {
486    let base = resolve_base_dir(base_dir)?;
487    plan_moves(db, pattern, &base, None)
488}
489
490/// Execute the moves: rename files, update DB, log for undo.
491pub fn execute(
492    db: &Database,
493    pattern: &str,
494    base_dir: Option<&Path>,
495) -> Result<OrganizeResult, OrganizeError> {
496    let base = resolve_base_dir(base_dir)?;
497    let mut result = plan_moves(db, pattern, &base, None)?;
498
499    if result.moves.is_empty() {
500        return Ok(result);
501    }
502
503    // Generate a batch ID from timestamp.
504    let batch_id = chrono_batch_id();
505
506    let mut completed_moves = Vec::new();
507    let mut new_errors = Vec::new();
508
509    for file_move in result.moves.drain(..) {
510        match execute_single_move(db, &file_move, &batch_id) {
511            Ok(()) => match verify_move(&file_move) {
512                Ok(()) => completed_moves.push(file_move),
513                Err(msg) => new_errors.push((file_move.from, msg)),
514            },
515            Err(e) => {
516                new_errors.push((file_move.from, e.to_string()));
517            }
518        }
519    }
520
521    result.moves = completed_moves;
522    result.errors.extend(new_errors);
523    Ok(result)
524}
525
526/// Preview organize for a specific set of tracks.
527pub fn preview_for_tracks(
528    db: &Database,
529    track_ids: &[i64],
530    pattern: &str,
531    base_dir: Option<&Path>,
532) -> Result<OrganizeResult, OrganizeError> {
533    let base = resolve_base_dir(base_dir)?;
534    plan_moves(db, pattern, &base, Some(track_ids))
535}
536
537/// Execute organize for a specific set of tracks.
538pub fn execute_for_tracks(
539    db: &Database,
540    track_ids: &[i64],
541    pattern: &str,
542    base_dir: Option<&Path>,
543) -> Result<OrganizeResult, OrganizeError> {
544    let base = resolve_base_dir(base_dir)?;
545    let mut result = plan_moves(db, pattern, &base, Some(track_ids))?;
546
547    if result.moves.is_empty() {
548        return Ok(result);
549    }
550
551    let batch_id = chrono_batch_id();
552    let mut completed_moves = Vec::new();
553    let mut new_errors = Vec::new();
554
555    for file_move in result.moves.drain(..) {
556        match execute_single_move(db, &file_move, &batch_id) {
557            Ok(()) => match verify_move(&file_move) {
558                Ok(()) => completed_moves.push(file_move),
559                Err(msg) => new_errors.push((file_move.from, msg)),
560            },
561            Err(e) => {
562                new_errors.push((file_move.from, e.to_string()));
563            }
564        }
565    }
566
567    result.moves = completed_moves;
568    result.errors.extend(new_errors);
569    Ok(result)
570}
571
572/// Execute a single file move: create dirs, move file + ancillary, update DB, write log.
573fn execute_single_move(
574    db: &Database,
575    file_move: &FileMove,
576    batch_id: &str,
577) -> Result<(), OrganizeError> {
578    // Create destination directory.
579    if let Some(parent) = file_move.to.parent() {
580        std::fs::create_dir_all(parent)?;
581    }
582
583    // Move the audio file (falls back to copy+delete across filesystems).
584    move_file(&file_move.from, &file_move.to)?;
585
586    // Log the move.
587    db.conn.execute(
588        "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, ?2, ?3, ?4)",
589        params![
590            batch_id,
591            file_move.track_id,
592            file_move.from.to_string_lossy().as_ref(),
593            file_move.to.to_string_lossy().as_ref(),
594        ],
595    )?;
596
597    // Update the track's path in the database.
598    db.conn.execute(
599        "UPDATE tracks SET path = ?1 WHERE id = ?2",
600        params![file_move.to.to_string_lossy().as_ref(), file_move.track_id],
601    )?;
602
603    // Update scan_cache if it exists for the old path.
604    db.conn.execute(
605        "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
606        params![
607            file_move.to.to_string_lossy().as_ref(),
608            file_move.from.to_string_lossy().as_ref(),
609        ],
610    )?;
611
612    // Move ancillary files.
613    for (anc_from, anc_to) in &file_move.ancillary {
614        if let Some(parent) = anc_to.parent() {
615            std::fs::create_dir_all(parent)?;
616        }
617        // Best-effort — don't fail the whole move if ancillary fails.
618        if move_file(anc_from, anc_to).is_ok() {
619            db.conn.execute(
620                "INSERT INTO organize_log (batch_id, track_id, from_path, to_path) VALUES (?1, NULL, ?2, ?3)",
621                params![
622                    batch_id,
623                    anc_from.to_string_lossy().as_ref(),
624                    anc_to.to_string_lossy().as_ref(),
625                ],
626            )?;
627        }
628    }
629
630    // Try to remove empty source directories.
631    if let Some(source_dir) = file_move.from.parent() {
632        remove_empty_dirs(source_dir);
633    }
634
635    Ok(())
636}
637
638/// Undo the most recent organize batch.
639pub fn undo(db: &Database) -> Result<usize, OrganizeError> {
640    // Find the most recent batch.
641    let batch_id: String = db
642        .conn
643        .query_row(
644            "SELECT batch_id FROM organize_log ORDER BY created_at DESC LIMIT 1",
645            [],
646            |row| row.get(0),
647        )
648        .map_err(|_| OrganizeError::NothingToUndo)?;
649
650    // Get all moves in this batch, in reverse order.
651    let mut stmt = db.conn.prepare(
652        "SELECT id, track_id, from_path, to_path FROM organize_log
653         WHERE batch_id = ?1 ORDER BY id DESC",
654    )?;
655
656    let entries: Vec<(i64, Option<i64>, String, String)> = stmt
657        .query_map(params![batch_id], |row| {
658            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
659        })?
660        .collect::<Result<Vec<_>, _>>()?;
661
662    let mut count = 0;
663
664    for (log_id, track_id, from_path, to_path) in &entries {
665        let to = Path::new(to_path);
666        let from = Path::new(from_path);
667
668        if !to.exists() {
669            // Already moved back or deleted — skip.
670            db.conn
671                .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
672            continue;
673        }
674
675        // Create original parent dir.
676        if let Some(parent) = from.parent() {
677            std::fs::create_dir_all(parent)?;
678        }
679
680        // Move back (falls back to copy+delete across filesystems).
681        move_file(to, from)?;
682
683        // Update track path in DB if this was a track (not ancillary).
684        if let Some(tid) = track_id {
685            db.conn.execute(
686                "UPDATE tracks SET path = ?1 WHERE id = ?2",
687                params![from_path, tid],
688            )?;
689            db.conn.execute(
690                "UPDATE scan_cache SET path = ?1 WHERE path = ?2",
691                params![from_path, to_path],
692            )?;
693        }
694
695        // Remove empty dirs at the (now old) destination.
696        if let Some(parent) = to.parent() {
697            remove_empty_dirs(parent);
698        }
699
700        db.conn
701            .execute("DELETE FROM organize_log WHERE id = ?1", params![log_id])?;
702
703        count += 1;
704    }
705
706    Ok(count)
707}
708
709/// Walk up directories removing empty ones, stopping at first non-empty.
710fn remove_empty_dirs(dir: &Path) {
711    let mut current = dir.to_path_buf();
712    loop {
713        if std::fs::read_dir(&current)
714            .map(|mut d| d.next().is_none())
715            .unwrap_or(false)
716        {
717            if std::fs::remove_dir(&current).is_err() {
718                break;
719            }
720            match current.parent() {
721                Some(p) => current = p.to_path_buf(),
722                None => break,
723            }
724        } else {
725            break;
726        }
727    }
728}
729
730/// Move a file, falling back to copy+delete if rename fails (cross-device).
731fn move_file(from: &Path, to: &Path) -> Result<(), OrganizeError> {
732    match std::fs::rename(from, to) {
733        Ok(()) => Ok(()),
734        Err(e) if e.raw_os_error() == Some(18) => {
735            // EXDEV (18): cross-device link — copy then delete original.
736            std::fs::copy(from, to)?;
737            std::fs::remove_file(from)?;
738            Ok(())
739        }
740        Err(e) => Err(e.into()),
741    }
742}
743
744/// Compare paths for equality, canonicalizing if possible (handles macOS case-insensitive FS).
745fn paths_equal(a: &Path, b: &Path) -> bool {
746    if a == b {
747        return true;
748    }
749    // Try canonicalizing both — handles symlinks, case differences, etc.
750    if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
751        return ca == cb;
752    }
753    false
754}
755
756fn resolve_base_dir(base_dir: Option<&Path>) -> Result<PathBuf, OrganizeError> {
757    if let Some(dir) = base_dir {
758        return Ok(dir.to_path_buf());
759    }
760
761    // Use first configured library folder.
762    let config = crate::config::Config::load()
763        .map_err(|e| OrganizeError::Io(std::io::Error::other(e.to_string())))?;
764
765    config.library.folders.into_iter().next().ok_or_else(|| {
766        OrganizeError::Io(std::io::Error::other(
767            "no library folders configured; use --base-dir",
768        ))
769    })
770}
771
772fn chrono_batch_id() -> String {
773    let now = std::time::SystemTime::now()
774        .duration_since(std::time::UNIX_EPOCH)
775        .unwrap_or_default();
776    format!("batch-{}", now.as_nanos())
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::db::connection::Database;
783    use crate::db::queries::TrackMeta;
784    use crate::db::schema;
785
786    fn test_db() -> Database {
787        let conn = rusqlite::Connection::open_in_memory().unwrap();
788        conn.pragma_update(None, "foreign_keys", "on").unwrap();
789        schema::create_tables(&conn).unwrap();
790        Database { conn }
791    }
792
793    fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
794        TrackMeta {
795            title: title.into(),
796            artist: artist.into(),
797            album_artist: Some(artist.into()),
798            album: album.into(),
799            date: Some("1997-06-16".into()),
800            disc: Some(1),
801            track_number: Some(1),
802            genre: Some("Rock".into()),
803            label: None,
804            duration_ms: Some(240_000),
805            codec: Some("FLAC".into()),
806            sample_rate: Some(44100),
807            bit_depth: Some(16),
808            channels: Some(2),
809            bitrate: Some(1000),
810            size_bytes: Some(30_000_000),
811            mtime: Some(1700000000),
812            path: None,
813            source: "local".into(),
814            remote_id: None,
815            remote_url: None,
816        }
817    }
818
819    #[test]
820    fn track_metadata_provider_fields() {
821        let track = TrackRow {
822            id: 1,
823            album_id: Some(1),
824            artist_id: Some(1),
825            artist_name: "Radiohead".into(),
826            album_artist_name: "Radiohead".into(),
827            album_title: "OK Computer".into(),
828            disc: Some(1),
829            track_number: Some(3),
830            title: "Subterranean Homesick Alien".into(),
831            duration_ms: Some(240_000),
832            path: Some("/music/ok_computer/03.flac".into()),
833            codec: Some("FLAC".into()),
834            sample_rate: Some(44100),
835            bit_depth: Some(16),
836            channels: Some(2),
837            bitrate: Some(1000),
838            genre: Some("Alternative".into()),
839            source: "local".into(),
840            remote_id: None,
841            cached_path: None,
842        };
843
844        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
845        assert_eq!(
846            meta.get_field("title").as_deref(),
847            Some("Subterranean Homesick Alien")
848        );
849        assert_eq!(meta.get_field("artist").as_deref(), Some("Radiohead"));
850        assert_eq!(meta.get_field("album artist").as_deref(), Some("Radiohead"));
851        assert_eq!(meta.get_field("album").as_deref(), Some("OK Computer"));
852        assert_eq!(meta.get_field("tracknumber").as_deref(), Some("03"));
853        assert_eq!(meta.get_field("discnumber").as_deref(), Some("1"));
854        assert_eq!(meta.get_field("date").as_deref(), Some("1997-06-16"));
855        assert_eq!(meta.get_field("codec").as_deref(), Some("FLAC"));
856        assert_eq!(meta.get_field("genre").as_deref(), Some("Alternative"));
857        assert_eq!(meta.get_field("nonexistent"), None);
858    }
859
860    #[test]
861    fn sanitize_replaces_illegal_chars() {
862        assert_eq!(sanitize_path_component("AC/DC"), "AC_DC");
863        assert_eq!(sanitize_path_component("What?"), "What_");
864        assert_eq!(sanitize_path_component("a:b*c"), "a_b_c");
865        assert_eq!(sanitize_path_component("normal"), "normal");
866    }
867
868    #[test]
869    fn sanitize_relative_path_splits() {
870        let result = sanitize_relative_path("Artist/Album/Track");
871        assert_eq!(result, PathBuf::from("Artist/Album/Track"));
872
873        let result = sanitize_relative_path("Radiohead/(1997) OK Computer/01. Airbag");
874        assert_eq!(
875            result,
876            PathBuf::from("Radiohead/(1997) OK Computer/01. Airbag")
877        );
878    }
879
880    #[test]
881    fn acdc_artist_name_sanitized() {
882        // "AC/DC" should become "AC_DC" through field sanitization.
883        let track = TrackRow {
884            id: 1,
885            album_id: Some(1),
886            artist_id: Some(1),
887            artist_name: "AC/DC".into(),
888            album_artist_name: "AC/DC".into(),
889            album_title: "Highway to Hell".into(),
890            disc: Some(1),
891            track_number: Some(1),
892            title: "Highway to Hell".into(),
893            duration_ms: None,
894            path: Some("/music/test.flac".into()),
895            codec: None,
896            sample_rate: None,
897            bit_depth: None,
898            channels: None,
899            bitrate: None,
900            genre: None,
901            source: "local".into(),
902            remote_id: None,
903            cached_path: None,
904        };
905        let meta = TrackMetadata::from_track_row(&track, Some("1979"));
906        assert_eq!(meta.get_field("album artist").as_deref(), Some("AC_DC"));
907        let result = format::format("%album artist%/%album%/%title%", &meta).unwrap();
908        assert_eq!(result, "AC_DC/Highway to Hell/Highway to Hell");
909    }
910
911    #[test]
912    fn format_string_evaluation() {
913        let track = TrackRow {
914            id: 1,
915            album_id: Some(1),
916            artist_id: Some(1),
917            artist_name: "Radiohead".into(),
918            album_artist_name: "Radiohead".into(),
919            album_title: "OK Computer".into(),
920            disc: Some(1),
921            track_number: Some(1),
922            title: "Airbag".into(),
923            duration_ms: Some(240_000),
924            path: Some("/music/test.flac".into()),
925            codec: Some("FLAC".into()),
926            sample_rate: Some(44100),
927            bit_depth: Some(16),
928            channels: Some(2),
929            bitrate: Some(1000),
930            genre: None,
931            source: "local".into(),
932            remote_id: None,
933            cached_path: None,
934        };
935
936        let meta = TrackMetadata::from_track_row(&track, Some("1997-06-16"));
937        let pattern =
938            "%album artist%/['('$left(%date%,4)')' ]%album%/$num(%tracknumber%,2). %title%";
939        let result = format::format(pattern, &meta).unwrap();
940        assert_eq!(result, "Radiohead/(1997) OK Computer/01. Airbag");
941    }
942
943    #[test]
944    fn preview_does_not_move_files() {
945        let db = test_db();
946        let tmp = std::env::temp_dir().join(format!("koan-organize-test-{}", std::process::id()));
947        std::fs::create_dir_all(&tmp).unwrap();
948
949        // Create a test file.
950        let test_file = tmp.join("test.flac");
951        std::fs::write(&test_file, b"fake audio data").unwrap();
952
953        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
954        meta.path = Some(test_file.to_string_lossy().into());
955        queries::upsert_track(&db.conn, &meta).unwrap();
956
957        let result = preview(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
958        // The file should still be at the original path.
959        assert!(test_file.exists());
960        assert!(!result.moves.is_empty());
961
962        // Cleanup.
963        std::fs::remove_dir_all(&tmp).ok();
964    }
965
966    #[test]
967    fn execute_moves_files_and_undo_reverts() {
968        let db = test_db();
969        let tmp = std::env::temp_dir().join(format!("koan-organize-exec-{}", std::process::id()));
970        let src_dir = tmp.join("src");
971        std::fs::create_dir_all(&src_dir).unwrap();
972
973        // Create test files.
974        let test_file = src_dir.join("test.flac");
975        std::fs::write(&test_file, b"fake audio data").unwrap();
976
977        let mut meta = sample_meta("Airbag", "Radiohead", "OK Computer");
978        meta.path = Some(test_file.to_string_lossy().into());
979        queries::upsert_track(&db.conn, &meta).unwrap();
980
981        // Execute.
982        let result = execute(&db, "%album artist%/%album%/%title%", Some(&tmp)).unwrap();
983        assert_eq!(result.moves.len(), 1);
984        assert!(!test_file.exists()); // original gone
985        let dest = &result.moves[0].to;
986        assert!(dest.exists()); // new location exists
987
988        // Undo.
989        let undone = undo(&db).unwrap();
990        assert_eq!(undone, 1);
991        assert!(test_file.exists()); // back to original
992        assert!(!dest.exists()); // new location gone
993
994        // Cleanup.
995        std::fs::remove_dir_all(&tmp).ok();
996    }
997
998    #[test]
999    fn ancillary_file_detection() {
1000        let tmp = std::env::temp_dir().join(format!("koan-organize-anc-{}", std::process::id()));
1001        std::fs::create_dir_all(&tmp).unwrap();
1002
1003        std::fs::write(tmp.join("cover.jpg"), b"img").unwrap();
1004        std::fs::write(tmp.join("cover.png"), b"img").unwrap();
1005        std::fs::write(tmp.join("album.cue"), b"cue").unwrap();
1006        std::fs::write(tmp.join("rip.log"), b"log").unwrap();
1007        std::fs::write(tmp.join("track.flac"), b"audio").unwrap(); // not ancillary
1008
1009        let found = find_ancillary_files(&tmp);
1010        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.jpg"));
1011        assert!(found.iter().any(|p| p.file_name().unwrap() == "cover.png"));
1012        assert!(found.iter().any(|p| p.file_name().unwrap() == "album.cue"));
1013        assert!(found.iter().any(|p| p.file_name().unwrap() == "rip.log"));
1014        assert!(!found.iter().any(|p| p.file_name().unwrap() == "track.flac"));
1015
1016        std::fs::remove_dir_all(&tmp).ok();
1017    }
1018
1019    #[test]
1020    fn extension_not_clobbered_by_dots_in_title() {
1021        // Regression: with_extension() replaces after the LAST dot,
1022        // destroying titles with dots ("0111. Bicep - TANGZ II" → "0111.flac").
1023        let db = test_db();
1024        let tmp = std::env::temp_dir().join(format!("koan-organize-dots-{}", std::process::id()));
1025        let src_dir = tmp.join("src");
1026        std::fs::create_dir_all(&src_dir).unwrap();
1027
1028        // Track with dots in title (A.L.O.E II) + tracknumber dot.
1029        let test_file = src_dir.join("CHROMA 011 A.L.O.E II.flac");
1030        std::fs::write(&test_file, b"fake").unwrap();
1031
1032        let mut meta = sample_meta("CHROMA 011 A.L.O.E II", "Bicep", "CHROMA 000");
1033        meta.track_number = Some(10);
1034        meta.disc = Some(1);
1035        meta.date = Some("2025-11-21".into());
1036        meta.codec = Some("FLAC".into());
1037        meta.album_artist = Some("Bicep".into());
1038        meta.path = Some(test_file.to_string_lossy().into());
1039        queries::upsert_track(&db.conn, &meta).unwrap();
1040
1041        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1042        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1043        assert_eq!(result.moves.len(), 1);
1044
1045        let dest_name = result.moves[0]
1046            .to
1047            .file_name()
1048            .unwrap()
1049            .to_string_lossy()
1050            .to_string();
1051        // Must preserve full title including dots — NOT truncated by set_extension.
1052        assert_eq!(dest_name, "0110. Bicep - CHROMA 011 A.L.O.E II.flac");
1053
1054        std::fs::remove_dir_all(&tmp).ok();
1055    }
1056
1057    #[test]
1058    fn extension_preserved_for_tracknumber_dot() {
1059        // "0111. Bicep - TANGZ II" must not become "0111.flac"
1060        let db = test_db();
1061        let tmp = std::env::temp_dir().join(format!("koan-organize-trkdot-{}", std::process::id()));
1062        let src_dir = tmp.join("src");
1063        std::fs::create_dir_all(&src_dir).unwrap();
1064
1065        let test_file = src_dir.join("CHROMA 012 TANGZ II.flac");
1066        std::fs::write(&test_file, b"fake").unwrap();
1067
1068        let mut meta = sample_meta("CHROMA 012 TANGZ II", "Bicep", "CHROMA 000");
1069        meta.track_number = Some(11);
1070        meta.disc = Some(1);
1071        meta.date = Some("2025-11-21".into());
1072        meta.codec = Some("FLAC".into());
1073        meta.album_artist = Some("Bicep".into());
1074        meta.path = Some(test_file.to_string_lossy().into());
1075        queries::upsert_track(&db.conn, &meta).unwrap();
1076
1077        let pattern = "%album artist%/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
1078        let result = preview(&db, pattern, Some(&tmp)).unwrap();
1079        assert_eq!(result.moves.len(), 1);
1080
1081        let dest_name = result.moves[0]
1082            .to
1083            .file_name()
1084            .unwrap()
1085            .to_string_lossy()
1086            .to_string();
1087        assert_eq!(dest_name, "0111. Bicep - CHROMA 012 TANGZ II.flac");
1088
1089        std::fs::remove_dir_all(&tmp).ok();
1090    }
1091}