devalang_wasm/platform/config/
mod.rs1#![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
109fn 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 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 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 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 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 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 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 match load_json(path) {
341 Ok(cfg) => Ok(cfg),
342 Err(_) => load_toml(path),
343 }
344 }
345 }
346 } else {
347 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 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 candidates[0].clone()
369}
370
371fn select_config_interactive(candidates: &[PathBuf]) -> Option<PathBuf> {
372 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 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}