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}
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
48fn 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 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 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 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 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 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 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 match load_json(path) {
272 Ok(cfg) => Ok(cfg),
273 Err(_) => load_toml(path),
274 }
275 }
276 }
277 } else {
278 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 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 candidates[0].clone()
300}
301
302fn select_config_interactive(candidates: &[PathBuf]) -> Option<PathBuf> {
303 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 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}