Skip to main content

xil/
commands.rs

1//! Subcommand registry. Mirrors `XIL_SCRIPT_COMMANDS` in the Python
2//! `xil.py`: same names, same groups, same order, same help text.
3//!
4//! Every command is implemented in Rust. The Python module names stay only
5//! for the `XILP000`/`XILU001` tags that `--help` prints.
6
7use std::ffi::OsString;
8
9/// Which help block a command is listed under.
10#[derive(Clone, Copy, PartialEq, Eq)]
11pub enum Group {
12    Pipeline,
13    Utility,
14}
15
16/// Signature every command entry point shares.
17///
18/// `args` excludes the command name itself, exactly like the `sys.argv[1:]`
19/// a Python `main()` sees after the dispatcher swaps argv.
20pub type NativeFn = fn(args: &[OsString]) -> anyhow::Result<i32>;
21
22pub struct CommandSpec {
23    pub name: &'static str,
24    /// The Python module the command was ported from, for the XILP/XILU tag
25    /// in `--help`.
26    pub py_module: &'static str,
27    pub description: &'static str,
28    pub group: Group,
29    pub hint: &'static str,
30    pub run: NativeFn,
31    /// The command's clap definition, for man pages.
32    pub command: fn() -> clap::Command,
33}
34
35macro_rules! cmd {
36    ($module:ident, $name:literal, $py:literal, $desc:literal, $group:ident) => {
37        cmd!($module, $name, $py, $desc, $group, "")
38    };
39    ($module:ident, $name:literal, $py:literal, $desc:literal, $group:ident, $hint:literal) => {
40        CommandSpec {
41            name: $name,
42            py_module: $py,
43            description: $desc,
44            group: Group::$group,
45            hint: $hint,
46            run: crate::cmd::$module::run,
47            command: crate::cmd::$module::command,
48        }
49    };
50}
51
52/// Insertion order defines display order within each group.
53#[rustfmt::skip] // one command per line reads as a table; keep it that way
54pub const COMMANDS: &[CommandSpec] = &[
55    cmd!(init, "init", "xil_pipeline.xil_init", "workspace scaffolding", Pipeline),
56    cmd!(use_cmd, "use", "xil_pipeline.xil_use", "set / show the active show context", Utility, "(multi-show workspaces)"),
57    cmd!(scan, "scan", "xil_pipeline.XILP000_script_scanner", "pre-flight script scanner", Pipeline),
58    cmd!(parse, "parse", "xil_pipeline.XILP001_script_parser", "script parser", Pipeline),
59    cmd!(cues, "cues", "xil_pipeline.XILP006_cues_ingester", "cues sheet ingestion", Pipeline),
60    cmd!(produce, "produce", "xil_pipeline.XILP002_producer", "voice stem generation", Pipeline),
61    cmd!(assemble, "assemble", "xil_pipeline.XILP003_audio_assembly", "master audio assembly", Pipeline),
62    cmd!(studio_onboard, "studio-onboard", "xil_pipeline.XILP004_studio_onboard", "ElevenLabs Studio project onboarding", Pipeline),
63    cmd!(daw, "daw", "xil_pipeline.XILP005_daw_export", "DAW layer export", Pipeline),
64    cmd!(migrate, "migrate", "xil_pipeline.XILP007_stem_migrator", "stem migration", Pipeline),
65    cmd!(cleanup, "cleanup", "xil_pipeline.XILP008_stale_stem_cleanup", "stale stem cleanup", Pipeline),
66    cmd!(import, "import", "xil_pipeline.XILP010_studio_import", "Studio export import", Pipeline),
67    cmd!(regen, "regen", "xil_pipeline.XILP009_script_regenerator", "script regeneration", Pipeline),
68    cmd!(master, "master", "xil_pipeline.XILP011_master_export", "final master MP3 export", Pipeline),
69    cmd!(publish, "publish", "xil_pipeline.XILP012_publish", "social media post draft generator", Pipeline),
70    cmd!(voices, "voices", "xil_pipeline.XILU001_discover_voices_T2S", "voice discovery", Utility, "(before parse/produce)"),
71    cmd!(csv_join, "csv-join", "xil_pipeline.XILU003_csv_sfx_join", "CSV + SFX/cast annotation join", Utility, "(after parse)"),
72    cmd!(sfx, "sfx", "xil_pipeline.XILU002_generate_SFX", "standalone SFX generation", Utility, "(after cues/parse)"),
73    cmd!(sample, "sample", "xil_pipeline.XILU004_sample_voices_T2S", "voice sample generation", Utility, "(after voices/cast config)"),
74    cmd!(sfx_lib, "sfx-lib", "xil_pipeline.XILU005_discover_SFX", "SFX library discovery", Utility, "(any time)"),
75    cmd!(splice, "splice", "xil_pipeline.XILU006_splice_parsed", "parsed JSON splice utility", Utility, "(advanced)"),
76    cmd!(mp3_hash, "mp3-hash", "xil_pipeline.XILU007_mp3_hash", "recursive MP3 SHA-256 hash log", Utility, "(integrity / audit)"),
77    cmd!(stem_log, "stem-log", "xil_pipeline.XILU008_stem_log_report", "parse daily logs → chronological stem generation CSV", Utility, "(integrity / audit)"),
78    cmd!(gui, "gui", "xil_pipeline.xil_gui", "web dashboard (requires [gui] extra)", Utility, "(pip install xil-pipeline[gui])"),
79    cmd!(migrate_workspace, "migrate-workspace", "xil_pipeline.XILU009_migrate_workspace", "migrate pre-0.1.8 workspace to normalized layout", Utility, "(run once per workspace)"),
80    cmd!(db_profile, "db-profile", "xil_pipeline.XILU010_db_profile", "profile MP3 loudness: peak, average, and minimum dBFS", Utility, "(audio level analysis)"),
81    cmd!(sfx_csv, "sfx-csv", "xil_pipeline.XILU011_sfx_csv", "flatten sfx_<tag>.json configs to CSV — one row per effect", Utility, "(debug / audit)"),
82    cmd!(parsed_csv, "parsed-csv", "xil_pipeline.XILU012_parsed_csv", "export parsed_<tag>.json entries to CSV — one row per entry", Utility, "(debug / audit)"),
83    cmd!(sfx_hydrate, "sfx-hydrate", "xil_pipeline.XILU013_sfx_hydrate", "write pipe-hint source fields from parsed JSON into the SFX config", Utility, "(after parse, before produce)"),
84    cmd!(sfx_restore, "sfx-restore", "xil_pipeline.XILU020_sfx_restore", "reapply journaled timeline sound edits onto the SFX config", Utility, "(recover timeline sound edits)"),
85    cmd!(sfx_impact, "sfx-impact", "xil_pipeline.XILU021_sfx_impact", "report which source-backed cues duration_seconds is clipping short", Utility, "(before changing clip durations)"),
86    cmd!(sfx_match, "sfx-match", "xil_pipeline.XILU022_sfx_match", "find existing library assets for cues whose source file is missing", Utility, "(when produce reports missing SFX sources)"),
87    cmd!(episode_summary, "episode-summary", "xil_pipeline.XILU014_episode_summary", "write one-row-per-episode summary CSV (lines, words, TTS chars)", Utility, "(any time)"),
88    cmd!(stem_verify, "stem-verify", "xil_pipeline.XILU015_stem_verify", "scan stems folder → JSON report with file attributes and optional Whisper transcripts", Utility, "(after produce / import)"),
89    cmd!(stem_compare, "stem-compare", "xil_pipeline.XILU016_stem_compare", "cross-reference a stem-verify transcript report against the parsed script", Utility, "(after stem-verify)"),
90    cmd!(remove_show, "remove-show", "xil_pipeline.XILU017_remove_show", "remove all workspace files for a show (--dry-run safe)", Utility, "(workspace management)"),
91    cmd!(remove_episode, "remove-episode", "xil_pipeline.XILU018_remove_episode", "remove workspace files for one episode, preserving the source script (--dry-run safe)", Utility, "(workspace management)"),
92    cmd!(status, "status", "xil_pipeline.XILU019_episode_status", "make-style staleness check of an episode's pipeline artifacts (report only)", Utility, "(workspace management)"),
93    cmd!(setup, "setup", "xil_pipeline.XILU023_setup", "create and verify local ML worker venvs (chatterbox, whisper, mmaudio)", Utility, "(local GPU TTS setup)"),
94];
95
96pub fn find(name: &str) -> Option<&'static CommandSpec> {
97    COMMANDS.iter().find(|c| c.name == name)
98}
99
100/// The `XILP000` / `XILU001` identifier embedded in a module path, or "".
101pub fn module_tag(module: &str) -> &str {
102    let Some(start) = module.find("XIL") else {
103        return "";
104    };
105    let rest = &module[start..];
106    // "XIL", one of P/U, then digits.
107    let mut end = 3;
108    let bytes = rest.as_bytes();
109    if bytes.len() < 4 || !matches!(bytes[3], b'P' | b'U') {
110        return "";
111    }
112    end += 1;
113    while end < bytes.len() && bytes[end].is_ascii_digit() {
114        end += 1;
115    }
116    if end == 4 {
117        return "";
118    }
119    &rest[..end]
120}
121
122/// Render the command list exactly as the Python `_print_help` does.
123pub fn help_text() -> String {
124    let width = COMMANDS.iter().map(|c| c.name.len()).max().unwrap_or(0);
125    let mut out = String::from("Usage: xil <command> [args...]\n\n");
126    for (label, group) in [
127        ("Pipeline Stages (recommended order):", Group::Pipeline),
128        ("Utilities:", Group::Utility),
129    ] {
130        out.push_str(label);
131        out.push('\n');
132        for c in COMMANDS.iter().filter(|c| c.group == group) {
133            let tag = module_tag(c.py_module);
134            let tag_str = if tag.is_empty() {
135                " ".repeat(8)
136            } else {
137                format!("{tag:<8}")
138            };
139            let suffix = if c.hint.is_empty() {
140                String::new()
141            } else {
142                format!("  {}", c.hint)
143            };
144            out.push_str(&format!(
145                "  {:<width$}  {tag_str} {}{suffix}\n",
146                c.name, c.description
147            ));
148        }
149        out.push('\n');
150    }
151    out.push_str("Run 'xil <command> --help' for command-specific options.\n");
152    out
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn table_has_all_39_python_commands() {
161        // len(XIL_SCRIPT_COMMANDS) in the Python xil.py at v0.4.0.
162        assert_eq!(COMMANDS.len(), 39);
163    }
164
165    #[test]
166    fn names_are_unique() {
167        let mut names: Vec<_> = COMMANDS.iter().map(|c| c.name).collect();
168        names.sort_unstable();
169        names.dedup();
170        assert_eq!(names.len(), COMMANDS.len());
171    }
172
173    #[test]
174    fn module_tag_extracts_xilp_and_xilu() {
175        assert_eq!(module_tag("xil_pipeline.XILP000_script_scanner"), "XILP000");
176        assert_eq!(module_tag("xil_pipeline.XILU022_sfx_match"), "XILU022");
177        assert_eq!(module_tag("xil_pipeline.xil_init"), "");
178        assert_eq!(module_tag("xil_pipeline.xil_gui"), "");
179    }
180
181    #[test]
182    fn help_matches_python_layout_for_first_lines() {
183        let text = help_text();
184        let lines: Vec<_> = text.lines().collect();
185        assert_eq!(lines[0], "Usage: xil <command> [args...]");
186        assert_eq!(lines[1], "");
187        assert_eq!(lines[2], "Pipeline Stages (recommended order):");
188        assert_eq!(
189            lines[3],
190            "  init                        workspace scaffolding"
191        );
192        assert_eq!(
193            lines[4],
194            "  scan               XILP000  pre-flight script scanner"
195        );
196        assert_eq!(
197            lines.last().copied(),
198            Some("Run 'xil <command> --help' for command-specific options.")
199        );
200    }
201}