1use std::env;
8use std::ffi::OsString;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::LazyLock;
12
13use clap::Parser;
14use regex::Regex;
15use xil_core::fsutil::{abspath, basename, list_dir_raw, relpath};
16use xil_core::log;
17use xil_core::workspace::show_slug;
18
19static CAST_RE: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r"^cast_([a-z0-9]+)_([A-Z0-9]+)\.json$").unwrap());
21static SFX_RE: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"^sfx_([a-z0-9]+)_([A-Z0-9]+)\.json$").unwrap());
23static PARSED_RE: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"^parsed_([a-z0-9]+)_([A-Z0-9]+)\.json$").unwrap());
25static PARSED_CSV_RE: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"^parsed_([a-z0-9]+)_([A-Z0-9]+)\.csv$").unwrap());
27static ANNOTATED_RE: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r"^parsed_([a-z0-9]+)_([A-Z0-9]+)_annotated\.csv$").unwrap());
29static ORIG_RE: LazyLock<Regex> =
30 LazyLock::new(|| Regex::new(r"^orig_parsed_([a-z0-9]+)_([A-Z0-9]+)\.json$").unwrap());
31static PRE_SPLICE_RE: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"^pre_splice_parsed_([a-z0-9]+)_([A-Z0-9]+)\.json$").unwrap());
33static DAW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([A-Z0-9]+)$").unwrap());
34static MASTER_RE: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"^([a-z0-9]+)_([A-Z0-9]+)_master\.mp3$").unwrap());
36static CUES_MD_RE: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"^cues_([a-z0-9]+)_([A-Z0-9]+)\.md$").unwrap());
38static CUES_MANIFEST_RE: LazyLock<Regex> =
39 LazyLock::new(|| Regex::new(r"^cues_manifest_([A-Z0-9]+)\.json$").unwrap());
40
41#[derive(Parser)]
42#[command(
43 name = "xil-migrate-workspace",
44 about = "Migrate a pre-0.1.8 workspace to the normalized directory layout"
45)]
46struct Args {
47 #[arg(long, default_value = ".")]
49 workspace: PathBuf,
50 #[arg(long)]
52 dry_run: bool,
53}
54
55fn glob_raw(dir: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {
58 list_dir_raw(dir)
59 .into_iter()
60 .filter(|p| {
61 p.file_name()
62 .and_then(|n| n.to_str())
63 .map(|n| {
64 !n.starts_with('.')
65 && n.starts_with(prefix)
66 && n.ends_with(suffix)
67 && n.len() >= prefix.len() + suffix.len()
68 })
69 .unwrap_or(false)
70 })
71 .collect()
72}
73
74fn names_raw(dir: &Path) -> Vec<String> {
75 list_dir_raw(dir).iter().map(|p| basename(p)).collect()
76}
77
78fn infer_slug_from_project(workspace: &Path) -> Option<String> {
79 let text = fs::read_to_string(workspace.join("project.json")).ok()?;
80 let data: serde_json::Value = serde_json::from_str(&text).ok()?;
81 Some(show_slug(
82 data.get("show").and_then(|v| v.as_str()).unwrap_or(""),
83 ))
84}
85
86fn infer_slug_from_tag(workspace: &Path, tag: &str) -> Option<String> {
87 for slug_dir in list_dir_raw(&workspace.join("configs")) {
88 if slug_dir.join(format!("cast_{tag}.json")).exists()
89 && !basename(&slug_dir).starts_with('.')
90 {
91 return Some(basename(&slug_dir));
92 }
93 }
94 for p in glob_raw(workspace, "cast_", &format!("_{tag}.json")) {
95 if let Some(m) = CAST_RE.captures(&basename(&p)) {
96 return Some(m[1].to_string());
97 }
98 }
99 None
100}
101
102pub fn discover_moves(workspace: &Path) -> Vec<(PathBuf, PathBuf)> {
104 let mut moves: Vec<(PathBuf, PathBuf)> = Vec::new();
105 fn push_move(
106 moves: &mut Vec<(PathBuf, PathBuf)>,
107 workspace: &Path,
108 src_rel: String,
109 dst_rel: String,
110 ) {
111 let (src, dst) = (
112 abspath(&workspace.join(src_rel)),
113 abspath(&workspace.join(dst_rel)),
114 );
115 if src.exists() && src != dst {
116 moves.push((src, dst));
117 }
118 }
119
120 if workspace.join("speakers.json").exists() {
121 if let Some(slug) = infer_slug_from_project(workspace).filter(|s| !s.is_empty()) {
122 push_move(
123 &mut moves,
124 workspace,
125 "speakers.json".into(),
126 format!("configs/{slug}/speakers.json"),
127 );
128 }
129 }
130 for p in glob_raw(workspace, "cast_", ".json") {
131 if let Some(m) = CAST_RE.captures(&basename(&p)) {
132 push_move(
133 &mut moves,
134 workspace,
135 format!("cast_{}_{}.json", &m[1], &m[2]),
136 format!("configs/{}/cast_{}.json", &m[1], &m[2]),
137 );
138 }
139 }
140 for p in glob_raw(workspace, "sfx_", ".json") {
141 if let Some(m) = SFX_RE.captures(&basename(&p)) {
142 push_move(
143 &mut moves,
144 workspace,
145 format!("sfx_{}_{}.json", &m[1], &m[2]),
146 format!("configs/{}/sfx_{}.json", &m[1], &m[2]),
147 );
148 }
149 }
150
151 let parsed_dir = workspace.join("parsed");
152 if parsed_dir.is_dir() {
153 for fname in names_raw(&parsed_dir) {
154 if let Some(m) = PARSED_RE.captures(&fname) {
155 push_move(
156 &mut moves,
157 workspace,
158 format!("parsed/parsed_{}_{}.json", &m[1], &m[2]),
159 format!("parsed/{}/parsed_{}.json", &m[1], &m[2]),
160 );
161 } else if let Some(m) = PARSED_CSV_RE.captures(&fname) {
162 push_move(
163 &mut moves,
164 workspace,
165 format!("parsed/parsed_{}_{}.csv", &m[1], &m[2]),
166 format!("parsed/{}/parsed_{}.csv", &m[1], &m[2]),
167 );
168 } else if let Some(m) = ANNOTATED_RE.captures(&fname) {
169 push_move(
170 &mut moves,
171 workspace,
172 format!("parsed/parsed_{}_{}_annotated.csv", &m[1], &m[2]),
173 format!("parsed/{}/annotated_{}.csv", &m[1], &m[2]),
174 );
175 } else if let Some(m) = ORIG_RE.captures(&fname) {
176 push_move(
177 &mut moves,
178 workspace,
179 format!("parsed/orig_parsed_{}_{}.json", &m[1], &m[2]),
180 format!("parsed/{}/orig_parsed_{}.json", &m[1], &m[2]),
181 );
182 } else if let Some(m) = PRE_SPLICE_RE.captures(&fname) {
183 push_move(
184 &mut moves,
185 workspace,
186 format!("parsed/pre_splice_parsed_{}_{}.json", &m[1], &m[2]),
187 format!("parsed/{}/pre_splice_parsed_{}.json", &m[1], &m[2]),
188 );
189 }
190 }
191 }
192
193 let daw_dir = workspace.join("daw");
196 if daw_dir.is_dir() {
197 for entry in names_raw(&daw_dir) {
198 let entry_path = daw_dir.join(&entry);
199 if !entry_path.is_dir() || !DAW_RE.is_match(&entry) {
200 continue;
201 }
202 if let Some(slug) = infer_slug_from_tag(workspace, &entry) {
203 let dst_dir = daw_dir.join(&slug).join(&entry);
204 if !dst_dir.exists() {
205 moves.push((entry_path, dst_dir));
206 }
207 }
208 }
209 }
210
211 let masters_dir = workspace.join("masters");
212 for (search_dir, is_root) in [(workspace.to_path_buf(), true), (masters_dir, false)] {
213 if !search_dir.is_dir() && !is_root {
214 continue;
215 }
216 for p in glob_raw(&search_dir, "", "_master.mp3") {
217 if let Some(m) = MASTER_RE.captures(&basename(&p)) {
218 let prefix = if is_root { "" } else { "masters/" };
219 push_move(
220 &mut moves,
221 workspace,
222 format!("{prefix}{}_{}_master.mp3", &m[1], &m[2]),
223 format!("masters/{}/{}_master.mp3", &m[1], &m[2]),
224 );
225 }
226 }
227 }
228
229 let cues_dir = workspace.join("cues");
230 if cues_dir.is_dir() {
231 for fname in names_raw(&cues_dir) {
232 if let Some(m) = CUES_MD_RE.captures(&fname) {
233 push_move(
234 &mut moves,
235 workspace,
236 format!("cues/cues_{}_{}.md", &m[1], &m[2]),
237 format!("cues/{}/cues_{}.md", &m[1], &m[2]),
238 );
239 } else if let Some(m) = CUES_MANIFEST_RE.captures(&fname) {
240 let tag = m[1].to_string();
241 if let Some(slug) = infer_slug_from_tag(workspace, &tag) {
242 push_move(
243 &mut moves,
244 workspace,
245 format!("cues/cues_manifest_{tag}.json"),
246 format!("cues/{slug}/cues_manifest_{tag}.json"),
247 );
248 }
249 }
250 }
251 }
252 moves
253}
254
255fn rel(p: &Path) -> String {
257 let cwd = env::current_dir().unwrap_or_default();
258 relpath(p, &cwd).display().to_string()
259}
260
261fn execute_moves(moves: &[(PathBuf, PathBuf)], dry_run: bool) -> anyhow::Result<(usize, usize)> {
262 let (mut moved, mut skipped) = (0, 0);
263 for (src, dst) in moves {
264 if dst.exists() {
265 log::info(&format!(" SKIP {} (already exists at target)", rel(dst)));
266 skipped += 1;
267 continue;
268 }
269 if dry_run {
270 log::info(&format!(" MOVE {} → {}", rel(src), rel(dst)));
271 } else {
272 if let Some(d) = dst.parent() {
273 fs::create_dir_all(d)?;
274 }
275 fs::rename(src, dst)?;
276 log::info(&format!(" MOVED {} → {}", rel(src), rel(dst)));
277 }
278 moved += 1;
279 }
280 Ok((moved, skipped))
281}
282
283pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
284 log::init("migrate-workspace");
285 let a: Args = match super::parse_or_exit("xil-migrate-workspace", args) {
286 Ok(a) => a,
287 Err(code) => return Ok(code),
288 };
289 let moves = discover_moves(&a.workspace);
290 if moves.is_empty() {
291 log::info("Nothing to migrate — workspace already uses the normalized layout.");
292 return Ok(0);
293 }
294 let mode = if a.dry_run { "DRY RUN — " } else { "" };
295 log::info(&format!(
296 "\n{mode}Workspace migration plan ({} moves):\n",
297 moves.len()
298 ));
299 let (moved, skipped) = execute_moves(&moves, a.dry_run)?;
300 if a.dry_run {
301 log::info(&format!(
302 "\n{moved} file(s) would be moved, {skipped} already at target."
303 ));
304 log::info("Run without --dry-run to execute.");
305 } else {
306 log::info(&format!("\n{moved} file(s) moved, {skipped} skipped."));
307 log::info("Migration complete. Run 'xil migrate-workspace --dry-run' to verify.");
308 }
309 Ok(0)
310}
311
312pub fn command() -> clap::Command {
314 <Args as clap::CommandFactory>::command()
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn discovers_every_legacy_shape() {
323 let tmp = tempfile::tempdir().unwrap();
324 let w = tmp.path();
325 for d in ["parsed", "daw/S01E01", "masters", "cues", "configs/old"] {
326 fs::create_dir_all(w.join(d)).unwrap();
327 }
328 fs::write(w.join("project.json"), r#"{"show": "Old"}"#).unwrap();
329 for f in [
330 "speakers.json",
331 "cast_old_S01E01.json",
332 "sfx_old_S01E01.json",
333 "parsed/parsed_old_S01E01.json",
334 "parsed/parsed_old_S01E01.csv",
335 "parsed/parsed_old_S01E01_annotated.csv",
336 "parsed/orig_parsed_old_S01E01.json",
337 "parsed/pre_splice_parsed_old_S01E01.json",
338 "old_S01E01_master.mp3",
339 "masters/old_S01E02_master.mp3",
340 "cues/cues_old_S01E01.md",
341 "cues/cues_manifest_S01E01.json",
342 "configs/old/cast_S01E02.json",
343 ] {
344 fs::write(w.join(f), "x").unwrap();
345 }
346 let moves = discover_moves(w);
347 let mut dsts: Vec<String> = moves.iter().map(|(_, d)| rel_to(d, w)).collect();
348 dsts.sort();
349 assert_eq!(
350 dsts,
351 vec![
352 "configs/old/cast_S01E01.json",
353 "configs/old/sfx_S01E01.json",
354 "configs/old/speakers.json",
355 "cues/old/cues_S01E01.md",
356 "cues/old/cues_manifest_S01E01.json",
357 "daw/old/S01E01",
358 "masters/old/S01E01_master.mp3",
359 "masters/old/S01E02_master.mp3",
360 "parsed/old/annotated_S01E01.csv",
361 "parsed/old/orig_parsed_S01E01.json",
362 "parsed/old/parsed_S01E01.csv",
363 "parsed/old/parsed_S01E01.json",
364 "parsed/old/pre_splice_parsed_S01E01.json",
365 ]
366 );
367 }
368
369 fn rel_to(p: &Path, root: &Path) -> String {
372 relpath(p, root).display().to_string().replace('\\', "/")
373 }
374
375 #[test]
376 fn nothing_to_move_in_normalized_workspace() {
377 let tmp = tempfile::tempdir().unwrap();
378 fs::create_dir_all(tmp.path().join("parsed/show")).unwrap();
379 fs::write(tmp.path().join("parsed/show/parsed_S01E01.json"), "{}").unwrap();
380 assert!(discover_moves(tmp.path()).is_empty());
381 }
382}