Skip to main content

xil/cmd/
status.rs

1//! `xil status` — make-style staleness check of an episode's artifacts.
2//! Port of `XILU019_episode_status.py`.
3
4use std::collections::BTreeSet;
5use std::env;
6use std::ffi::OsString;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10use std::time::UNIX_EPOCH;
11
12use chrono::{Local, TimeZone};
13use clap::Parser;
14use regex::Regex;
15use serde_json::{json, Value};
16use xil_audio::tags::{read_sfx_grade, SFX_GRADE_REJECTED};
17use xil_core::fsutil::{basename, list_dir_raw, pathlib_glob, pathlib_rglob_contains, sort_py};
18use xil_core::log;
19use xil_core::pyjson::{dumps, Style};
20use xil_core::sfxlib::shared_sfx_path;
21use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
22
23static TAG_SEARCH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)S\d+E\d+").unwrap());
24static TAG_FULL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^S\d+E\d+$").unwrap());
25
26const OK: &str = "OK";
27const STALE: &str = "STALE";
28const MISSING: &str = "MISSING";
29const NONE: &str = "-";
30
31#[derive(Parser)]
32#[command(
33    name = "xil-status",
34    about = "Make-style staleness checker for episode pipeline artifacts. Reports, per stage, whether outputs \
35             are up to date with their inputs, and prints the xil commands needed to refresh anything stale. \
36             Nothing is rebuilt.",
37    after_help = "Examples:\n  xil status --episode S01E01\n  xil status S01E01 --show \"Night Owls\"\n  \
38                  xil status --all\n  xil status --episode S01E01 --json\n"
39)]
40struct Args {
41    /// Episode tag to check (e.g. S01E01). Omit with --all.
42    #[arg(value_name = "TAG")]
43    episode: Option<String>,
44    /// Episode tag (alternative to the positional argument)
45    #[arg(long = "episode", short = 'e', value_name = "TAG")]
46    episode_flag: Option<String>,
47    /// Show name or slug (default: resolved from project.json / XIL_PROJECTROOT)
48    #[arg(long, short = 's', value_name = "SHOW")]
49    show: Option<String>,
50    /// Check every episode of the show (summary row per episode)
51    #[arg(long, short = 'a')]
52    all: bool,
53    /// Google Drive dir holding the source .gdoc (default: $XIL_GDOC_DIR or /mnt/i/My Drive)
54    #[arg(long, value_name = "DIR")]
55    gdoc_dir: Option<PathBuf>,
56    /// Emit results as JSON (single-episode mode only)
57    #[arg(long)]
58    json: bool,
59    /// List each output file with its mtime below the stage row
60    #[arg(long, short = 'v')]
61    verbose: bool,
62}
63
64/// Freshness result for one pipeline stage.
65#[derive(Debug, Clone)]
66pub struct StageStatus {
67    pub name: &'static str,
68    pub status: &'static str,
69    pub newest_input: Option<f64>,
70    pub newest_output: Option<f64>,
71    pub oldest_output: Option<f64>,
72    pub output_count: usize,
73    pub note: String,
74    pub refresh: String,
75    pub output_files: Vec<PathBuf>,
76}
77
78/// `st_mtime` as CPython computes it: `sec + nsec * 1e-9` in doubles.
79fn mtime(p: &Path) -> Option<f64> {
80    let d = fs::metadata(p)
81        .ok()?
82        .modified()
83        .ok()?
84        .duration_since(UNIX_EPOCH)
85        .ok()?;
86    Some(d.as_secs() as f64 + d.subsec_nanos() as f64 * 1e-9)
87}
88
89/// mtimes of every regular file in `paths` (directories walked recursively),
90/// plus the top-level paths that are regular files.
91///
92/// One `stat` per path: on a drvfs/NAS workspace each one is a network round
93/// trip, and the Episodes table in `xil gui` runs this for every episode.
94/// Directory walks take the entry type from `readdir` and only stat what is
95/// not a directory, which is what `is_dir()`/`is_file()`/`stat()` decide in
96/// three calls in the Python.
97fn mtimes_and_files(paths: &[PathBuf]) -> (Vec<f64>, Vec<PathBuf>) {
98    let mut out = Vec::new();
99    let mut files = Vec::new();
100    for p in paths {
101        let Ok(meta) = fs::metadata(p) else { continue };
102        if meta.is_dir() {
103            let mut stack = vec![p.clone()];
104            while let Some(d) = stack.pop() {
105                let Ok(rd) = fs::read_dir(&d) else { continue };
106                for e in rd.filter_map(Result::ok) {
107                    let path = e.path();
108                    if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
109                        stack.push(path);
110                    } else if let Ok(m) = fs::metadata(&path) {
111                        if m.is_file() {
112                            out.extend(meta_mtime(&m));
113                        }
114                    }
115                }
116            }
117        } else if meta.is_file() {
118            out.extend(meta_mtime(&meta));
119            files.push(p.clone());
120        }
121    }
122    (out, files)
123}
124
125fn mtimes(paths: &[PathBuf]) -> Vec<f64> {
126    mtimes_and_files(paths).0
127}
128
129fn meta_mtime(m: &fs::Metadata) -> Option<f64> {
130    let d = m.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
131    Some(d.as_secs() as f64 + d.subsec_nanos() as f64 * 1e-9)
132}
133
134fn fmt_time(t: Option<f64>) -> String {
135    match t {
136        None => "—".to_string(),
137        Some(t) => {
138            let secs = t.floor() as i64;
139            let nanos = ((t - t.floor()) * 1e9) as u32;
140            Local
141                .timestamp_opt(secs, nanos)
142                .single()
143                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
144                .unwrap_or_else(|| "—".to_string())
145        }
146    }
147}
148
149fn gdoc_files(gdoc_dir: &Path, tag: &str) -> Vec<PathBuf> {
150    if !gdoc_dir.is_dir() {
151        return Vec::new();
152    }
153    let mut set: BTreeSet<PathBuf> = pathlib_glob(gdoc_dir, tag, ".gdoc").into_iter().collect();
154    for p in list_dir_raw(gdoc_dir) {
155        if let Some(n) = p.file_name().and_then(|n| n.to_str()) {
156            if let Some(stem) = n.strip_suffix(".gdoc") {
157                if stem.contains(tag) {
158                    set.insert(p);
159                }
160            }
161        }
162    }
163    let mut v: Vec<PathBuf> = set.into_iter().collect();
164    sort_py(&mut v);
165    v
166}
167
168fn script_files(root: &Path, tag: &str) -> Vec<PathBuf> {
169    let scripts = root.join("scripts");
170    if !scripts.is_dir() {
171        return Vec::new();
172    }
173    pathlib_rglob_contains(&scripts, tag, ".md")
174        .into_iter()
175        .filter(|p| !basename(p).starts_with("revised_"))
176        .collect()
177}
178
179fn glob_in(dir: &Path, suffix: &str) -> Vec<PathBuf> {
180    if dir.is_dir() {
181        pathlib_glob(dir, "", suffix)
182    } else {
183        Vec::new()
184    }
185}
186
187fn master_files(root: &Path, slug: &str, tag: &str) -> Vec<PathBuf> {
188    let masters = root.join("masters");
189    let mut found = Vec::new();
190    if masters.is_dir() {
191        found.extend(pathlib_glob(&masters, &format!("{tag}_{slug}_"), ".mp3"));
192        let sub = masters.join(slug);
193        if sub.is_dir() {
194            found.extend(pathlib_glob(&sub, &format!("{tag}_"), ".mp3"));
195        }
196    }
197    let legacy = root.join(format!("{slug}_{tag}_master.mp3"));
198    if legacy.is_file() {
199        found.push(legacy);
200    }
201    let set: BTreeSet<PathBuf> = found.into_iter().filter(|p| p.is_file()).collect();
202    let mut v: Vec<PathBuf> = set.into_iter().collect();
203    sort_py(&mut v);
204    v
205}
206
207fn fmax(v: &[f64]) -> Option<f64> {
208    v.iter().copied().reduce(f64::max)
209}
210fn fmin(v: &[f64]) -> Option<f64> {
211    v.iter().copied().reduce(f64::min)
212}
213
214fn evaluate_stage(
215    name: &'static str,
216    inputs: &[PathBuf],
217    outputs: &[PathBuf],
218    refresh: &str,
219    count: Option<usize>,
220) -> StageStatus {
221    let in_times = mtimes(inputs);
222    let (out_times, output_files) = mtimes_and_files(outputs);
223    let newest_in = fmax(&in_times);
224    let newest_out = fmax(&out_times);
225    let oldest_out = fmin(&out_times);
226    let (status, suggested) = if out_times.is_empty() {
227        (MISSING, refresh.to_string())
228    } else if matches!((newest_in, newest_out), (Some(i), Some(o)) if i > o) {
229        (STALE, refresh.to_string())
230    } else {
231        (OK, String::new())
232    };
233    StageStatus {
234        name,
235        status,
236        newest_input: newest_in,
237        newest_output: newest_out,
238        oldest_output: oldest_out,
239        output_count: count.unwrap_or(out_times.len()),
240        note: String::new(),
241        refresh: suggested,
242        output_files,
243    }
244}
245
246pub fn evaluate_episode(
247    slug: &str,
248    tag: &str,
249    gdoc_dir: &Path,
250    include_source: bool,
251) -> Vec<StageStatus> {
252    let root = workspace_root();
253    let paths = derive_paths(slug, tag);
254
255    let gdocs = if include_source {
256        gdoc_files(gdoc_dir, tag)
257    } else {
258        Vec::new()
259    };
260    let scripts = script_files(&root, tag);
261    let parsed = vec![paths["parsed"].clone()];
262    let stems = glob_in(&paths["stems"], ".mp3");
263    let stems_manifest = glob_in(&paths["stems"], "_stem_manifest.json");
264    let daw = glob_in(&paths["daw"], ".wav");
265    let masters = master_files(&root, slug, tag);
266
267    let script_refresh = "(re-import the production doc in xil-gui)";
268    // Python's max() keeps the first of equal keys; fold the same way.
269    let newest_script = scripts.iter().fold(None::<(&PathBuf, f64)>, |best, p| {
270        let t = mtime(p).unwrap_or(0.0);
271        match best {
272            Some((_, bt)) if bt >= t => best,
273            _ => Some((p, t)),
274        }
275    });
276    let parse_refresh = match newest_script {
277        Some((p, _)) => format!(
278            "xil parse {} --episode {tag}",
279            p.strip_prefix(&root)
280                .map(|r| r.display().to_string())
281                .unwrap_or_else(|_| p.display().to_string())
282        ),
283        None => format!("xil parse <script> --episode {tag}"),
284    };
285
286    let mut stems_outputs = stems.clone();
287    stems_outputs.extend(stems_manifest);
288
289    let mut stages = Vec::new();
290    if include_source {
291        stages.push(evaluate_stage("source", &[], &gdocs, "", None));
292        stages.push(evaluate_stage(
293            "script",
294            &gdocs,
295            &scripts,
296            script_refresh,
297            None,
298        ));
299        if gdocs.is_empty() {
300            let src = &mut stages[0];
301            src.status = NONE;
302            src.note = if gdoc_dir.is_dir() {
303                "no source doc"
304            } else {
305                "no gdoc dir"
306            }
307            .to_string();
308            src.refresh = String::new();
309        }
310    }
311
312    let mut daw_inputs = stems.clone();
313    if paths["sfx"].exists() {
314        daw_inputs.push(paths["sfx"].clone());
315    }
316
317    stages.push(evaluate_stage(
318        "parsed",
319        &scripts,
320        &parsed,
321        &parse_refresh,
322        None,
323    ));
324    stages.push(evaluate_stage(
325        "stems",
326        &parsed,
327        &stems_outputs,
328        &format!("xil produce --episode {tag}"),
329        Some(stems.len()),
330    ));
331    stages.push(evaluate_stage(
332        "daw",
333        &daw_inputs,
334        &daw,
335        &format!("xil daw --episode {tag}"),
336        None,
337    ));
338    stages.push(evaluate_stage(
339        "master",
340        &daw,
341        &masters,
342        &format!("xil master --episode {tag}"),
343        None,
344    ));
345    stages
346}
347
348fn worst(stages: &[StageStatus]) -> &'static str {
349    let relevant: Vec<&str> = stages
350        .iter()
351        .filter(|s| s.name != "source")
352        .map(|s| s.status)
353        .collect();
354    if relevant.contains(&MISSING) {
355        MISSING
356    } else if relevant.contains(&STALE) {
357        STALE
358    } else {
359        OK
360    }
361}
362
363/// Effect keys whose shared pool file is graded `rejected`. Best-effort,
364/// never fails.
365pub fn rejected_sfx(slug: &str, tag: &str) -> Vec<String> {
366    let root = workspace_root();
367    let cfg_path = root
368        .join("configs")
369        .join(slug)
370        .join(format!("sfx_{tag}.json"));
371    let Ok(text) = fs::read_to_string(&cfg_path) else {
372        return Vec::new();
373    };
374    let Ok(cfg) = serde_json::from_str::<Value>(&text) else {
375        return Vec::new();
376    };
377    let sfx_dir = root.join("SFX");
378    let mut rejected = Vec::new();
379    for (key, eff) in cfg
380        .get("effects")
381        .and_then(Value::as_object)
382        .cloned()
383        .unwrap_or_default()
384    {
385        let mut candidates: Vec<PathBuf> = Vec::new();
386        let src = eff
387            .get("source")
388            .and_then(Value::as_str)
389            .filter(|s| !s.is_empty());
390        if let Some(src) = src {
391            let p = Path::new(src);
392            candidates.push(if p.is_absolute() {
393                p.to_path_buf()
394            } else {
395                root.join(p)
396            });
397        } else {
398            let base = shared_sfx_path(&sfx_dir, &key, "elevenlabs");
399            let stem = basename(&base).trim_end_matches(".mp3").to_string();
400            candidates.push(base);
401            // backend-tagged variants, e.g. SFX/{slug}.audioldm2.mp3
402            for p in list_dir_raw(&sfx_dir) {
403                let n = basename(&p);
404                if !n.starts_with('.')
405                    && n.starts_with(&format!("{stem}."))
406                    && n.ends_with(".mp3")
407                    && n.len() >= stem.len() + 5
408                {
409                    candidates.push(p);
410                }
411            }
412        }
413        if candidates
414            .iter()
415            .any(|c| c.exists() && read_sfx_grade(c) == SFX_GRADE_REJECTED)
416        {
417            rejected.push(key);
418        }
419    }
420    rejected.sort();
421    rejected
422}
423
424fn print_episode(slug: &str, tag: &str, stages: &[StageStatus], verbose: bool) {
425    let root = workspace_root();
426    log::info(&format!("Episode {tag} (show: {slug})"));
427    log::info("");
428    log::info(&format!(
429        "  {:<9} {:<8} {:<18} {:<18} FILES",
430        "STAGE", "STATUS", "NEWEST INPUT", "NEWEST OUTPUT"
431    ));
432    for s in stages {
433        let in_str = fmt_time(s.newest_input);
434        let out_str = if s.note.is_empty() {
435            fmt_time(s.newest_output)
436        } else {
437            format!("({})", s.note)
438        };
439        let count = if s.name == "source" && s.status == NONE {
440            "—".to_string()
441        } else {
442            s.output_count.to_string()
443        };
444        let marker = match s.status {
445            STALE => "   ← input is newer (stage not re-run)",
446            MISSING => "   ← not built yet",
447            _ => "",
448        };
449        log::info(&format!(
450            "  {:<9} {:<8} {:<18} {:<18} {count}{marker}",
451            s.name, s.status, in_str, out_str
452        ));
453        if verbose && !s.output_files.is_empty() {
454            for f in &s.output_files {
455                let rel = f
456                    .strip_prefix(&root)
457                    .map(|r| r.display().to_string())
458                    .unwrap_or_else(|_| f.display().to_string());
459                log::info(&format!("             {}  {rel}", fmt_time(mtime(f))));
460            }
461        }
462    }
463    let refreshes: Vec<&str> = stages
464        .iter()
465        .filter(|s| !s.refresh.is_empty())
466        .map(|s| s.refresh.as_str())
467        .collect();
468    if !refreshes.is_empty() {
469        log::info("");
470        log::info("Stale/missing — refresh with:");
471        for cmd in refreshes {
472            log::info(&format!("  {cmd}"));
473        }
474    }
475    let rejected = rejected_sfx(slug, tag);
476    if !rejected.is_empty() {
477        log::info("");
478        log::warning(&format!(
479            "{} SFX graded rejected — omitted from production (fill the gap or re-grade in xil-gui):",
480            rejected.len()
481        ));
482        for key in rejected {
483            log::info(&format!("    ✗ {key}"));
484        }
485    }
486}
487
488fn opt_f(v: Option<f64>) -> Value {
489    v.map(Value::from).unwrap_or(Value::Null)
490}
491
492fn emit_json(slug: &str, tag: &str, stages: &[StageStatus]) {
493    let payload = json!({
494        "show": slug,
495        "episode": tag,
496        "overall": worst(stages),
497        "rejected_sfx": rejected_sfx(slug, tag),
498        "stages": stages.iter().map(|s| json!({
499            "name": s.name,
500            "status": s.status,
501            "newest_input": opt_f(s.newest_input),
502            "newest_output": opt_f(s.newest_output),
503            "oldest_output": opt_f(s.oldest_output),
504            "output_count": s.output_count,
505            "note": s.note,
506            "refresh": s.refresh,
507        })).collect::<Vec<_>>(),
508    });
509    println!("{}", dumps(&payload, Style::INDENT2));
510}
511
512fn stem_of(p: &Path) -> String {
513    p.file_stem()
514        .map(|s| s.to_string_lossy().into_owned())
515        .unwrap_or_default()
516}
517
518pub fn discover_tags(slug: &str) -> Vec<String> {
519    let root = workspace_root();
520    let mut tags = BTreeSet::new();
521    let parsed_dir = root.join("parsed").join(slug);
522    if parsed_dir.is_dir() {
523        for p in pathlib_glob(&parsed_dir, "parsed_", ".json") {
524            if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
525                tags.insert(m.as_str().to_uppercase());
526            }
527        }
528    }
529    for sub in ["stems", "daw"] {
530        let base = root.join(sub).join(slug);
531        if base.is_dir() {
532            for child in list_dir_raw(&base) {
533                if child.is_dir() && TAG_FULL.is_match(&basename(&child)) {
534                    tags.insert(basename(&child).to_uppercase());
535                }
536            }
537        }
538    }
539    let masters_dir = root.join("masters");
540    if masters_dir.is_dir() {
541        for p in list_dir_raw(&masters_dir) {
542            let n = basename(&p);
543            if n.contains(&format!("_{slug}_")) && n.ends_with(".mp3") && n.len() > slug.len() + 6 {
544                if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
545                    tags.insert(m.as_str().to_uppercase());
546                }
547            }
548        }
549        let sub = masters_dir.join(slug);
550        if sub.is_dir() {
551            for p in pathlib_glob(&sub, "", ".mp3") {
552                if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
553                    tags.insert(m.as_str().to_uppercase());
554                }
555            }
556        }
557    }
558    tags.into_iter().collect()
559}
560
561fn print_all(slug: &str, tags: &[String], gdoc_dir: &Path) -> i32 {
562    if tags.is_empty() {
563        log::info(&format!("No episodes found for show '{slug}'."));
564        return 0;
565    }
566    log::info(&format!("Show: {slug} — {} episode(s)", tags.len()));
567    log::info("");
568    log::info(&format!("  {:<10} {:<8} NEXT STEP", "EPISODE", "STATUS"));
569    let mut exit_code = 0;
570    for tag in tags {
571        let stages = evaluate_episode(slug, tag, gdoc_dir, true);
572        let w = worst(&stages);
573        if w != OK {
574            exit_code = 1;
575        }
576        let mut next_step = stages
577            .iter()
578            .find(|s| !s.refresh.is_empty())
579            .map(|s| s.refresh.clone())
580            .unwrap_or_default();
581        let rej = rejected_sfx(slug, tag);
582        if !rej.is_empty() {
583            let prefix = if next_step.is_empty() {
584                String::new()
585            } else {
586                format!("{next_step}   ")
587            };
588            next_step = format!("{prefix}⚠ {} SFX rejected", rej.len());
589        }
590        log::info(&format!("  {tag:<10} {w:<8} {next_step}"));
591    }
592    exit_code
593}
594
595pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
596    log::init("status");
597    let a: Args = match super::parse_or_exit("xil-status", args) {
598        Ok(a) => a,
599        Err(code) => return Ok(code),
600    };
601    let slug = resolve_slug(a.show.as_deref(), "project.json");
602    let gdoc_dir = a.gdoc_dir.clone().unwrap_or_else(|| {
603        PathBuf::from(env::var("XIL_GDOC_DIR").unwrap_or_else(|_| "/mnt/i/My Drive".into()))
604    });
605    if !gdoc_dir.is_dir() {
606        log::warning(&format!(
607            "Google Drive dir not available: {} — skipping source check.",
608            gdoc_dir.display()
609        ));
610    }
611
612    if a.all {
613        if a.json {
614            log::error("--json is not supported with --all.");
615            return Ok(2);
616        }
617        let tags = discover_tags(&slug);
618        return Ok(print_all(&slug, &tags, &gdoc_dir));
619    }
620
621    let Some(tag) = a.episode_flag.clone().or(a.episode.clone()) else {
622        log::error("Provide an episode tag (e.g. S01E01) or use --all.");
623        return Ok(2);
624    };
625    let stages = evaluate_episode(&slug, &tag, &gdoc_dir, true);
626    if a.json {
627        emit_json(&slug, &tag, &stages);
628    } else {
629        print_episode(&slug, &tag, &stages, a.verbose);
630    }
631    Ok(if worst(&stages) == OK { 0 } else { 1 })
632}
633
634/// The clap definition behind `--help`, for man pages.
635pub fn command() -> clap::Command {
636    <Args as clap::CommandFactory>::command()
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    fn touch(p: &Path, secs: i64) {
644        fs::create_dir_all(p.parent().unwrap()).unwrap();
645        fs::write(p, "x").unwrap();
646        let t = filetime::FileTime::from_unix_time(secs, 0);
647        filetime::set_file_mtime(p, t).unwrap();
648    }
649
650    #[test]
651    fn stage_status_rules() {
652        let tmp = tempfile::tempdir().unwrap();
653        let r = tmp.path();
654        touch(&r.join("in.txt"), 1_700_000_010);
655        touch(&r.join("out_old.txt"), 1_700_000_005);
656        touch(&r.join("out_new.txt"), 1_700_000_020);
657        let missing = evaluate_stage("x", &[r.join("in.txt")], &[r.join("nope")], "fix", None);
658        assert_eq!(
659            (
660                missing.status,
661                missing.refresh.as_str(),
662                missing.output_count
663            ),
664            (MISSING, "fix", 0)
665        );
666        let stale = evaluate_stage(
667            "x",
668            &[r.join("in.txt")],
669            &[r.join("out_old.txt")],
670            "fix",
671            None,
672        );
673        assert_eq!(stale.status, STALE);
674        let ok = evaluate_stage(
675            "x",
676            &[r.join("in.txt")],
677            &[r.join("out_old.txt"), r.join("out_new.txt")],
678            "fix",
679            Some(7),
680        );
681        assert_eq!(
682            (ok.status, ok.refresh.as_str(), ok.output_count),
683            (OK, "", 7)
684        );
685        assert_eq!(ok.oldest_output, Some(1_700_000_005.0));
686        assert_eq!(worst(&[missing.clone(), stale.clone()]), MISSING);
687        assert_eq!(worst(&[ok.clone(), stale]), STALE);
688        assert_eq!(worst(&[ok]), OK);
689    }
690
691    #[test]
692    fn tag_discovery_across_layouts() {
693        let _env = crate::cmd::ENV_LOCK
694            .lock()
695            .unwrap_or_else(|e| e.into_inner());
696        let tmp = tempfile::tempdir().unwrap();
697        let r = tmp.path();
698        std::env::set_var("XIL_PROJECTROOT", r);
699        touch(&r.join("parsed/s/parsed_S01E01.json"), 1);
700        touch(&r.join("stems/s/s01e02/a.mp3"), 1);
701        touch(&r.join("daw/s/notatag/a.wav"), 1);
702        touch(&r.join("masters/S01E03_s_2026-01-01.mp3"), 1);
703        touch(&r.join("masters/S01E09_other_2026-01-01.mp3"), 1);
704        touch(&r.join("masters/s/S01E04_master.mp3"), 1);
705        assert_eq!(
706            discover_tags("s"),
707            vec!["S01E01", "S01E02", "S01E03", "S01E04"]
708        );
709        std::env::remove_var("XIL_PROJECTROOT");
710    }
711}