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