1use 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 #[arg(value_name = "TAG")]
25 episode: String,
26 #[arg(long, short = 's', value_name = "SHOW")]
28 show: Option<String>,
29 #[arg(long, short = 'n')]
31 dry_run: bool,
32 #[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 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
191pub fn command() -> clap::Command {
193 <Args as clap::CommandFactory>::command()
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 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}