1use 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 #[arg(long, value_name = "TAG")]
25 episode: Option<String>,
26 #[arg(long, value_name = "TAG")]
28 tag: Option<String>,
29 #[arg(long)]
31 show: Option<String>,
32 #[arg(long, value_name = "PATH")]
34 parsed: Option<PathBuf>,
35 #[arg(long, value_name = "DIR")]
37 stems: Option<PathBuf>,
38 #[arg(long)]
40 dry_run: bool,
41}
42
43const USAGE: &str = "usage: xil-cleanup [-h] [--episode TAG] [--tag TAG] [--show SHOW]\n\
47 \x20 [--parsed PATH] [--stems DIR] [--dry-run]";
48
49fn parser_error(msg: &str) -> i32 {
51 eprintln!("{USAGE}");
52 eprintln!("xil-cleanup: error: {msg}");
53 2
54}
55
56pub struct Stale {
58 pub path: PathBuf,
59 pub reason: String,
60}
61
62pub 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 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
143fn 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
152fn 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
236pub 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"); touch(d, "002_cold-open_sfx.mp3"); touch(d, "003_cold-open_adam.mp3"); touch(d, "004_cold-open_adam.mp3"); touch(d, "005_cold-open_sfx.mp3"); touch(d, "006_cold-open_adam.mp3"); touch(d, "007_cold-open_adam.mp3"); touch(d, "007_act1_maya.mp3");
268 touch(d, "notaseq.mp3"); 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}