Skip to main content

xil/cmd/
sfx_lib.rs

1//! `xil sfx-lib` — inventory of the local SFX library from ID3 and MPEG
2//! headers, or of the account's ElevenLabs sound-generation history with
3//! `--api`. Port of `XILU005_discover_SFX.py`.
4
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use serde_json::{Map, Value};
11use xil_audio::mpeg;
12use xil_audio::tags::read_text_tags;
13use xil_core::fsutil::{basename, glob_recursive, relpath, sort_py};
14use xil_core::pyfmt::{fixed, head, pad_right, round_to};
15use xil_core::pyjson::{dumps, py_float, Style};
16use xil_core::workspace::{code_root, workspace_root};
17use xil_core::{banner, log};
18
19const REFERENCE_DOC: &str = "claude-scriptwriter-reference.md";
20
21#[derive(Parser)]
22#[command(
23    name = "xil-sfx-lib",
24    about = "Discover personally generated Sound Effects from the ElevenLabs account or local SFX/ directory"
25)]
26struct Args {
27    /// Query ElevenLabs /v1/sound-generation/history (requires sound_generation permission)
28    #[arg(long, conflicts_with = "local")]
29    api: bool,
30    /// Scan local SFX/ directory only (no API key needed)
31    #[arg(long)]
32    local: bool,
33    /// Local SFX directory to scan (default: <workspace>/SFX/)
34    #[arg(long, value_name = "DIR")]
35    sfx_dir: Option<String>,
36    /// Case-insensitive substring filter on the prompt/filename
37    #[arg(long, value_name = "TEXT")]
38    search: Option<String>,
39    /// (API mode) Paginate through the full account history; default: most recent 100
40    #[arg(long)]
41    all: bool,
42    /// Print all fields for each record
43    #[arg(long, short = 'v')]
44    verbose: bool,
45    /// Output results as a JSON array
46    #[arg(long)]
47    json: bool,
48    /// Export SFX inventory JSON + scriptwriter reference doc to DIR (default: current directory)
49    #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
50    export_kit: Option<String>,
51}
52
53/// `f"{seconds:.1f}s"`, or empty for `None`.
54fn fmt_duration(seconds: Option<f64>) -> String {
55    seconds
56        .map(|s| format!("{}s", fixed(s, 1)))
57        .unwrap_or_default()
58}
59
60/// `f"{bytes_ / 1024:.0f} KB"`.
61fn fmt_size(bytes: u64) -> String {
62    format!("{} KB", fixed(bytes as f64 / 1024.0, 0))
63}
64
65fn get_str<'a>(rec: &'a Map<String, Value>, key: &str) -> &'a str {
66    rec.get(key).and_then(Value::as_str).unwrap_or("")
67}
68
69/// `_read_local_record`: ID3 text frames plus MPEG duration/bit rate.
70pub fn read_local_record(path: &Path, sfx_root: &Path) -> Map<String, Value> {
71    let filename = basename(path);
72    let size_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
73    let parent = path.parent().unwrap_or(Path::new(""));
74    let rel_dir = relpath(parent, sfx_root).to_string_lossy().into_owned();
75    let show = if rel_dir == "." {
76        String::new()
77    } else {
78        rel_dir.split('/').next().unwrap_or("").to_string()
79    };
80
81    let tags = read_text_tags(path);
82    let (duration, bitrate) = match mpeg::info(path) {
83        Ok(i) => (
84            py_float(round_to(i.length, 1)),
85            Value::from(i.bitrate / 1000),
86        ),
87        Err(_) => (Value::Null, Value::Null),
88    };
89
90    let mut rec = Map::new();
91    rec.insert("source".into(), "local".into());
92    rec.insert("filename".into(), filename.into());
93    rec.insert("path".into(), path.to_string_lossy().into_owned().into());
94    rec.insert("show".into(), show.into());
95    rec.insert("prompt".into(), tags.lyrics.into());
96    rec.insert("title".into(), tags.title.into());
97    rec.insert("artist".into(), tags.artist.into());
98    rec.insert("duration_seconds".into(), duration);
99    rec.insert("bitrate_kbps".into(), bitrate);
100    rec.insert("size_bytes".into(), Value::from(size_bytes));
101    rec.insert("date".into(), "".into());
102    rec
103}
104
105/// One record per `.mp3` under `sfx_dir`, any depth, sorted by path.
106pub fn fetch_local_records(sfx_dir: &Path) -> Vec<Map<String, Value>> {
107    if !sfx_dir.is_dir() {
108        log::warning(&format!("SFX directory not found: {}", sfx_dir.display()));
109        return Vec::new();
110    }
111    // glob sorts by the path string, not by component.
112    let mut paths = glob_recursive(sfx_dir, "", ".mp3");
113    sort_py(&mut paths);
114    paths
115        .iter()
116        .map(|p| read_local_record(p, sfx_dir))
117        .collect()
118}
119
120fn print_verbose_local(rec: &Map<String, Value>) {
121    log::info(&format!("  File           : {}", get_str(rec, "filename")));
122    let show = get_str(rec, "show");
123    log::info(&format!(
124        "  Show           : {}",
125        if show.is_empty() {
126            "(shared pool)"
127        } else {
128            show
129        }
130    ));
131    let prompt = get_str(rec, "prompt");
132    log::info(&format!(
133        "  Prompt         : {}",
134        if prompt.is_empty() {
135            "— (no prompt tag)"
136        } else {
137            prompt
138        }
139    ));
140    let title = get_str(rec, "title");
141    if !title.is_empty() {
142        log::info(&format!("  Title          : {title}"));
143    }
144    if let Some(d) = rec.get("duration_seconds").and_then(Value::as_f64) {
145        log::info(&format!("  Duration       : {}", fmt_duration(Some(d))));
146    }
147    if let Some(b) = rec.get("bitrate_kbps").and_then(Value::as_u64) {
148        log::info(&format!("  Bitrate        : {b} kbps"));
149    }
150    log::info(&format!(
151        "  Size           : {}",
152        fmt_size(rec.get("size_bytes").and_then(Value::as_u64).unwrap_or(0))
153    ));
154    log::info("");
155}
156
157/// `fetch_api_records(api_key, max_items)`. `None` means the key lacks the
158/// permission: the warnings are logged and the command exits 1.
159fn fetch_api_records(
160    api_key: &str,
161    max_items: Option<usize>,
162) -> anyhow::Result<Option<Vec<Map<String, Value>>>> {
163    let client = xil_api::elevenlabs::Client::new(Some(api_key.to_string()));
164    let mut records = Vec::new();
165    let mut start_after: Option<String> = None;
166    loop {
167        let data = match client.sound_generation_history(100, start_after.as_deref()) {
168            Ok(d) => d,
169            Err(xil_api::ApiError::Status { status: 401, body }) => {
170                let parsed: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
171                let status = parsed
172                    .get("detail")
173                    .and_then(|d| d.get("status"))
174                    .and_then(Value::as_str)
175                    .unwrap_or("");
176                log::warning("ElevenLabs API: permission denied for sound-generation history.");
177                log::warning("");
178                if matches!(status, "missing_permissions" | "needs_authorization") {
179                    log::warning("    Fix: ElevenLabs dashboard → Profile → API Keys");
180                    log::warning("    Edit your key → Endpoints → Sound Effects → set to 'Access'");
181                    log::warning("    then re-run without --api to fall back to local scan,");
182                    log::warning("    or with --api once the permission is active.");
183                } else {
184                    log::warning(&format!("    Response: {}", head(&body, 200)));
185                }
186                return Ok(None);
187            }
188            Err(e) => anyhow::bail!("httpx.HTTPStatusError: {e}"),
189        };
190        let truthy = crate::cmd::truthy;
191        let first = |keys: &[&str]| {
192            keys.iter()
193                .filter_map(|k| data.get(*k))
194                .find(|v| truthy(v))
195                .cloned()
196        };
197        let items = first(&["history", "generations", "items"])
198            .and_then(|v| v.as_array().cloned())
199            .unwrap_or_default();
200
201        for item in &items {
202            let get = |k: &str| item.get(k).cloned();
203            let or = |a: &str, b: &str, default: Value| {
204                get(a).filter(truthy).or_else(|| get(b)).unwrap_or(default)
205            };
206            let cfg = [get("generation_config"), get("settings")]
207                .into_iter()
208                .flatten()
209                .find(truthy)
210                .unwrap_or(Value::Object(Map::new()));
211            let ts = get("date_unix")
212                .filter(truthy)
213                .or_else(|| get("created_at_unix"))
214                .filter(|v| !v.is_null());
215            let date = ts
216                .as_ref()
217                .and_then(Value::as_f64)
218                .and_then(|t| chrono::DateTime::from_timestamp(t.floor() as i64, 0))
219                .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
220                .unwrap_or_default();
221            let count = |k: &str| item.get(k).and_then(Value::as_i64).unwrap_or(0);
222
223            let mut rec = Map::new();
224            rec.insert("source".into(), "api".into());
225            rec.insert(
226                "history_item_id".into(),
227                or("history_item_id", "id", "".into()),
228            );
229            rec.insert("prompt".into(), or("text", "prompt", "".into()));
230            rec.insert(
231                "model_id".into(),
232                get("model_id").unwrap_or_else(|| "".into()),
233            );
234            rec.insert("date".into(), date.into());
235            rec.insert(
236                "date_unix".into(),
237                ts.filter(truthy).unwrap_or_else(|| 0.into()),
238            );
239            rec.insert(
240                "duration_seconds".into(),
241                cfg.get("duration_seconds").cloned().unwrap_or(Value::Null),
242            );
243            rec.insert(
244                "prompt_influence".into(),
245                cfg.get("prompt_influence").cloned().unwrap_or(Value::Null),
246            );
247            rec.insert(
248                "credits_used".into(),
249                (count("character_count_change_to") - count("character_count_change_from")).into(),
250            );
251            rec.insert("filename".into(), "".into());
252            rec.insert("path".into(), "".into());
253            records.push(rec);
254
255            if max_items.is_some_and(|m| records.len() >= m) {
256                return Ok(Some(records));
257            }
258        }
259
260        if !data.get("has_more").is_some_and(truthy) {
261            break;
262        }
263        let next = data
264            .get("last_history_item_id")
265            .filter(|v| truthy(v))
266            .or_else(|| items.last().and_then(|i| i.get("history_item_id")))
267            .filter(|v| truthy(v));
268        match next {
269            Some(v) => start_after = Some(crate::cmd::py_str(v)),
270            None => break,
271        }
272    }
273    Ok(Some(records))
274}
275
276fn print_verbose_api(rec: &Map<String, Value>) {
277    let field = |k: &str| rec.get(k).map(crate::cmd::py_str).unwrap_or_default();
278    log::info(&format!("  Prompt         : {}", field("prompt")));
279    log::info(&format!("  History ID     : {}", field("history_item_id")));
280    if rec.get("model_id").is_some_and(crate::cmd::truthy) {
281        log::info(&format!("  Model          : {}", field("model_id")));
282    }
283    let date = get_str(rec, "date");
284    log::info(&format!(
285        "  Created        : {}",
286        if date.is_empty() { "—" } else { date }
287    ));
288    if let Some(d) = rec.get("duration_seconds").and_then(Value::as_f64) {
289        log::info(&format!("  Duration       : {}", fmt_duration(Some(d))));
290    }
291    if rec.get("prompt_influence").is_some_and(|v| !v.is_null()) {
292        log::info(&format!("  Prompt infl.   : {}", field("prompt_influence")));
293    }
294    log::info(&format!("  Credits used   : {}", field("credits_used")));
295    log::info("");
296}
297
298fn print_compact_api(rec: &Map<String, Value>) {
299    let dur = rec
300        .get("duration_seconds")
301        .filter(|v| crate::cmd::truthy(v))
302        .and_then(Value::as_f64)
303        .map(|d| fmt_duration(Some(d)))
304        .unwrap_or_default();
305    let prompt = get_str(rec, "prompt");
306    let prompt = if prompt.chars().count() > 72 {
307        format!("{}…", head(prompt, 72))
308    } else {
309        prompt.to_string()
310    };
311    log::info(&format!(
312        "  {}  {}  {dur}",
313        get_str(rec, "date"),
314        rec.get("history_item_id")
315            .map(crate::cmd::py_str)
316            .unwrap_or_default()
317    ));
318    if !prompt.is_empty() {
319        log::info(&format!("    {prompt}"));
320    }
321}
322
323fn print_compact_local(rec: &Map<String, Value>) {
324    let dur = fmt_duration(rec.get("duration_seconds").and_then(Value::as_f64));
325    let size = fmt_size(rec.get("size_bytes").and_then(Value::as_u64).unwrap_or(0));
326    let prompt = get_str(rec, "prompt");
327    let prompt = if prompt.chars().count() > 72 {
328        format!("{}…", head(prompt, 72))
329    } else {
330        prompt.to_string()
331    };
332    let meta = format!("{}  {}", pad_right(&dur, 6), pad_right(&size, 8));
333    let show = get_str(rec, "show");
334    let name = if show.is_empty() {
335        get_str(rec, "filename").to_string()
336    } else {
337        format!("[{show}] {}", get_str(rec, "filename"))
338    };
339    log::info(&format!("  {}  {meta}", pad_right(&name, 38)));
340    if !prompt.is_empty() {
341        log::info(&format!("    {prompt}"));
342    }
343}
344
345/// Filename → cheatsheet bucket, by prefix.
346fn category(filename: &str) -> &'static str {
347    let f = filename.to_lowercase();
348    if f.starts_with("ambience_") || f.starts_with("amb-") || f.starts_with("amb_") {
349        return "AMBIENCE";
350    }
351    if f.starts_with("amb") && f.chars().nth(3).is_some_and(char::is_alphabetic) {
352        return "AMBIENCE";
353    }
354    if f.starts_with("music_") || f.starts_with("mus-") || f.starts_with("mus_") {
355        return "MUSIC";
356    }
357    if f.starts_with("mus") && f.chars().nth(3).is_some_and(char::is_alphabetic) {
358        return "MUSIC";
359    }
360    if f.starts_with("beat") {
361        return "BEAT";
362    }
363    "SFX"
364}
365
366/// `_write_pipe_hints_md`: returns the written path.
367fn write_pipe_hints_md(
368    records: &[Map<String, Value>],
369    output_dir: &Path,
370) -> std::io::Result<PathBuf> {
371    let mut buckets: indexmap::IndexMap<&str, Vec<String>> = ["AMBIENCE", "MUSIC", "SFX", "BEAT"]
372        .into_iter()
373        .map(|k| (k, Vec::new()))
374        .collect();
375    let mut untitled: Vec<String> = Vec::new();
376    for rec in records {
377        let fnm = get_str(rec, "filename");
378        let title = get_str(rec, "title").trim();
379        if title.is_empty() {
380            untitled.push(fnm.to_string());
381        } else {
382            buckets[category(fnm)].push(format!("[{title} | {fnm}]"));
383        }
384    }
385    for lines in buckets.values_mut() {
386        lines.sort();
387    }
388    untitled.sort();
389
390    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
391    let titled_total: usize = buckets.values().map(Vec::len).sum();
392    let mut out: Vec<String> = vec![
393        "# SFX Pipe-Hint Cheatsheet".into(),
394        format!(
395            "# Generated {today} from {} assets ({titled_total} titled)",
396            records.len()
397        ),
398        "# Copy lines verbatim into script directions — do NOT modify the filename.".into(),
399        "# If a sound you need is not listed here, omit the pipe-hint entirely.".into(),
400        String::new(),
401    ];
402    for cat in ["AMBIENCE", "MUSIC", "SFX", "BEAT"] {
403        let entries = &buckets[cat];
404        out.push(format!("## {cat} ({} titled)", entries.len()));
405        out.push(String::new());
406        out.extend(entries.iter().cloned());
407        out.push(String::new());
408    }
409    if !untitled.is_empty() {
410        out.push(format!(
411            "## Untitled Assets — filename only ({} assets)",
412            untitled.len()
413        ));
414        out.push("# These have no direction-text title tag.".into());
415        out.push("# Match by filename keyword; use as bare source reference.".into());
416        out.push(String::new());
417        out.extend(untitled);
418        out.push(String::new());
419    }
420    let hints_path = output_dir.join("sfx_pipe_hints.md");
421    fs::write(&hints_path, out.join("\n"))?;
422    Ok(hints_path)
423}
424
425/// `os.path.normpath` for the handful of shapes this command builds.
426fn normpath(p: &Path) -> PathBuf {
427    let s = p.to_string_lossy();
428    let absolute = s.starts_with('/');
429    let mut parts: Vec<&str> = Vec::new();
430    for seg in s.split('/') {
431        match seg {
432            "" | "." => {}
433            ".." => {
434                if parts.last().is_some_and(|l| *l != "..") {
435                    parts.pop();
436                } else if !absolute {
437                    parts.push("..");
438                }
439            }
440            other => parts.push(other),
441        }
442    }
443    let joined = parts.join("/");
444    if absolute {
445        PathBuf::from(format!("/{joined}"))
446    } else if joined.is_empty() {
447        PathBuf::from(".")
448    } else {
449        PathBuf::from(joined)
450    }
451}
452
453fn same_file(a: &Path, b: &Path) -> bool {
454    match (fs::canonicalize(a), fs::canonicalize(b)) {
455        (Ok(x), Ok(y)) => x == y,
456        _ => false,
457    }
458}
459
460/// `shutil.copy2`: contents plus the modification time.
461fn copy2(src: &Path, dst: &Path) -> std::io::Result<()> {
462    fs::copy(src, dst)?;
463    if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
464        let f = fs::File::options().write(true).open(dst)?;
465        f.set_modified(m)?;
466    }
467    Ok(())
468}
469
470/// Where Python looks first: `<package>/../../docs/<doc>`, which for the
471/// editable install is the Python checkout's `docs/` directory. The doc
472/// actually lives in `docs/guides/` there, so this lookup fails on both
473/// sides and the CWD fallback decides; the path is still printed.
474fn package_reference_path() -> PathBuf {
475    match code_root() {
476        Some(root) => root.join("docs").join(REFERENCE_DOC),
477        None => {
478            let exe = std::env::current_exe().unwrap_or_default();
479            normpath(
480                &exe.join("..")
481                    .join("..")
482                    .join("..")
483                    .join("docs")
484                    .join(REFERENCE_DOC),
485            )
486        }
487    }
488}
489
490/// `export_kit`: `(json_path, hints_path, md_path)`; `md_path` is empty
491/// when no reference doc was found.
492fn export_kit(
493    records: &[Map<String, Value>],
494    output_dir: &Path,
495) -> anyhow::Result<(PathBuf, PathBuf, PathBuf)> {
496    fs::create_dir_all(output_dir)?;
497    let json_path = output_dir.join("sfx_inventory.json");
498    let arr = Value::Array(records.iter().cloned().map(Value::Object).collect());
499    fs::write(&json_path, dumps(&arr, Style::INDENT2) + "\n")?;
500    let hints_path = write_pipe_hints_md(records, output_dir)?;
501
502    let ref_src = normpath(&package_reference_path());
503    let mut md_path = normpath(&output_dir.join(REFERENCE_DOC));
504    if ref_src.exists() {
505        if !same_file(&ref_src, &md_path) {
506            copy2(&ref_src, &md_path)?;
507        }
508    } else {
509        let cwd_ref = normpath(&Path::new("docs").join(REFERENCE_DOC));
510        if cwd_ref.exists() {
511            if !same_file(&cwd_ref, &md_path) {
512                copy2(&cwd_ref, &md_path)?;
513            } else {
514                md_path = cwd_ref;
515            }
516        } else {
517            log::warning(&format!(
518                "Reference doc not found at {} or {}",
519                ref_src.display(),
520                cwd_ref.display()
521            ));
522            md_path = PathBuf::new();
523        }
524    }
525    Ok((json_path, hints_path, md_path))
526}
527
528fn execute(a: &Args) -> anyhow::Result<i32> {
529    let sfx_dir = a
530        .sfx_dir
531        .clone()
532        .unwrap_or_else(|| workspace_root().join("SFX").to_string_lossy().into_owned());
533    let (mut records, data_source) = if a.api {
534        let api_key = std::env::var("ELEVENLABS_API_KEY").unwrap_or_default();
535        if api_key.is_empty() {
536            log::warning("ELEVENLABS_API_KEY not set.");
537            return Ok(1);
538        }
539        match fetch_api_records(&api_key, if a.all { None } else { Some(100) })? {
540            Some(r) => (r, "API".to_string()),
541            None => return Ok(1),
542        }
543    } else {
544        (
545            fetch_local_records(Path::new(&sfx_dir)),
546            format!("local ({sfx_dir}/)"),
547        )
548    };
549
550    if let Some(q) = &a.search {
551        let q = q.to_lowercase();
552        records.retain(|r| {
553            get_str(r, "prompt").to_lowercase().contains(&q)
554                || get_str(r, "filename").to_lowercase().contains(&q)
555        });
556    }
557    let date_unix =
558        |r: &Map<String, Value>| r.get("date_unix").and_then(Value::as_f64).unwrap_or(0.0);
559    if records
560        .first()
561        .is_some_and(|r| r.get("date_unix").is_some_and(crate::cmd::truthy))
562    {
563        records.sort_by(|x, y| date_unix(y).total_cmp(&date_unix(x)));
564    } else {
565        records.sort_by_key(|r| get_str(r, "filename").to_lowercase());
566    }
567
568    if let Some(dir) = &a.export_kit {
569        let (json_path, hints_path, md_path) = export_kit(&records, Path::new(dir))?;
570        log::info(&format!(
571            "\n--- Export kit ({} assets) ---\n",
572            records.len()
573        ));
574        log::info(&format!("  JSON inventory : {}", json_path.display()));
575        log::info(&format!(
576            "  Pipe-hint cheatsheet : {}",
577            hints_path.display()
578        ));
579        if !md_path.as_os_str().is_empty() {
580            log::info(&format!("  Reference doc  : {}", md_path.display()));
581        }
582        log::info("");
583        log::info("  Attach all three files to your Claude project as knowledge files.");
584        return Ok(0);
585    }
586
587    if a.json {
588        let arr = Value::Array(records.into_iter().map(Value::Object).collect());
589        println!("{}", dumps(&arr, Style::INDENT2));
590        return Ok(0);
591    }
592
593    log::info(&format!(
594        "\n--- ElevenLabs Sound Effects  [{data_source}]  ({} items) ---\n",
595        records.len()
596    ));
597    if records.is_empty() {
598        log::info("  No sound-effect records found.");
599        if let Some(q) = &a.search {
600            log::info(&format!(
601                "  (search filter: {})",
602                xil_core::script::hints::py_repr(q)
603            ));
604        }
605        return Ok(0);
606    }
607
608    if a.verbose {
609        for rec in &records {
610            if get_str(rec, "source") == "local" {
611                print_verbose_local(rec);
612            } else {
613                print_verbose_api(rec);
614            }
615        }
616    } else {
617        for rec in &records {
618            if get_str(rec, "source") == "local" {
619                print_compact_local(rec);
620            } else {
621                print_compact_api(rec);
622            }
623        }
624        log::info("");
625        if data_source.starts_with("local") {
626            let total: u64 = records
627                .iter()
628                .map(|r| r.get("size_bytes").and_then(Value::as_u64).unwrap_or(0))
629                .sum();
630            log::info(&format!(
631                "  Total size: {} MB  ({} files)",
632                fixed(total as f64 / (1024.0 * 1024.0), 1),
633                records.len()
634            ));
635        } else {
636            let total: i64 = records
637                .iter()
638                .map(|r| r.get("credits_used").and_then(Value::as_i64).unwrap_or(0))
639                .sum();
640            log::info(&format!(
641                "  Total credits used: {}",
642                xil_core::pyfmt::commas(total)
643            ));
644        }
645        log::info("");
646        log::info("  Use --verbose for full details, --json for machine-readable output,");
647        log::info("  --search <text> to filter, --local / --api to select data source.");
648    }
649    Ok(0)
650}
651
652pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
653    log::init("sfx-lib");
654    let _banner = banner::begin(super::prog(), &super::argv_line(args));
655    let a: Args = match super::parse_or_exit("xil-sfx-lib", args) {
656        Ok(a) => a,
657        Err(code) => return Ok(code),
658    };
659    execute(&a)
660}
661
662/// The clap definition behind `--help`, for man pages.
663pub fn command() -> clap::Command {
664    <Args as clap::CommandFactory>::command()
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn categories_follow_filename_prefixes() {
673        assert_eq!(category("AMBGras-field.mp3"), "AMBIENCE");
674        assert_eq!(category("ambience_cafe.mp3"), "AMBIENCE");
675        assert_eq!(
676            category("amb1.mp3"),
677            "SFX",
678            "digit after amb is not a short code"
679        );
680        assert_eq!(category("MUSIC_theme.mp3"), "MUSIC");
681        assert_eq!(category("MUSFolk-x.mp3"), "MUSIC");
682        assert_eq!(category("beat.mp3"), "BEAT");
683        assert_eq!(category("sfx_door.mp3"), "SFX");
684    }
685
686    #[test]
687    fn formatting_helpers() {
688        assert_eq!(fmt_duration(Some(2.35)), "2.4s");
689        assert_eq!(fmt_duration(None), "");
690        assert_eq!(fmt_size(1536), "2 KB");
691        assert_eq!(fmt_size(1024 * 2 + 512), "2 KB", "half rounds to even");
692        assert_eq!(
693            normpath(Path::new("./kit/../kit/x.md")),
694            PathBuf::from("kit/x.md")
695        );
696        assert_eq!(normpath(Path::new("/a/b/../../c")), PathBuf::from("/c"));
697        assert_eq!(normpath(Path::new("./x")), PathBuf::from("x"));
698    }
699
700    #[test]
701    fn records_carry_show_and_blank_tags() {
702        let tmp = tempfile::tempdir().unwrap();
703        let root = tmp.path().join("SFX");
704        fs::create_dir_all(root.join("myshow")).unwrap();
705        fs::write(root.join("myshow").join("x.mp3"), b"ID3\x03\x00x").unwrap();
706        fs::write(root.join("top.mp3"), b"").unwrap();
707        fs::write(root.join("skip.MP3"), b"").unwrap();
708        let recs = fetch_local_records(&root);
709        let names: Vec<&str> = recs.iter().map(|r| get_str(r, "filename")).collect();
710        assert_eq!(
711            names,
712            vec!["x.mp3", "top.mp3"],
713            "path order, .MP3 not matched"
714        );
715        assert_eq!(get_str(&recs[0], "show"), "myshow");
716        assert_eq!(get_str(&recs[1], "show"), "");
717        assert!(recs[0]["duration_seconds"].is_null());
718        assert_eq!(recs[0]["size_bytes"], 6);
719        let keys: Vec<&str> = recs[0].keys().map(String::as_str).collect();
720        assert_eq!(keys[0], "source");
721        assert_eq!(keys[10], "date");
722    }
723}