Skip to main content

xil/cmd/
remove_episode.rs

1//! `xil remove-episode` — delete one episode's workspace files. Port of
2//! `XILU018_remove_episode.py`.
3
4use std::collections::HashSet;
5use std::ffi::OsString;
6use std::path::Path;
7
8use clap::Parser;
9use xil_core::log;
10use xil_core::workspace::{resolve_slug, workspace_root};
11
12use super::removal::{delete, fmt_bytes, input, rel_display, Item, Kind};
13
14#[derive(Parser)]
15#[command(
16    name = "xil-remove-episode",
17    about = "Remove all workspace files for a single episode. The source production script, masters/, and the \
18             sfx edit journal are never touched. Shared assets (SFX/, logs/) are never touched.",
19    after_help = "Examples:\n  xil remove-episode S01E01 --dry-run\n  xil remove-episode S01E01 --yes\n  \
20                  xil remove-episode S01E01 --show \"Night Owls\" --dry-run\n"
21)]
22struct Args {
23    /// Episode tag to remove (e.g. S01E01)
24    #[arg(value_name = "TAG")]
25    episode: String,
26    /// Show name or slug (default: resolved from project.json / XIL_PROJECTROOT)
27    #[arg(long, short = 's', value_name = "SHOW")]
28    show: Option<String>,
29    /// Show what would be removed without deleting anything
30    #[arg(long, short = 'n')]
31    dry_run: bool,
32    /// Skip the confirmation prompt
33    #[arg(long, short = 'y')]
34    yes: bool,
35}
36
37pub fn collect(root: &Path, slug: &str, tag: &str) -> Vec<Item> {
38    let mut items = Vec::new();
39    let mut file = |p: std::path::PathBuf, label: &'static str| items.push(Item::file(p, label));
40
41    let cfg = root.join("configs").join(slug);
42    file(cfg.join(format!("cast_{tag}.json")), "");
43    file(cfg.join(format!("sfx_{tag}.json")), "");
44
45    let psd = root.join("parsed").join(slug);
46    for prefix in ["parsed", "orig_parsed", "pre_splice_parsed", "stem_verify"] {
47        file(psd.join(format!("{prefix}_{tag}.json")), "");
48    }
49    file(psd.join(format!("parsed_{tag}.csv")), "");
50    file(psd.join(format!("annotated_{tag}.csv")), "");
51
52    let cues = root.join("cues").join(slug);
53    file(cues.join(format!("cues_{tag}.md")), "");
54    file(cues.join(format!("cues_manifest_{tag}.json")), "");
55
56    // Directories are only listed when they exist.
57    let mut dir = |p: std::path::PathBuf, label: &'static str| {
58        if p.exists() {
59            items.push(Item::dir(p, label));
60        }
61    };
62    dir(root.join("stems").join(slug).join(tag), "");
63    dir(root.join("daw").join(slug).join(tag), "");
64
65    items.push(Item::file(
66        root.join("posts")
67            .join(slug)
68            .join(format!("{tag}_posts.md")),
69        "",
70    ));
71
72    let vs = root.join("voice_samples").join(tag);
73    if vs.exists() {
74        items.push(Item::dir(vs, ""));
75    }
76
77    items.push(Item::file(
78        root.join(format!("cast_{slug}_{tag}.json")),
79        "legacy root",
80    ));
81    items.push(Item::file(
82        root.join(format!("sfx_{slug}_{tag}.json")),
83        "legacy root",
84    ));
85    let legacy_psd = root.join("parsed");
86    for prefix in ["parsed", "orig_parsed", "pre_splice_parsed", "annotated"] {
87        items.push(Item::file(
88            legacy_psd.join(format!("{prefix}_{slug}_{tag}.json")),
89            "legacy parsed",
90        ));
91    }
92    items.push(Item::file(
93        legacy_psd.join(format!("parsed_{slug}_{tag}.csv")),
94        "legacy parsed",
95    ));
96
97    let legacy_daw = root.join("daw").join(tag);
98    if legacy_daw.exists() && legacy_daw != root.join("daw").join(slug).join(tag) {
99        items.push(Item::dir(legacy_daw, "legacy daw"));
100    }
101    items.push(Item::file(
102        root.join(format!("{slug}_{tag}_master.mp3")),
103        "legacy master",
104    ));
105
106    let mut seen = HashSet::new();
107    items
108        .into_iter()
109        .filter(|i| seen.insert(i.path.clone()))
110        .collect()
111}
112
113fn report(root: &Path, items: &[Item], slug: &str, tag: &str, dry_run: bool) -> (u64, u64) {
114    let present: Vec<&Item> = items.iter().filter(|i| i.path.exists()).collect();
115    let total_files: u64 = present.iter().map(|i| i.file_count()).sum();
116    let total_bytes: u64 = present.iter().map(|i| i.total_bytes()).sum();
117    if present.is_empty() {
118        log::info(&format!(
119            "Nothing found for episode '{tag}' (show '{slug}') — workspace is already clean."
120        ));
121        return (0, 0);
122    }
123    let action = if dry_run { "Would remove" } else { "Removing" };
124    log::info(&format!("{action} episode '{tag}' (show '{slug}'):"));
125    log::info("");
126    for item in &present {
127        let fc = item.file_count();
128        let rel = rel_display(&item.path, root);
129        let size = fmt_bytes(item.total_bytes());
130        let tag_str = if item.label.is_empty() {
131            String::new()
132        } else {
133            format!("  ({})", item.label)
134        };
135        match item.kind {
136            Kind::Dir => log::info(&format!("  [DIR]  {rel}/  — {fc} file(s), {size}{tag_str}")),
137            Kind::File => log::info(&format!("  [FILE] {rel}  — {size}{tag_str}")),
138        }
139    }
140    log::info("");
141    log::info(&format!(
142        "Total: {total_files} file(s), {}",
143        fmt_bytes(total_bytes)
144    ));
145    (total_files, total_bytes)
146}
147
148pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
149    log::init("remove-episode");
150    let a: Args = match super::parse_or_exit("xil-remove-episode", args) {
151        Ok(a) => a,
152        Err(code) => return Ok(code),
153    };
154    let root = workspace_root();
155    let slug = resolve_slug(a.show.as_deref(), "project.json");
156    let tag = a.episode.clone();
157    let items = collect(&root, &slug, &tag);
158    let (total_files, total_bytes) = report(&root, &items, &slug, &tag, a.dry_run);
159
160    if a.dry_run {
161        if total_files > 0 {
162            log::info("");
163            log::info("Dry run — nothing deleted. Run without --dry-run to remove.");
164        }
165        return Ok(0);
166    }
167    if total_files == 0 {
168        return Ok(0);
169    }
170    if !a.yes {
171        log::info("");
172        let confirm = input(&format!(
173            "⚠️  This will permanently delete {total_files} file(s) ({}) for episode \"{tag}\" (show \"{slug}\").\n    \
174             Type \"{tag}\" to confirm (or Ctrl-C to abort): ",
175            fmt_bytes(total_bytes)
176        ));
177        if confirm != tag {
178            log::info("Aborted — input did not match. Nothing deleted.");
179            return Ok(1);
180        }
181    }
182    log::info("");
183    let removed = delete(&items)?;
184    log::info("");
185    log::info(&format!(
186        "✓ Removed {removed} file(s) for episode '{tag}' (show '{slug}')."
187    ));
188    Ok(0)
189}
190
191/// The clap definition behind `--help`, for man pages.
192pub fn command() -> clap::Command {
193    <Args as clap::CommandFactory>::command()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// Relative path with forward slashes, so these expectations read the
201    /// same on Windows as on the platforms that run the pipeline.
202    fn slashed(p: &Path, root: &Path) -> String {
203        rel_display(p, root).replace('\\', "/")
204    }
205    use std::fs;
206
207    #[test]
208    fn collect_lists_every_candidate_once_and_only_existing_dirs() {
209        let tmp = tempfile::tempdir().unwrap();
210        let r = tmp.path();
211        fs::create_dir_all(r.join("stems/s/S01E01")).unwrap();
212        fs::create_dir_all(r.join("daw/S01E01")).unwrap();
213        let items = collect(r, "s", "S01E01");
214        let rels: Vec<String> = items.iter().map(|i| slashed(&i.path, r)).collect();
215        assert!(rels.contains(&"configs/s/cast_S01E01.json".to_string()));
216        assert!(rels.contains(&"stems/s/S01E01".to_string()));
217        assert!(
218            !rels.contains(&"daw/s/S01E01".to_string()),
219            "absent dirs are not listed"
220        );
221        assert!(
222            rels.contains(&"daw/S01E01".to_string()),
223            "legacy flat daw dir is listed"
224        );
225        assert_eq!(
226            items
227                .iter()
228                .find(|i| slashed(&i.path, r) == "daw/S01E01")
229                .unwrap()
230                .label,
231            "legacy daw"
232        );
233        let unique: HashSet<_> = rels.iter().collect();
234        assert_eq!(unique.len(), rels.len());
235        assert_eq!(items.iter().filter(|i| i.path.exists()).count(), 2);
236    }
237}