devalang_wasm/language/addons/registry/
mod.rs

1#![cfg(feature = "cli")]
2
3use std::collections::{HashMap, HashSet, hash_map::Entry};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use serde::Deserialize;
9
10#[derive(Debug, Clone)]
11pub struct BankDefinition {
12    identifier: String,
13    publisher: Option<String>,
14    name: String,
15    root_dir: PathBuf,
16    audio_root: PathBuf,
17    triggers: HashMap<String, PathBuf>,
18}
19
20impl BankDefinition {
21    pub fn identifier(&self) -> &str {
22        &self.identifier
23    }
24
25    pub fn name(&self) -> &str {
26        &self.name
27    }
28
29    pub fn publisher(&self) -> Option<&str> {
30        self.publisher.as_deref()
31    }
32
33    pub fn root_dir(&self) -> &Path {
34        &self.root_dir
35    }
36
37    pub fn audio_root(&self) -> &Path {
38        &self.audio_root
39    }
40
41    pub fn trigger_count(&self) -> usize {
42        self.triggers.len()
43    }
44
45    pub fn resolve_trigger(&self, trigger: &str) -> Option<PathBuf> {
46        self.triggers.get(trigger).cloned()
47    }
48
49    pub fn list_triggers(&self) -> Vec<&String> {
50        self.triggers.keys().collect()
51    }
52
53    fn load(identifier: &str, project_root: &Path, base_dir: &Path) -> Result<Self> {
54        let manifest_path = locate_manifest(identifier, project_root, base_dir)
55            .with_context(|| format!("unable to locate bank manifest for '{}'", identifier))?;
56
57        let manifest_dir = manifest_path
58            .parent()
59            .map(Path::to_path_buf)
60            .unwrap_or_else(|| project_root.to_path_buf());
61
62        let raw = fs::read_to_string(&manifest_path)
63            .with_context(|| format!("failed to read {}", manifest_path.display()))?;
64        let manifest: BankManifest = toml::from_str(&raw)
65            .with_context(|| format!("invalid bank manifest at {}", manifest_path.display()))?;
66
67        let mut publisher = manifest
68            .bank
69            .as_ref()
70            .and_then(|section| section.publisher.clone());
71        let mut name = manifest
72            .bank
73            .as_ref()
74            .and_then(|section| section.name.clone())
75            .or_else(|| {
76                manifest_dir
77                    .file_name()
78                    .map(|v| v.to_string_lossy().to_string())
79            })
80            .unwrap_or_else(|| {
81                identifier
82                    .rsplit_once('.')
83                    .map(|(_, bank)| bank.to_string())
84                    .unwrap_or_else(|| identifier.to_string())
85            });
86
87        if publisher.is_none() {
88            if let Some((pubr, _)) = identifier.rsplit_once('.') {
89                publisher = Some(pubr.to_string());
90            }
91        }
92
93        if name.is_empty() {
94            name = identifier.to_string();
95        }
96
97        let audio_root = manifest
98            .bank
99            .as_ref()
100            .and_then(|section| section.audio_path.clone())
101            .map(|raw| resolve_audio_root(&manifest_dir, &raw))
102            .unwrap_or_else(|| manifest_dir.join("audio"));
103
104        let audio_root = normalize_path(&audio_root);
105
106        let mut triggers = HashMap::new();
107        for entry in manifest.triggers.into_iter() {
108            let trigger_path = resolve_trigger_path(&audio_root, &manifest_dir, &entry.path);
109            triggers.insert(entry.name, trigger_path);
110        }
111
112        if triggers.is_empty() {
113            return Err(anyhow!(
114                "bank manifest {} does not define any triggers",
115                manifest_path.display()
116            ));
117        }
118
119        Ok(Self {
120            identifier: identifier.to_string(),
121            publisher,
122            name,
123            root_dir: manifest_dir,
124            audio_root,
125            triggers,
126        })
127    }
128}
129
130#[derive(Default, Clone)]
131pub struct BankRegistry {
132    banks: HashMap<String, BankDefinition>,
133}
134
135impl BankRegistry {
136    pub fn new() -> Self {
137        Self {
138            banks: HashMap::new(),
139        }
140    }
141
142    pub fn register_bank(
143        &mut self,
144        alias: impl Into<String>,
145        identifier: &str,
146        project_root: &Path,
147        base_dir: &Path,
148    ) -> Result<&mut BankDefinition> {
149        let alias = alias.into();
150        let result = match self.banks.entry(alias.clone()) {
151            Entry::Occupied(mut entry) => {
152                if entry.get().identifier == identifier {
153                    entry.into_mut()
154                } else {
155                    let bank = BankDefinition::load(identifier, project_root, base_dir)?;
156                    *entry.get_mut() = bank;
157                    entry.into_mut()
158                }
159            }
160            Entry::Vacant(entry) => {
161                let bank = BankDefinition::load(identifier, project_root, base_dir)?;
162                entry.insert(bank)
163            }
164        };
165
166        Ok(result)
167    }
168
169    pub fn resolve_trigger(&self, alias: &str, trigger: &str) -> Option<PathBuf> {
170        self.banks
171            .get(alias)
172            .and_then(|bank| bank.resolve_trigger(trigger))
173    }
174
175    pub fn has_bank(&self, alias: &str) -> bool {
176        self.banks.contains_key(alias)
177    }
178
179    pub fn list_banks(&self) -> Vec<(&String, &BankDefinition)> {
180        self.banks.iter().collect()
181    }
182}
183
184#[derive(Debug, Deserialize)]
185struct BankManifest {
186    #[serde(default)]
187    bank: Option<BankSection>,
188    #[serde(default)]
189    triggers: Vec<TriggerEntry>,
190}
191
192#[derive(Debug, Deserialize)]
193struct BankSection {
194    #[serde(default)]
195    name: Option<String>,
196    #[serde(default)]
197    publisher: Option<String>,
198    #[serde(default, alias = "audio_path", alias = "audioPath")]
199    audio_path: Option<String>,
200}
201
202#[derive(Debug, Deserialize)]
203struct TriggerEntry {
204    name: String,
205    path: String,
206}
207
208fn locate_manifest(identifier: &str, project_root: &Path, base_dir: &Path) -> Result<PathBuf> {
209    let candidates = candidate_directories(identifier, project_root, base_dir);
210    for candidate in candidates {
211        let manifest_path = if candidate.is_file() {
212            if let Some(name) = candidate.file_name() {
213                if name == "bank.toml" {
214                    candidate.clone()
215                } else {
216                    continue;
217                }
218            } else {
219                continue;
220            }
221        } else {
222            candidate.join("bank.toml")
223        };
224
225        if manifest_path.is_file() {
226            return Ok(manifest_path);
227        }
228    }
229
230    Err(anyhow!(
231        "bank '{}' not found (searched relative to {} and project root {})",
232        identifier,
233        base_dir.display(),
234        project_root.display()
235    ))
236}
237
238fn candidate_directories(identifier: &str, project_root: &Path, base_dir: &Path) -> Vec<PathBuf> {
239    let mut seen = HashSet::new();
240    let mut candidates = Vec::new();
241
242    let normalized = identifier.replace('\\', "/");
243    let identifier_path = Path::new(&normalized);
244
245    let push_candidate = |path: PathBuf, set: &mut HashSet<String>, vec: &mut Vec<PathBuf>| {
246        let key = path.to_string_lossy().to_string();
247        if set.insert(key) {
248            vec.push(path);
249        }
250    };
251
252    if identifier_path.is_absolute() {
253        push_candidate(identifier_path.to_path_buf(), &mut seen, &mut candidates);
254    } else {
255        push_candidate(base_dir.join(identifier_path), &mut seen, &mut candidates);
256        push_candidate(
257            project_root.join(identifier_path),
258            &mut seen,
259            &mut candidates,
260        );
261
262        if normalized.starts_with("./") || normalized.starts_with("../") {
263            let joined = base_dir.join(identifier_path);
264            push_candidate(normalize_path(&joined), &mut seen, &mut candidates);
265        }
266
267        if let Some((publisher, bank)) = normalized.rsplit_once('.') {
268            let banks_root = project_root.join(".deva").join("banks");
269
270            let publisher_path = publisher.replace('.', "/");
271            let nested_pub: PathBuf = publisher
272                .split('.')
273                .fold(banks_root.clone(), |acc, part| acc.join(part));
274
275            push_candidate(nested_pub.join(bank), &mut seen, &mut candidates);
276            push_candidate(
277                banks_root.join(format!("{}.{}", publisher, bank)),
278                &mut seen,
279                &mut candidates,
280            );
281            push_candidate(
282                banks_root.join(&publisher).join(bank),
283                &mut seen,
284                &mut candidates,
285            );
286            push_candidate(
287                banks_root.join(&publisher_path).join(bank),
288                &mut seen,
289                &mut candidates,
290            );
291        } else {
292            let banks_root = project_root.join(".deva").join("banks");
293            push_candidate(banks_root.join(&normalized), &mut seen, &mut candidates);
294        }
295    }
296
297    candidates
298}
299
300fn resolve_audio_root(manifest_dir: &Path, raw: &str) -> PathBuf {
301    let path = Path::new(raw);
302    if path.is_absolute() {
303        normalize_path(path)
304    } else {
305        let joined = manifest_dir.join(path);
306        normalize_path(&joined)
307    }
308}
309
310fn resolve_trigger_path(audio_root: &Path, manifest_dir: &Path, raw: &str) -> PathBuf {
311    let path = Path::new(raw);
312    if path.is_absolute() {
313        normalize_path(path)
314    } else if raw.starts_with("./") {
315        let trimmed = raw.trim_start_matches("./");
316
317        if !trimmed.is_empty() {
318            let candidate = audio_root.join(trimmed);
319            if candidate.is_file() {
320                return normalize_path(&candidate);
321            }
322        }
323
324        let joined = manifest_dir.join(path);
325        normalize_path(&joined)
326    } else if raw.starts_with("../") {
327        let joined = manifest_dir.join(path);
328        normalize_path(&joined)
329    } else {
330        let joined = audio_root.join(path);
331        if joined.is_file() {
332            normalize_path(&joined)
333        } else {
334            normalize_path(&manifest_dir.join(path))
335        }
336    }
337}
338
339fn normalize_path(path: &Path) -> PathBuf {
340    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
341}