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