devalang_wasm/platform/config/
mod.rs

1#![cfg(feature = "cli")]
2
3use std::fs::{self, File};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use atty;
9use inquire;
10use serde::{Deserialize, Serialize};
11
12use crate::engine::audio::settings::{AudioBitDepth, AudioChannels, AudioFormat, ResampleQuality};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(default)]
16pub struct AppConfig {
17    pub project: ProjectSection,
18    pub paths: PathsSection,
19    pub audio: AudioSection,
20    pub live: LiveSection,
21    pub rules: RulesSection,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct ProjectSection {
27    pub name: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct PathsSection {
33    pub entry: PathBuf,
34    pub output: PathBuf,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct AudioSection {
40    #[serde(deserialize_with = "deserialize_format")]
41    pub format: Vec<String>,
42    pub bit_depth: u16,
43    pub channels: u16,
44    pub sample_rate: u32,
45    pub resample_quality: String,
46    pub bpm: f32,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct LiveSection {
52    pub crossfade_ms: u64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(default)]
57pub struct RulesSection {
58    #[serde(default)]
59    pub explicit_durations: RuleLevel,
60    #[serde(default)]
61    pub deprecated_syntax: RuleLevel,
62    #[serde(default)]
63    pub var_keyword: RuleLevel,
64    #[serde(default)]
65    pub missing_duration: RuleLevel,
66    #[serde(default)]
67    pub implicit_type_conversion: RuleLevel,
68    #[serde(default)]
69    pub unused_variables: RuleLevel,
70}
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "lowercase")]
74pub enum RuleLevel {
75    #[serde(rename = "error")]
76    Error,
77    #[serde(rename = "warning")]
78    Warning,
79    #[serde(rename = "info")]
80    Info,
81    #[serde(rename = "off")]
82    Off,
83}
84
85impl Default for RuleLevel {
86    fn default() -> Self {
87        RuleLevel::Warning
88    }
89}
90
91impl RuleLevel {
92    pub fn should_report(&self) -> bool {
93        *self != RuleLevel::Off
94    }
95
96    pub fn is_error(&self) -> bool {
97        *self == RuleLevel::Error
98    }
99
100    pub fn is_warning(&self) -> bool {
101        *self == RuleLevel::Warning
102    }
103
104    pub fn is_info(&self) -> bool {
105        *self == RuleLevel::Info
106    }
107}
108
109/// Custom deserializer to handle both String and Vec<String> for format field
110fn deserialize_format<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
111where
112    D: serde::Deserializer<'de>,
113{
114    use serde::de::Error;
115    use serde_json::Value;
116
117    let value = Value::deserialize(deserializer)?;
118
119    match value {
120        Value::String(s) => Ok(vec![s]),
121        Value::Array(arr) => arr
122            .into_iter()
123            .map(|v| {
124                v.as_str()
125                    .map(|s| s.to_string())
126                    .ok_or_else(|| D::Error::custom("format array must contain strings"))
127            })
128            .collect(),
129        _ => Err(D::Error::custom(
130            "format must be a string or array of strings",
131        )),
132    }
133}
134
135impl Default for AppConfig {
136    fn default() -> Self {
137        Self {
138            project: ProjectSection::default(),
139            paths: PathsSection::default(),
140            audio: AudioSection::default(),
141            live: LiveSection::default(),
142            rules: RulesSection::default(),
143        }
144    }
145}
146
147impl Default for ProjectSection {
148    fn default() -> Self {
149        Self {
150            name: "Devalang Project".to_string(),
151        }
152    }
153}
154
155impl Default for PathsSection {
156    fn default() -> Self {
157        Self {
158            entry: PathBuf::from("examples/index.deva"),
159            output: PathBuf::from("output"),
160        }
161    }
162}
163
164impl Default for AudioSection {
165    fn default() -> Self {
166        Self {
167            format: vec!["wav".to_string()],
168            bit_depth: 16,
169            channels: 2,
170            sample_rate: 44_100,
171            resample_quality: "sinc24".to_string(),
172            bpm: 120.0,
173        }
174    }
175}
176
177impl Default for LiveSection {
178    fn default() -> Self {
179        Self { crossfade_ms: 50 }
180    }
181}
182
183impl Default for RulesSection {
184    fn default() -> Self {
185        Self {
186            explicit_durations: RuleLevel::Warning,
187            deprecated_syntax: RuleLevel::Warning,
188            var_keyword: RuleLevel::Error,
189            missing_duration: RuleLevel::Info,
190            implicit_type_conversion: RuleLevel::Info,
191            unused_variables: RuleLevel::Warning,
192        }
193    }
194}
195
196impl AppConfig {
197    pub fn load(root: impl AsRef<Path>) -> Result<Self> {
198        let root = root.as_ref();
199        let json_path = root.join("devalang.json");
200        let dot_path = root.join(".devalang");
201        let toml_path = root.join("devalang.toml");
202
203        // Collect existing candidate configs
204        let mut candidates: Vec<PathBuf> = Vec::new();
205        if json_path.exists() {
206            candidates.push(json_path.clone());
207        }
208        if dot_path.exists() {
209            candidates.push(dot_path.clone());
210        }
211        if toml_path.exists() {
212            candidates.push(toml_path.clone());
213        }
214
215        match candidates.len() {
216            0 => {
217                let default = AppConfig::default();
218                // create devalang.json with defaults for discoverability
219                write_default_json(root, &json_path, &default)?;
220                Ok(default)
221            }
222            1 => {
223                let path = &candidates[0];
224                load_config_by_path(path)
225            }
226            _ => {
227                // Conflict: multiple config files present. Prompt the user to choose.
228                // Use interactive prompt (inquire). If prompt fails (non-interactive), fall back to priority order.
229                let selected = select_config_interactive(&candidates)
230                    .unwrap_or_else(|| pick_config_priority(&candidates));
231                load_config_by_path(&selected)
232            }
233        }
234    }
235
236    pub fn entry_path(&self, root: impl AsRef<Path>) -> PathBuf {
237        root.as_ref().join(&self.paths.entry)
238    }
239
240    pub fn output_path(&self, root: impl AsRef<Path>) -> PathBuf {
241        root.as_ref().join(&self.paths.output)
242    }
243
244    pub fn audio_format(&self) -> AudioFormat {
245        // Return first format as primary (for backward compatibility)
246        self.audio_formats().first().copied().unwrap_or_default()
247    }
248
249    pub fn audio_formats(&self) -> Vec<AudioFormat> {
250        self.audio
251            .format
252            .iter()
253            .filter_map(|s| AudioFormat::from_str(s))
254            .collect()
255    }
256
257    pub fn audio_bit_depth(&self) -> AudioBitDepth {
258        match self.audio.bit_depth {
259            8 => AudioBitDepth::Bit8,
260            24 => AudioBitDepth::Bit24,
261            32 => AudioBitDepth::Bit32,
262            _ => AudioBitDepth::Bit16,
263        }
264    }
265
266    pub fn audio_channels(&self) -> AudioChannels {
267        match self.audio.channels {
268            1 => AudioChannels::Mono,
269            _ => AudioChannels::Stereo,
270        }
271    }
272
273    pub fn resample_quality(&self) -> ResampleQuality {
274        match self.audio.resample_quality.to_lowercase().as_str() {
275            "linear2" | "linear" | "2" => ResampleQuality::Linear2,
276            "sinc12" => ResampleQuality::Sinc12,
277            "sinc48" => ResampleQuality::Sinc48,
278            "sinc96" => ResampleQuality::Sinc96,
279            "sinc192" => ResampleQuality::Sinc192,
280            "sinc512" => ResampleQuality::Sinc512,
281            _ => ResampleQuality::Sinc24,
282        }
283    }
284
285    pub fn crossfade_ms(&self) -> u64 {
286        self.live.crossfade_ms.max(10)
287    }
288
289    pub fn sample_rate(&self) -> u32 {
290        self.audio.sample_rate.max(8_000)
291    }
292}
293
294fn load_json(path: &Path) -> Result<AppConfig> {
295    let file = fs::read_to_string(path)
296        .with_context(|| format!("failed to read config: {}", path.display()))?;
297    let config = serde_json::from_str(&file)
298        .with_context(|| format!("invalid JSON config: {}", path.display()))?;
299    Ok(config)
300}
301
302fn load_toml(path: &Path) -> Result<AppConfig> {
303    let file = fs::read_to_string(path)
304        .with_context(|| format!("failed to read config: {}", path.display()))?;
305    let config = toml::from_str(&file)
306        .with_context(|| format!("invalid TOML config: {}", path.display()))?;
307    Ok(config)
308}
309
310fn load_config_by_path(path: &Path) -> Result<AppConfig> {
311    // If filename is exactly ".devalang", try to detect JSON vs TOML by content
312    if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
313        if name == ".devalang" {
314            let raw = fs::read_to_string(path)
315                .with_context(|| format!("failed to read config: {}", path.display()))?;
316            let trimmed = raw.trim_start();
317            if trimmed.starts_with('{') || trimmed.starts_with('[') {
318                let cfg = serde_json::from_str(&raw)
319                    .with_context(|| format!("invalid JSON config: {}", path.display()))?;
320                return Ok(cfg);
321            } else {
322                let cfg = toml::from_str(&raw)
323                    .with_context(|| format!("invalid TOML config: {}", path.display()))?;
324                return Ok(cfg);
325            }
326        }
327    }
328
329    // otherwise choose by extension
330    if let Some(ext) = path
331        .extension()
332        .and_then(|s| s.to_str())
333        .map(|s| s.to_lowercase())
334    {
335        match ext.as_str() {
336            "json" => load_json(path),
337            "toml" => load_toml(path),
338            _ => {
339                // default: try json then toml
340                match load_json(path) {
341                    Ok(cfg) => Ok(cfg),
342                    Err(_) => load_toml(path),
343                }
344            }
345        }
346    } else {
347        // default: try json then toml
348        match load_json(path) {
349            Ok(cfg) => Ok(cfg),
350            Err(_) => load_toml(path),
351        }
352    }
353}
354
355fn pick_config_priority(candidates: &[PathBuf]) -> PathBuf {
356    // Priority: devalang.toml > devalang.json > .devalang
357    for pref in ["devalang.toml", "devalang.json", ".devalang"] {
358        if let Some(found) = candidates.iter().find(|p| {
359            p.file_name()
360                .and_then(|s| s.to_str())
361                .map(|n| n.eq_ignore_ascii_case(pref))
362                .unwrap_or(false)
363        }) {
364            return found.clone();
365        }
366    }
367    // fallback to first
368    candidates[0].clone()
369}
370
371fn select_config_interactive(candidates: &[PathBuf]) -> Option<PathBuf> {
372    // Use inquire crate for interactive selection if available
373    // Guard the call so code still works in non-interactive environments
374    if atty::is(atty::Stream::Stdin) {
375        let options: Vec<String> = candidates
376            .iter()
377            .map(|p| p.to_string_lossy().to_string())
378            .collect();
379        // try to use inquire; fall back if unavailable at runtime
380        if let Ok(selected) =
381            inquire::Select::new("Multiple config files found; select one:", options).prompt()
382        {
383            return Some(PathBuf::from(selected));
384        }
385    }
386    None
387}
388
389fn write_default_json(root: &Path, path: &Path, config: &AppConfig) -> Result<()> {
390    if let Some(parent) = path.parent() {
391        if parent != root {
392            fs::create_dir_all(parent).with_context(|| {
393                format!("failed to create config directory: {}", parent.display())
394            })?;
395        }
396    }
397    let json = serde_json::to_string_pretty(config).context("serialize default config")?;
398    let mut file = File::create(path)
399        .with_context(|| format!("failed to create config file: {}", path.display()))?;
400    file.write_all(json.as_bytes())
401        .with_context(|| format!("unable to write config file: {}", path.display()))?;
402    Ok(())
403}