Skip to main content

xil/cmd/
sfx_csv.rs

1//! `xil sfx-csv` — sfx config → one row per effect. Port of `XILU011_sfx_csv.py`.
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_core::fsutil::{abspath, basename, glob_children};
11use xil_core::pycsv;
12use xil_core::pyjson::{dumps, Style};
13use xil_core::workspace::workspace_root;
14use xil_core::{banner, log};
15
16const META_COLS: [&str; 5] = ["file", "tag", "show", "season", "episode"];
17const EFFECT_COLS: [&str; 11] = [
18    "effect_key",
19    "type",
20    "prompt",
21    "source",
22    "duration_seconds",
23    "loop",
24    "play_duration",
25    "volume_percentage",
26    "ramp_in_seconds",
27    "ramp_out_seconds",
28    "prompt_influence",
29];
30const DEFAULT_COLS: [&str; 10] = [
31    "default_prompt_influence",
32    "default_volume_percentage",
33    "default_ramp_in_seconds",
34    "default_ramp_out_seconds",
35    "default_music_volume_percentage",
36    "default_music_ramp_in_seconds",
37    "default_music_ramp_out_seconds",
38    "default_ambience_volume_percentage",
39    "default_ambience_ramp_in_seconds",
40    "default_ambience_ramp_out_seconds",
41];
42const COPIED_FIELDS: [&str; 10] = [
43    "prompt",
44    "type",
45    "source",
46    "duration_seconds",
47    "loop",
48    "play_duration",
49    "volume_percentage",
50    "ramp_in_seconds",
51    "ramp_out_seconds",
52    "prompt_influence",
53];
54const DEFAULT_PATH: &str = "__configs_default__";
55
56fn all_cols() -> Vec<&'static str> {
57    META_COLS
58        .iter()
59        .chain(EFFECT_COLS.iter())
60        .chain(DEFAULT_COLS.iter())
61        .copied()
62        .collect()
63}
64
65#[derive(Parser)]
66#[command(
67    name = "xil-sfx-csv",
68    about = "Flatten sfx_<tag>.json configs to CSV — one row per effect"
69)]
70struct Args {
71    /// sfx JSON file, or directory containing sfx_*.json files (default: workspace configs/)
72    #[arg(default_value = DEFAULT_PATH)]
73    path: String,
74    /// Write CSV to FILE (default: stdout)
75    #[arg(long, short = 'o', value_name = "FILE")]
76    output: Option<PathBuf>,
77    /// Output a JSON array to stdout — no banner, safe to pipe to jq
78    #[arg(long)]
79    json: bool,
80}
81
82fn tag_from_path(path: &Path, data: &Map<String, Value>) -> Value {
83    if let Some(t) = data.get("tag_override") {
84        return t.clone();
85    }
86    let name = basename(path);
87    let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(&name);
88    Value::String(stem.strip_prefix("sfx_").unwrap_or(stem).to_string())
89}
90
91/// Flat rows for one sfx config. Key order matches Python: every column
92/// first (blank), then any extra `default_*` keys appended.
93pub fn flatten(path: &Path) -> Result<Vec<Map<String, Value>>, String> {
94    let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
95    let data: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
96    let obj = data.as_object().cloned().unwrap_or_default();
97    let tag = tag_from_path(path, &obj);
98    let defaults = obj
99        .get("defaults")
100        .and_then(Value::as_object)
101        .cloned()
102        .unwrap_or_default();
103
104    let cols = all_cols();
105    let mut rows = Vec::new();
106    for (key, effect) in obj
107        .get("effects")
108        .and_then(Value::as_object)
109        .cloned()
110        .unwrap_or_default()
111    {
112        let mut row = Map::new();
113        for c in &cols {
114            row.insert((*c).to_string(), Value::String(String::new()));
115        }
116        row.insert("file".into(), Value::String(basename(path)));
117        row.insert("tag".into(), tag.clone());
118        row.insert("show".into(), super::get_or_empty(&obj, "show"));
119        row.insert("season".into(), super::get_or_empty(&obj, "season"));
120        row.insert("episode".into(), super::get_or_empty(&obj, "episode"));
121        for (k, v) in &defaults {
122            row.insert(format!("default_{k}"), v.clone());
123        }
124        row.insert("effect_key".into(), Value::String(key));
125        if let Some(e) = effect.as_object() {
126            for f in COPIED_FIELDS {
127                if let Some(v) = e.get(f) {
128                    row.insert(f.to_string(), v.clone());
129                }
130            }
131        }
132        rows.push(row);
133    }
134    Ok(rows)
135}
136
137fn collect_files(path: &Path) -> Vec<PathBuf> {
138    if path.is_file() {
139        vec![abspath(path)]
140    } else if path.is_dir() {
141        glob_children(path, "sfx_", ".json")
142            .into_iter()
143            .map(|p| abspath(&p))
144            .collect()
145    } else {
146        Vec::new()
147    }
148}
149
150fn execute(a: &Args) -> anyhow::Result<()> {
151    let quiet = a.json || a.output.is_none();
152    let resolved = if a.path == DEFAULT_PATH {
153        workspace_root().join("configs")
154    } else {
155        abspath(Path::new(&a.path))
156    };
157
158    let files = collect_files(&resolved);
159    if files.is_empty() {
160        log::error(&format!(
161            "No sfx_*.json files found at: {}",
162            resolved.display()
163        ));
164        return Ok(());
165    }
166    if !quiet {
167        log::info(&format!("Processing {} sfx config file(s)…", files.len()));
168    }
169
170    let mut all_rows = Vec::new();
171    for p in &files {
172        match flatten(p) {
173            Ok(rows) => {
174                if !quiet {
175                    log::info(&format!("  {} → {} effect(s)", basename(p), rows.len()));
176                }
177                all_rows.extend(rows);
178            }
179            Err(e) => log::warning(&format!("Skipping {} — {e}", basename(p))),
180        }
181    }
182
183    if all_rows.is_empty() {
184        if !quiet {
185            log::info("No effect rows produced.");
186        }
187        return Ok(());
188    }
189
190    if a.json {
191        let arr = Value::Array(all_rows.into_iter().map(Value::Object).collect());
192        println!("{}", dumps(&arr, Style::INDENT2));
193        return Ok(());
194    }
195
196    let cols = all_cols();
197    if let Some(out) = &a.output {
198        let mut f = fs::File::create(out)?;
199        pycsv::write_dicts(&mut f, &cols, &all_rows)?;
200        log::info(&format!(
201            "Written: {}  ({} rows)",
202            out.display(),
203            all_rows.len()
204        ));
205    } else {
206        let mut so = std::io::stdout().lock();
207        pycsv::write_dicts(&mut so, &cols, &all_rows)?;
208        so.flush()?;
209    }
210
211    if !quiet {
212        log::info(&format!(
213            "Total: {} effect rows from {} file(s)",
214            all_rows.len(),
215            files.len()
216        ));
217    }
218    Ok(())
219}
220
221pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
222    log::init("sfx-csv");
223    let a: Args = match super::parse_or_exit("xil-sfx-csv", args) {
224        Ok(a) => a,
225        Err(code) => return Ok(code),
226    };
227    if a.json || a.output.is_none() {
228        execute(&a)?;
229    } else {
230        let _banner = banner::begin(super::prog(), &super::argv_line(args));
231        execute(&a)?;
232    }
233    Ok(0)
234}
235
236/// The clap definition behind `--help`, for man pages.
237pub fn command() -> clap::Command {
238    <Args as clap::CommandFactory>::command()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn flatten_orders_columns_then_extra_defaults() {
247        let tmp = tempfile::tempdir().unwrap();
248        let p = tmp.path().join("sfx_S01E01.json");
249        fs::write(
250            &p,
251            r#"{"show":"S","season":1,"episode":1,"defaults":{"prompt_influence":0.3,"odd_key":7},
252                "effects":{"BEAT":{"type":"silence","duration_seconds":1.0},"SFX: X":{"prompt":"p","loop":true}}}"#,
253        )
254        .unwrap();
255        let rows = flatten(&p).unwrap();
256        assert_eq!(rows.len(), 2);
257        let keys: Vec<&str> = rows[0].keys().map(String::as_str).collect();
258        assert_eq!(keys[0], "file");
259        assert_eq!(keys[5], "effect_key");
260        assert_eq!(*keys.last().unwrap(), "default_odd_key");
261        assert_eq!(rows[0]["tag"], "S01E01");
262        assert_eq!(rows[0]["effect_key"], "BEAT");
263        assert_eq!(rows[0]["duration_seconds"], 1.0);
264        assert_eq!(rows[0]["prompt"], "");
265        assert_eq!(rows[0]["default_prompt_influence"], 0.3);
266        assert_eq!(rows[1]["loop"], true);
267    }
268
269    #[test]
270    fn tag_override_wins_even_when_null() {
271        let mut d = Map::new();
272        assert_eq!(tag_from_path(Path::new("/c/sfx_S01E01.json"), &d), "S01E01");
273        assert_eq!(tag_from_path(Path::new("/c/other.json"), &d), "other");
274        d.insert("tag_override".into(), Value::Null);
275        assert_eq!(
276            tag_from_path(Path::new("/c/sfx_S01E01.json"), &d),
277            Value::Null
278        );
279    }
280}