devalang_wasm/language/addons/registry/
mod.rs1#![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}