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 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}