Skip to main content

xil/cmd/
remove_show.rs

1//! `xil remove-show` — delete every workspace file for a show. Port of
2//! `XILU017_remove_show.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::Path;
7
8use clap::Parser;
9use xil_core::fsutil::glob_children;
10use xil_core::log;
11use xil_core::workspace::{show_slug, workspace_root};
12
13use super::removal::{delete, fmt_bytes, input, rel_display, Item, Kind};
14
15#[derive(Parser)]
16#[command(
17    name = "xil-remove-show",
18    about = "Remove all workspace files for a given show. Shared assets (SFX/, logs/) are never touched.",
19    after_help = "Examples:\n  xil remove-show mypodcast --dry-run\n  xil remove-show mypodcast --yes\n  \
20                  xil remove-show \"My Podcast\" --dry-run\n  xil remove-show mypodcast --include-scripts --yes\n"
21)]
22struct Args {
23    /// Show name or slug to remove (e.g. mypodcast or 'My Podcast')
24    #[arg(value_name = "SHOW")]
25    show: String,
26    /// Show what would be removed without deleting anything
27    #[arg(long, short = 'n')]
28    dry_run: bool,
29    /// Skip the confirmation prompt
30    #[arg(long, short = 'y')]
31    yes: bool,
32    /// Also remove scripts/*_{slug}_*.md files whose filename contains the show slug (caution: source material)
33    #[arg(long)]
34    include_scripts: bool,
35}
36
37/// Slug for a show name or slug: direct hit on `configs/<slug>`, else a
38/// `project.json` whose show name slugifies to it, else the slug as given.
39pub fn resolve_slug(root: &Path, name_or_slug: &str) -> String {
40    let candidate = show_slug(name_or_slug);
41    if root
42        .join("configs")
43        .join(&candidate)
44        .join("project.json")
45        .exists()
46        || root.join("configs").join(&candidate).is_dir()
47    {
48        return candidate;
49    }
50    if let Ok(rd) = fs::read_dir(root.join("configs")) {
51        let mut dirs: Vec<_> = rd.filter_map(Result::ok).map(|e| e.path()).collect();
52        dirs.sort();
53        for d in dirs {
54            let pj = d.join("project.json");
55            if !pj.exists() {
56                continue;
57            }
58            let name = fs::read_to_string(&pj)
59                .ok()
60                .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
61                .and_then(|v| v.get("show").and_then(|s| s.as_str().map(str::to_string)))
62                .unwrap_or_default();
63            if show_slug(&name) == candidate {
64                return d
65                    .file_name()
66                    .map(|n| n.to_string_lossy().into_owned())
67                    .unwrap_or(candidate);
68            }
69        }
70    }
71    candidate
72}
73
74pub fn collect(root: &Path, slug: &str, include_scripts: bool) -> Vec<Item> {
75    let mut items = Vec::new();
76    for category in [
77        "configs", "parsed", "stems", "daw", "masters", "cues", "posts",
78    ] {
79        let d = root.join(category).join(slug);
80        if d.exists() {
81            items.push(Item::dir(d, ""));
82        }
83    }
84    for prefix in [format!("cast_{slug}_"), format!("sfx_{slug}_")] {
85        for p in glob_children(root, &prefix, ".json") {
86            items.push(Item::file(p, "legacy root"));
87        }
88    }
89    let parsed_dir = root.join("parsed");
90    if parsed_dir.is_dir() {
91        for prefix in ["parsed", "annotated", "pre_splice_parsed", "orig_parsed"] {
92            for p in glob_children(&parsed_dir, &format!("{prefix}_{slug}_"), ".json") {
93                items.push(Item::file(p, "legacy parsed"));
94            }
95        }
96    }
97    if include_scripts {
98        let scripts = root.join("scripts");
99        if scripts.is_dir() {
100            for p in glob_children(&scripts, "", ".md") {
101                let name = p
102                    .file_name()
103                    .map(|n| n.to_string_lossy().into_owned())
104                    .unwrap_or_default();
105                if name.contains(&format!("_{slug}_")) || name.ends_with(&format!("_{slug}.md")) {
106                    items.push(Item::file(p, "script"));
107                }
108            }
109        }
110    }
111    let active = root.join(".active_show");
112    if active.exists() {
113        if let Ok(cur) = fs::read_to_string(&active) {
114            if cur.trim() == slug {
115                items.push(Item::file(active, ".active_show"));
116            }
117        }
118    }
119    items
120}
121
122/// Print the plan; return (total_files, total_bytes).
123fn report(root: &Path, items: &[Item], slug: &str, dry_run: bool) -> (u64, u64) {
124    let total_files: u64 = items.iter().map(Item::file_count).sum();
125    let total_bytes: u64 = items.iter().map(Item::total_bytes).sum();
126    if items.is_empty() {
127        log::info(&format!(
128            "Nothing found for show '{slug}' — workspace is already clean."
129        ));
130        return (0, 0);
131    }
132    let action = if dry_run { "Would remove" } else { "Removing" };
133    log::info(&format!("{action} show '{slug}':"));
134    log::info("");
135    for item in items {
136        let fc = item.file_count();
137        if fc == 0 {
138            continue;
139        }
140        let rel = rel_display(&item.path, root);
141        let size = fmt_bytes(item.total_bytes());
142        let tag = if item.label.is_empty() {
143            String::new()
144        } else {
145            format!("  ({})", item.label)
146        };
147        match item.kind {
148            Kind::Dir => log::info(&format!("  [DIR]  {rel}/  — {fc} file(s), {size}{tag}")),
149            Kind::File => log::info(&format!("  [FILE] {rel}  — {size}{tag}")),
150        }
151    }
152    let empty: Vec<_> = items
153        .iter()
154        .filter(|i| i.file_count() == 0 && i.path.exists())
155        .collect();
156    if !empty.is_empty() {
157        log::info("");
158        for item in empty {
159            let rel = rel_display(&item.path, root);
160            let kind = if item.kind == Kind::Dir {
161                "[DIR] "
162            } else {
163                "[FILE]"
164            };
165            log::info(&format!("  {kind} {rel}  — empty"));
166        }
167    }
168    log::info("");
169    let suffix = if total_files > 0 {
170        " across all matched items"
171    } else {
172        ""
173    };
174    log::info(&format!(
175        "Total: {total_files} file(s), {}{suffix}",
176        fmt_bytes(total_bytes)
177    ));
178    (total_files, total_bytes)
179}
180
181pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
182    log::init("remove-show");
183    let a: Args = match super::parse_or_exit("xil-remove-show", args) {
184        Ok(a) => a,
185        Err(code) => return Ok(code),
186    };
187    let root = workspace_root();
188    let slug = resolve_slug(&root, &a.show);
189    let items = collect(&root, &slug, a.include_scripts);
190    let (total_files, total_bytes) = report(&root, &items, &slug, a.dry_run);
191    let any_exists = items.iter().any(|i| i.path.exists());
192
193    if a.dry_run {
194        if total_files > 0 || any_exists {
195            log::info("");
196            log::info("Dry run — nothing deleted. Run without --dry-run to remove.");
197        }
198        return Ok(0);
199    }
200    if total_files == 0 && !any_exists {
201        return Ok(0);
202    }
203    if !a.yes {
204        log::info("");
205        let confirm = input(&format!(
206            "⚠️  This will permanently delete {total_files} file(s) ({}) for show \"{slug}\".\n    \
207             Type \"{slug}\" to confirm (or Ctrl-C to abort): ",
208            fmt_bytes(total_bytes)
209        ));
210        if confirm != slug {
211            log::info("Aborted — input did not match. Nothing deleted.");
212            return Ok(1);
213        }
214    }
215    log::info("");
216    let removed = delete(&items)?;
217    log::info("");
218    log::info(&format!("✓ Removed {removed} file(s) for show '{slug}'."));
219    Ok(0)
220}
221
222/// The clap definition behind `--help`, for man pages.
223pub fn command() -> clap::Command {
224    <Args as clap::CommandFactory>::command()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    /// Relative path with forward slashes, so these expectations read the
232    /// same on Windows as on the platforms that run the pipeline.
233    fn slashed(p: &Path, root: &Path) -> String {
234        rel_display(p, root).replace('\\', "/")
235    }
236
237    fn show(root: &Path, slug: &str, name: &str) {
238        let d = root.join("configs").join(slug);
239        fs::create_dir_all(&d).unwrap();
240        fs::write(d.join("project.json"), format!(r#"{{"show": "{name}"}}"#)).unwrap();
241    }
242
243    #[test]
244    fn resolve_by_slug_name_or_fallback() {
245        let tmp = tempfile::tempdir().unwrap();
246        let r = tmp.path();
247        show(r, "the413", "THE 413");
248        show(r, "odd", "Night Owls");
249        assert_eq!(resolve_slug(r, "the413"), "the413");
250        assert_eq!(resolve_slug(r, "THE 413"), "the413");
251        assert_eq!(resolve_slug(r, "Night Owls"), "odd");
252        assert_eq!(resolve_slug(r, "No Such Show"), "nosuchshow");
253    }
254
255    #[test]
256    fn collect_finds_dirs_legacy_files_scripts_and_active() {
257        let tmp = tempfile::tempdir().unwrap();
258        let r = tmp.path();
259        show(r, "s", "S");
260        fs::create_dir_all(r.join("parsed/s")).unwrap();
261        fs::create_dir_all(r.join("scripts")).unwrap();
262        fs::write(r.join("cast_s_S01E01.json"), "{}").unwrap();
263        fs::write(r.join("parsed/orig_parsed_s_S01E01.json"), "{}").unwrap();
264        fs::write(r.join("scripts/ep_s_S01E01.md"), "#").unwrap();
265        fs::write(r.join("scripts/other.md"), "#").unwrap();
266        fs::write(r.join(".active_show"), "s").unwrap();
267        let labels: Vec<(String, &str)> = collect(r, "s", true)
268            .iter()
269            .map(|i| (slashed(&i.path, r), i.label))
270            .collect();
271        assert_eq!(
272            labels,
273            vec![
274                ("configs/s".to_string(), ""),
275                ("parsed/s".to_string(), ""),
276                ("cast_s_S01E01.json".to_string(), "legacy root"),
277                (
278                    "parsed/orig_parsed_s_S01E01.json".to_string(),
279                    "legacy parsed"
280                ),
281                ("scripts/ep_s_S01E01.md".to_string(), "script"),
282                (".active_show".to_string(), ".active_show"),
283            ]
284        );
285        assert_eq!(collect(r, "s", false).len(), 5);
286    }
287}