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 println!("🔍 Registering bank: {}", alias);
168 println!(" Identifier: {}", result.identifier);
169 println!(" Available triggers:");
170 for trigger in result.triggers.keys() {
171 println!(" {}", trigger);
172 }
173
174 Ok(result)
175 }
176
177 pub fn resolve_trigger(&self, alias: &str, trigger: &str) -> Option<PathBuf> {
178 self.banks
179 .get(alias)
180 .and_then(|bank| bank.resolve_trigger(trigger))
181 }
182
183 pub fn has_bank(&self, alias: &str) -> bool {
184 self.banks.contains_key(alias)
185 }
186
187 pub fn list_banks(&self) -> Vec<(&String, &BankDefinition)> {
188 self.banks.iter().collect()
189 }
190}
191
192#[derive(Debug, Deserialize)]
193struct BankManifest {
194 #[serde(default)]
195 bank: Option<BankSection>,
196 #[serde(default)]
197 triggers: Vec<TriggerEntry>,
198}
199
200#[derive(Debug, Deserialize)]
201struct BankSection {
202 #[serde(default)]
203 name: Option<String>,
204 #[serde(default)]
205 publisher: Option<String>,
206 #[serde(default, alias = "audio_path", alias = "audioPath")]
207 audio_path: Option<String>,
208}
209
210#[derive(Debug, Deserialize)]
211struct TriggerEntry {
212 name: String,
213 path: String,
214}
215
216fn locate_manifest(identifier: &str, project_root: &Path, base_dir: &Path) -> Result<PathBuf> {
217 let candidates = candidate_directories(identifier, project_root, base_dir);
218 for candidate in candidates {
219 let manifest_path = if candidate.is_file() {
220 if let Some(name) = candidate.file_name() {
221 if name == "bank.toml" {
222 candidate.clone()
223 } else {
224 continue;
225 }
226 } else {
227 continue;
228 }
229 } else {
230 candidate.join("bank.toml")
231 };
232
233 if manifest_path.is_file() {
234 return Ok(manifest_path);
235 }
236 }
237
238 Err(anyhow!(
239 "bank '{}' not found (searched relative to {} and project root {})",
240 identifier,
241 base_dir.display(),
242 project_root.display()
243 ))
244}
245
246fn candidate_directories(identifier: &str, project_root: &Path, base_dir: &Path) -> Vec<PathBuf> {
247 let mut seen = HashSet::new();
248 let mut candidates = Vec::new();
249
250 let normalized = identifier.replace('\\', "/");
251 let identifier_path = Path::new(&normalized);
252
253 let push_candidate = |path: PathBuf, set: &mut HashSet<String>, vec: &mut Vec<PathBuf>| {
254 let key = path.to_string_lossy().to_string();
255 if set.insert(key) {
256 vec.push(path);
257 }
258 };
259
260 if identifier_path.is_absolute() {
261 push_candidate(identifier_path.to_path_buf(), &mut seen, &mut candidates);
262 } else {
263 push_candidate(base_dir.join(identifier_path), &mut seen, &mut candidates);
264 push_candidate(
265 project_root.join(identifier_path),
266 &mut seen,
267 &mut candidates,
268 );
269
270 if normalized.starts_with("./") || normalized.starts_with("../") {
271 let joined = base_dir.join(identifier_path);
272 push_candidate(normalize_path(&joined), &mut seen, &mut candidates);
273 }
274
275 if let Some((publisher, bank)) = normalized.rsplit_once('.') {
276 let banks_root = project_root.join(".deva").join("banks");
277
278 let publisher_path = publisher.replace('.', "/");
279 let nested_pub: PathBuf = publisher
280 .split('.')
281 .fold(banks_root.clone(), |acc, part| acc.join(part));
282
283 push_candidate(nested_pub.join(bank), &mut seen, &mut candidates);
284 push_candidate(
285 banks_root.join(format!("{}.{}", publisher, bank)),
286 &mut seen,
287 &mut candidates,
288 );
289 push_candidate(
290 banks_root.join(&publisher).join(bank),
291 &mut seen,
292 &mut candidates,
293 );
294 push_candidate(
295 banks_root.join(&publisher_path).join(bank),
296 &mut seen,
297 &mut candidates,
298 );
299 } else {
300 let banks_root = project_root.join(".deva").join("banks");
301 push_candidate(banks_root.join(&normalized), &mut seen, &mut candidates);
302 }
303 }
304
305 candidates
306}
307
308fn resolve_audio_root(manifest_dir: &Path, raw: &str) -> PathBuf {
309 let path = Path::new(raw);
310 if path.is_absolute() {
311 normalize_path(path)
312 } else {
313 let joined = manifest_dir.join(path);
314 normalize_path(&joined)
315 }
316}
317
318fn resolve_trigger_path(audio_root: &Path, manifest_dir: &Path, raw: &str) -> PathBuf {
319 let path = Path::new(raw);
320 if path.is_absolute() {
321 normalize_path(path)
322 } else if raw.starts_with("./") {
323 let trimmed = raw.trim_start_matches("./");
324
325 if !trimmed.is_empty() {
326 let candidate = audio_root.join(trimmed);
327 if candidate.is_file() {
328 return normalize_path(&candidate);
329 }
330 }
331
332 let joined = manifest_dir.join(path);
333 normalize_path(&joined)
334 } else if raw.starts_with("../") {
335 let joined = manifest_dir.join(path);
336 normalize_path(&joined)
337 } else {
338 let joined = audio_root.join(path);
339 if joined.is_file() {
340 normalize_path(&joined)
341 } else {
342 normalize_path(&manifest_dir.join(path))
343 }
344 }
345}
346
347fn normalize_path(path: &Path) -> PathBuf {
348 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
349}