1use 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 #[arg(value_name = "SHOW")]
25 show: String,
26 #[arg(long, short = 'n')]
28 dry_run: bool,
29 #[arg(long, short = 'y')]
31 yes: bool,
32 #[arg(long)]
34 include_scripts: bool,
35}
36
37pub 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
122fn 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
222pub fn command() -> clap::Command {
224 <Args as clap::CommandFactory>::command()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 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}