Skip to main content

xil/cmd/
cleanup.rs

1//! `xil cleanup` — delete stems whose seq no longer matches the parsed
2//! script. Port of `XILP008_stale_stem_cleanup.py`.
3
4use std::collections::BTreeMap;
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use serde_json::{Map, Value};
11use xil_core::fsutil::{basename, glob_children};
12use xil_core::stems::{entries_index, expected_stem_basename, extract_seq};
13use xil_core::workspace::{derive_paths, resolve_slug};
14use xil_core::{banner, log};
15
16#[derive(Parser)]
17#[command(
18    name = "xil-cleanup",
19    about = "Remove stale stems that no longer match the current parsed script.  Use --dry-run first to \
20             review what would be deleted."
21)]
22struct Args {
23    /// Episode tag (e.g. S02E03); derives --parsed and --stems
24    #[arg(long, value_name = "TAG")]
25    episode: Option<String>,
26    /// Raw tag for non-episodic content (e.g. V01C03, D01); same as --episode
27    #[arg(long, value_name = "TAG")]
28    tag: Option<String>,
29    /// Show name override (default: from project.json)
30    #[arg(long)]
31    show: Option<String>,
32    /// Parsed script JSON (overrides --episode)
33    #[arg(long, value_name = "PATH")]
34    parsed: Option<PathBuf>,
35    /// Stems directory (overrides --episode)
36    #[arg(long, value_name = "DIR")]
37    stems: Option<PathBuf>,
38    /// List stale stems without deleting them
39    #[arg(long)]
40    dry_run: bool,
41}
42
43/// argparse's own usage block, wrapped at its default 80 columns. The
44/// three `parser.error` sites print this verbatim before their message,
45/// and clap's usage rendering is not the same shape.
46const USAGE: &str = "usage: xil-cleanup [-h] [--episode TAG] [--tag TAG] [--show SHOW]\n\
47                     \x20                  [--parsed PATH] [--stems DIR] [--dry-run]";
48
49/// `parser.error(msg)`: usage on stderr, the message, exit 2.
50fn parser_error(msg: &str) -> i32 {
51    eprintln!("{USAGE}");
52    eprintln!("xil-cleanup: error: {msg}");
53    2
54}
55
56/// One stale stem and why it is stale.
57pub struct Stale {
58    pub path: PathBuf,
59    pub reason: String,
60}
61
62/// Stems that disagree with the current parsed entries.
63///
64/// A stem is stale when its seq is gone, its seq is now a header, its
65/// `_sfx`/speaker suffix contradicts the entry type, its speaker changed,
66/// or it is a duplicate of the canonically named file for that seq.
67pub fn find_stale_stems(
68    stems_dir: &Path,
69    index: &std::collections::HashMap<i64, Map<String, Value>>,
70) -> Vec<Stale> {
71    let mut by_seq: BTreeMap<i64, Vec<PathBuf>> = BTreeMap::new();
72    for path in glob_children(stems_dir, "", ".mp3") {
73        if let Some(seq) = extract_seq(&path) {
74            by_seq.entry(seq).or_default().push(path);
75        }
76    }
77
78    let mut stale = Vec::new();
79    for (seq, paths) in by_seq {
80        let Some(entry) = index.get(&seq) else {
81            for p in paths {
82                stale.push(Stale {
83                    path: p,
84                    reason: "seq not in parsed JSON".into(),
85                });
86            }
87            continue;
88        };
89        let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("");
90
91        // Header entries never have stems.
92        if entry_type != "dialogue" && entry_type != "direction" {
93            for p in paths {
94                stale.push(Stale {
95                    path: p,
96                    reason: format!("seq {seq} is now a {entry_type} entry"),
97                });
98            }
99            continue;
100        }
101
102        if paths.len() > 1 {
103            let expected = expected_stem_basename(entry);
104            for p in paths {
105                if stem_of(&p) != expected {
106                    stale.push(Stale {
107                        path: p,
108                        reason: format!("seq {seq} duplicate (expected {expected})"),
109                    });
110                }
111            }
112            continue;
113        }
114
115        let path = paths.into_iter().next().expect("one path");
116        let base = stem_of(&path);
117        let suffix = base.rsplit('_').next().unwrap_or("");
118        let is_sfx_stem = suffix == "sfx";
119
120        if is_sfx_stem && entry_type == "dialogue" {
121            stale.push(Stale {
122                path,
123                reason: format!("seq {seq} is now a dialogue entry"),
124            });
125        } else if !is_sfx_stem && entry_type == "direction" {
126            stale.push(Stale {
127                path,
128                reason: format!("seq {seq} is now a direction entry"),
129            });
130        } else if entry_type == "dialogue" {
131            let speaker = entry.get("speaker").and_then(Value::as_str).unwrap_or("");
132            if !base.ends_with(&format!("_{speaker}")) {
133                stale.push(Stale {
134                    path,
135                    reason: format!("seq {seq} speaker is now {speaker}"),
136                });
137            }
138        }
139    }
140    stale
141}
142
143/// `os.path.splitext(os.path.basename(p))[0]`.
144fn stem_of(p: &Path) -> String {
145    let name = basename(p);
146    match name.rfind('.') {
147        Some(dot) if dot > 0 => name[..dot].to_string(),
148        _ => name,
149    }
150}
151
152/// Pad to `width` by character count (`f"{s:40s}"`).
153fn pad(s: &str, width: usize) -> String {
154    let n = s.chars().count();
155    if n >= width {
156        s.to_string()
157    } else {
158        format!("{s}{}", " ".repeat(width - n))
159    }
160}
161
162fn execute(a: &Args) -> anyhow::Result<i32> {
163    let (parsed_path, stems_dir) = match a.episode.as_ref().or(a.tag.as_ref()) {
164        Some(tag) => {
165            let slug = resolve_slug(a.show.as_deref(), "project.json");
166            let p = derive_paths(&slug, tag);
167            (
168                a.parsed.clone().unwrap_or_else(|| p["parsed"].clone()),
169                a.stems.clone().unwrap_or_else(|| p["stems"].clone()),
170            )
171        }
172        None => match (&a.parsed, &a.stems) {
173            (Some(p), Some(s)) => (p.clone(), s.clone()),
174            _ => {
175                return Ok(parser_error(
176                    "Provide --episode, or both --parsed and --stems.",
177                ))
178            }
179        },
180    };
181
182    if !parsed_path.is_file() {
183        return Ok(parser_error(&format!(
184            "Parsed JSON not found: {}",
185            parsed_path.display()
186        )));
187    }
188    if !stems_dir.is_dir() {
189        return Ok(parser_error(&format!(
190            "Stems directory not found: {}",
191            stems_dir.display()
192        )));
193    }
194
195    let parsed: Value = serde_json::from_str(&fs::read_to_string(&parsed_path)?)?;
196    let index = entries_index(&parsed);
197    let stale = find_stale_stems(&stems_dir, &index);
198
199    if stale.is_empty() {
200        log::info("No stale stems found — stems directory is clean.");
201        return Ok(0);
202    }
203
204    let label = if a.dry_run { "[DRY RUN] " } else { "" };
205    log::info(&format!("\n{label}Stale stems ({}):\n", stale.len()));
206    for s in &stale {
207        log::info(&format!(
208            "  {}  ({})",
209            pad(&basename(&s.path), 40),
210            s.reason
211        ));
212    }
213    log::info("");
214    if a.dry_run {
215        log::info(&format!("  {} stale stems would be deleted.", stale.len()));
216        log::info("  Re-run without --dry-run to delete them.");
217    } else {
218        for s in &stale {
219            fs::remove_file(&s.path)?;
220        }
221        log::info(&format!("  Deleted {} stale stems.", stale.len()));
222    }
223    Ok(0)
224}
225
226pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
227    log::init("cleanup");
228    let _banner = banner::begin(super::prog(), &super::argv_line(args));
229    let a: Args = match super::parse_or_exit("xil-cleanup", args) {
230        Ok(a) => a,
231        Err(code) => return Ok(code),
232    };
233    execute(&a)
234}
235
236/// The clap definition behind `--help`, for man pages.
237pub fn command() -> clap::Command {
238    <Args as clap::CommandFactory>::command()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use serde_json::json;
245    use std::collections::HashMap;
246
247    fn index(v: Value) -> HashMap<i64, Map<String, Value>> {
248        entries_index(&v)
249    }
250
251    fn touch(dir: &Path, name: &str) {
252        fs::create_dir_all(dir).unwrap();
253        fs::write(dir.join(name), "x").unwrap();
254    }
255
256    #[test]
257    fn every_staleness_reason() {
258        let tmp = tempfile::tempdir().unwrap();
259        let d = tmp.path();
260        touch(d, "001_cold-open_adam.mp3"); // fine
261        touch(d, "002_cold-open_sfx.mp3"); // now dialogue
262        touch(d, "003_cold-open_adam.mp3"); // now direction
263        touch(d, "004_cold-open_adam.mp3"); // speaker changed
264        touch(d, "005_cold-open_sfx.mp3"); // now a header
265        touch(d, "006_cold-open_adam.mp3"); // seq gone
266        touch(d, "007_cold-open_adam.mp3"); // duplicate pair
267        touch(d, "007_act1_maya.mp3");
268        touch(d, "notaseq.mp3"); // unparseable, ignored
269
270        let idx = index(json!({"entries": [
271            {"seq": 1, "type": "dialogue", "section": "cold-open", "speaker": "adam"},
272            {"seq": 2, "type": "dialogue", "section": "cold-open", "speaker": "adam"},
273            {"seq": 3, "type": "direction", "section": "cold-open"},
274            {"seq": 4, "type": "dialogue", "section": "cold-open", "speaker": "maya"},
275            {"seq": 5, "type": "section_header", "section": "cold-open"},
276            {"seq": 7, "type": "dialogue", "section": "cold-open", "speaker": "adam"}
277        ]}));
278
279        let stale = find_stale_stems(d, &idx);
280        let got: Vec<(String, String)> = stale
281            .iter()
282            .map(|s| (basename(&s.path), s.reason.clone()))
283            .collect();
284        assert_eq!(
285            got,
286            vec![
287                (
288                    "002_cold-open_sfx.mp3".to_string(),
289                    "seq 2 is now a dialogue entry".to_string()
290                ),
291                (
292                    "003_cold-open_adam.mp3".into(),
293                    "seq 3 is now a direction entry".into()
294                ),
295                (
296                    "004_cold-open_adam.mp3".into(),
297                    "seq 4 speaker is now maya".into()
298                ),
299                (
300                    "005_cold-open_sfx.mp3".into(),
301                    "seq 5 is now a section_header entry".into()
302                ),
303                (
304                    "006_cold-open_adam.mp3".into(),
305                    "seq not in parsed JSON".into()
306                ),
307                (
308                    "007_act1_maya.mp3".into(),
309                    "seq 7 duplicate (expected 007_cold-open_adam)".into()
310                ),
311            ]
312        );
313    }
314
315    #[test]
316    fn a_clean_directory_yields_nothing() {
317        let tmp = tempfile::tempdir().unwrap();
318        touch(tmp.path(), "001_cold-open_adam.mp3");
319        let idx = index(
320            json!({"entries": [{"seq": 1, "type": "dialogue", "section": "cold-open", "speaker": "adam"}]}),
321        );
322        assert!(find_stale_stems(tmp.path(), &idx).is_empty());
323    }
324}