Skip to main content

xil/cmd/
mod.rs

1//! Native command implementations, one module per subcommand.
2//!
3//! Each `run(args)` receives the arguments after the command name — what a
4//! Python `main()` sees in `sys.argv[1:]` — and returns the exit code.
5
6// The `///` lines on clap fields ARE the `--help` text, copied verbatim from
7// argparse so the parity suite can diff it. Placeholders like `<slug>` and
8// `<TAG>` look like HTML to rustdoc; wrapping them in backticks would change
9// the help output, so silence that one lint here instead.
10#![allow(rustdoc::invalid_html_tags)]
11
12use std::ffi::OsString;
13use std::sync::OnceLock;
14
15use clap::Parser;
16
17pub mod assemble;
18pub mod cleanup;
19pub mod csv_join;
20pub mod cues;
21pub mod daw;
22pub mod db_profile;
23pub mod episode_summary;
24pub mod gui;
25pub mod import;
26pub mod init;
27pub mod master;
28pub mod migrate;
29pub mod migrate_workspace;
30pub mod mp3_hash;
31pub mod parse;
32pub mod parsed_csv;
33pub mod produce;
34pub mod publish;
35pub mod regen;
36pub mod removal;
37pub mod remove_episode;
38pub mod remove_show;
39pub mod sample;
40pub mod scan;
41pub mod setup;
42pub mod sfx;
43pub mod sfx_csv;
44pub mod sfx_hydrate;
45pub mod sfx_impact;
46pub mod sfx_lib;
47pub mod sfx_match;
48pub mod sfx_restore;
49pub mod splice;
50pub mod status;
51pub mod stem_compare;
52pub mod stem_log;
53pub mod stem_verify;
54pub mod studio_onboard;
55pub mod use_cmd;
56pub mod voices;
57
58/// What Python sees as `sys.argv[0]` for this run: `"xil parse"` through the
59/// dispatcher, `"xil-parse"` through an alias. Feeds the run banner.
60static PROG: OnceLock<String> = OnceLock::new();
61
62pub fn set_prog(prog: &str) {
63    let _ = PROG.set(prog.to_string());
64}
65
66pub fn prog() -> &'static str {
67    PROG.get().map(String::as_str).unwrap_or("xil")
68}
69
70/// `" ".join(sys.argv)` — the BEGIN record's `argv=` field.
71pub fn argv_line(args: &[OsString]) -> String {
72    let mut s = prog().to_string();
73    for a in args {
74        s.push(' ');
75        s.push_str(&a.to_string_lossy());
76    }
77    s
78}
79
80/// A `sys.exit(<message>)` from inside a command, reported the way Python
81/// reports it once the run banner has closed: through the `xil` dispatcher
82/// (`_normalize_exit_code`) the message is logged as an error; through an
83/// `xil-<command>` entry point the interpreter prints it to stderr. Either
84/// way the status is 1.
85#[derive(Debug)]
86pub struct SysExit(pub String);
87
88impl std::fmt::Display for SysExit {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(&self.0)
91    }
92}
93
94impl std::error::Error for SysExit {}
95
96/// Finish a command whose body may have raised [`SysExit`]. Call after the
97/// banner guard has been dropped.
98pub fn finish(result: anyhow::Result<i32>) -> anyhow::Result<i32> {
99    match result {
100        Err(e) => match e.downcast::<SysExit>() {
101            Ok(SysExit(msg)) => {
102                if prog().starts_with("xil ") {
103                    xil_core::log::error(&msg);
104                } else {
105                    eprintln!("{msg}");
106                }
107                Ok(1)
108            }
109            Err(other) => Err(other),
110        },
111        ok => ok,
112    }
113}
114
115/// Parse `args` with a clap derive type, using `prog` as `argv[0]` so help and
116/// usage lines name the command the way argparse does (`xil-use`).
117///
118/// On a usage error or `--help`, prints what clap would print and returns
119/// the exit code to hand back (2 for errors, 0 for help/version), matching
120/// argparse.
121pub fn parse_or_exit<T: Parser>(prog: &str, args: &[OsString]) -> Result<T, i32> {
122    let argv = std::iter::once(OsString::from(prog)).chain(args.iter().cloned());
123    match T::try_parse_from(argv) {
124        Ok(t) => Ok(t),
125        Err(e) => {
126            let _ = e.print();
127            Err(e.exit_code())
128        }
129    }
130}
131
132/// `data.get(key, "")` — the value when the key is present (even `null`),
133/// an empty string when it is absent.
134pub fn get_or_empty(
135    map: &serde_json::Map<String, serde_json::Value>,
136    key: &str,
137) -> serde_json::Value {
138    map.get(key)
139        .cloned()
140        .unwrap_or(serde_json::Value::String(String::new()))
141}
142
143/// `entry.get(key) or ""` — falsy values (`null`, `""`, `0`, `false`) become
144/// an empty string, anything else passes through.
145pub fn get_or_blank(
146    map: &serde_json::Map<String, serde_json::Value>,
147    key: &str,
148) -> serde_json::Value {
149    use serde_json::Value;
150    match map.get(key) {
151        Some(v) if truthy(v) => v.clone(),
152        _ => Value::String(String::new()),
153    }
154}
155
156/// `bool(value)` for a JSON value: `null`, `false`, `0`, `""`, `[]` and
157/// `{}` are false, everything else true.
158/// `str(value)` for a JSON value printed through an f-string.
159pub fn py_str(v: &serde_json::Value) -> String {
160    use serde_json::Value;
161    match v {
162        Value::Null => "None".into(),
163        Value::Bool(true) => "True".into(),
164        Value::Bool(false) => "False".into(),
165        Value::String(s) => s.clone(),
166        other => xil_core::pycsv::cell(other),
167    }
168}
169
170pub fn truthy(v: &serde_json::Value) -> bool {
171    use serde_json::Value;
172    match v {
173        Value::Null | Value::Bool(false) => false,
174        Value::Bool(true) => true,
175        Value::Number(n) => n.as_f64() != Some(0.0),
176        Value::String(s) => !s.is_empty(),
177        Value::Array(a) => !a.is_empty(),
178        Value::Object(o) => !o.is_empty(),
179    }
180}
181
182/// Held by tests that set `XIL_PROJECTROOT`, which is process-wide.
183#[cfg(test)]
184pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());