Skip to main content

xil/cmd/
migrate_workspace.rs

1//! `xil migrate-workspace` — move a pre-0.1.8 flat workspace to the
2//! normalized layout. Port of `XILU009_migrate_workspace.py`.
3//!
4//! The Python calls `run_banner(...)` without entering it, so no banner is
5//! ever printed. Faithfully reproduced: no banner here either.
6
7use 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    /// Workspace root directory (default: current directory)
48    #[arg(long, default_value = ".")]
49    workspace: PathBuf,
50    /// Preview moves without touching files (default: enabled for safety)
51    #[arg(long)]
52    dry_run: bool,
53}
54
55/// `glob.glob(os.path.join(dir, f"{prefix}*{suffix}"))` — raw directory
56/// order, dotfiles excluded.
57fn 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
102/// `(src, dst)` pairs for every legacy file found, as absolute paths.
103pub 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    // Directory moves bypass push_move: the Python appends them with no
194    // exists/equality check of its own.
195    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
255/// `os.path.relpath(path)` — relative to the current directory.
256fn 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
312/// The clap definition behind `--help`, for man pages.
313pub 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    /// Relative path with forward slashes, so the expectations below read
370    /// the same on Windows as they do on the platforms that run the pipeline.
371    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}