1use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::dirs::Dirs;
7use crate::error::{Error, Result};
8
9use super::dynamic::{self, DynamicBackendFactory};
10use super::Backend;
11
12pub struct Registry {
13 backends: Vec<Arc<dyn Backend>>,
14 by_name: HashMap<String, usize>,
15 dynamic_by_prefix: HashMap<&'static str, Arc<dyn DynamicBackendFactory>>,
16}
17
18impl Registry {
19 pub fn new() -> Registry {
21 Self::from_backends(vec![
22 Arc::new(crate::backend::node::NodeBackend),
23 Arc::new(crate::backend::npm_cli::NpmBackend),
24 Arc::new(crate::backend::go::GoBackend),
25 Arc::new(crate::backend::python::PythonBackend),
26 Arc::new(crate::backend::java::JavaBackend),
27 Arc::new(crate::backend::jvm_tools::JvmToolBackend::Maven),
28 Arc::new(crate::backend::jvm_tools::JvmToolBackend::Gradle),
29 Arc::new(crate::backend::jvm_tools::JvmToolBackend::Kotlin),
30 Arc::new(crate::backend::rust::RustBackend),
31 Arc::new(crate::backend::pnpm::PnpmBackend),
32 Arc::new(crate::backend::yarn::YarnBackend),
33 Arc::new(crate::backend::deno::DenoBackend),
34 Arc::new(crate::backend::bun::BunBackend),
35 ])
36 .expect("compiled-in backend ids and aliases must be unique")
37 }
38
39 pub fn load(dirs: &Dirs) -> Result<Registry> {
45 let mut backends = Self::new().backends;
46 for directory in [dirs.config.join("plugins"), dirs.plugins()] {
47 backends.extend(
48 crate::backend::declarative::load_dir(&directory)?
49 .into_iter()
50 .map(|backend| Arc::new(backend) as Arc<dyn Backend>),
51 );
52 }
53 Self::from_backends(backends)
54 }
55
56 fn from_backends(backends: Vec<Arc<dyn Backend>>) -> Result<Registry> {
57 Self::from_parts(backends, dynamic::builtin_factories())
58 }
59
60 fn from_parts(
61 backends: Vec<Arc<dyn Backend>>,
62 dynamic_factories: Vec<Arc<dyn DynamicBackendFactory>>,
63 ) -> Result<Registry> {
64 let mut by_name = HashMap::new();
65 for (i, b) in backends.iter().enumerate() {
66 insert_name(&mut by_name, b.id(), i)?;
67 for alias in b.aliases() {
68 insert_name(&mut by_name, alias, i)?;
69 }
70 }
71
72 let mut dynamic_by_prefix = HashMap::new();
73 for factory in dynamic_factories {
74 let prefix = factory.prefix();
75 if dynamic_by_prefix.insert(prefix, factory).is_some() {
76 return Err(Error::config(format!(
77 "duplicate dynamic backend prefix `{prefix}`"
78 )));
79 }
80 }
81
82 Ok(Registry {
83 backends,
84 by_name,
85 dynamic_by_prefix,
86 })
87 }
88
89 pub fn get(&self, name: &str) -> Result<Arc<dyn Backend>> {
90 if let Some((prefix, _)) = name.split_once(':') {
91 return self
92 .dynamic_by_prefix
93 .get(prefix)
94 .and_then(|factory| factory.create(name))
95 .ok_or_else(|| Error::UnknownBackend(name.to_string()));
96 }
97 self.by_name
98 .get(name)
99 .map(|&i| self.backends[i].clone())
100 .ok_or_else(|| Error::UnknownBackend(name.to_string()))
101 }
102
103 pub fn all(&self) -> &[Arc<dyn Backend>] {
104 &self.backends
105 }
106
107 pub fn ids(&self) -> Vec<&str> {
108 self.backends.iter().map(|b| b.id()).collect()
109 }
110}
111
112impl Default for Registry {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118fn insert_name(by_name: &mut HashMap<String, usize>, name: &str, index: usize) -> Result<()> {
119 if by_name.insert(name.to_string(), index).is_some() {
120 return Err(Error::config(format!(
121 "duplicate backend id or alias `{name}`"
122 )));
123 }
124 Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn load_rejects_external_backend_collisions() {
133 let temp = tempfile::tempdir().unwrap();
134 let dirs = Dirs::resolve_from(|key| match key {
135 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
136 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
137 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
138 _ => None,
139 })
140 .unwrap();
141 let plugins = dirs.config.join("plugins");
142 std::fs::create_dir_all(&plugins).unwrap();
143 let fixture = include_str!("../../tests/fixtures/declarative/static-backend.toml");
144 std::fs::write(
145 plugins.join("node.toml"),
146 fixture.replace("id = \"acme\"", "id = \"node\""),
147 )
148 .unwrap();
149
150 let error = match Registry::load(&dirs) {
151 Ok(_) => panic!("expected a duplicate backend error"),
152 Err(error) => error,
153 };
154 assert!(error.to_string().contains("duplicate backend id"));
155 }
156
157 #[test]
158 fn load_preserves_dynamic_backend_factories() {
159 let temp = tempfile::tempdir().unwrap();
160 let dirs = Dirs::resolve_from(|key| match key {
161 "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
162 "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
163 "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
164 _ => None,
165 })
166 .unwrap();
167
168 let registry = Registry::load(&dirs).unwrap();
169 assert_eq!(registry.get("npm:Prettier").unwrap().id(), "npm:prettier");
170 assert_eq!(
171 registry.get("github:cli/cli").unwrap().id(),
172 "github:cli/cli"
173 );
174 assert_eq!(
175 registry
176 .get("http:https://example.test/tool-{version}.zip")
177 .unwrap()
178 .id(),
179 "http:https://example.test/tool-{version}.zip"
180 );
181 }
182
183 #[test]
184 fn resolves_dynamic_namespaced_backends_without_shadowing_bare_npm() {
185 let registry = Registry::new();
186
187 assert_eq!(registry.get("npm").unwrap().id(), "npm");
188 assert_eq!(registry.get("go").unwrap().id(), "go");
189 assert_eq!(
190 registry.get("go:example.com/acme/tool").unwrap().id(),
191 "go:example.com/acme/tool"
192 );
193 assert_eq!(
194 registry.get("github:cli/cli").unwrap().id(),
195 "github:cli/cli"
196 );
197 assert_eq!(registry.get("npm:prettier").unwrap().id(), "npm:prettier");
198 assert_eq!(registry.get("npm:@antfu/ni").unwrap().id(), "npm:@antfu/ni");
199 assert_eq!(registry.get("npm:npm").unwrap().id(), "npm:npm");
200 assert_eq!(registry.get("cargo:RipGrep").unwrap().id(), "cargo:ripgrep");
201 assert_eq!(
202 registry
203 .get("cargo:https://github.com/BurntSushi/ripgrep.git")
204 .unwrap()
205 .id(),
206 "cargo:https://github.com/BurntSushi/ripgrep.git"
207 );
208 assert_eq!(
209 registry
210 .get("http:https://example.test/tool-{version}.tar.gz")
211 .unwrap()
212 .id(),
213 "http:https://example.test/tool-{version}.tar.gz"
214 );
215 }
216
217 #[test]
218 fn resolves_dynamic_namespaced_backends_with_lowercase_canonical_ids_and_paths() {
219 let registry = Registry::new();
220
221 let prettier = registry.get("npm:Prettier").unwrap();
222 assert_eq!(prettier.id(), "npm:prettier");
223 assert_eq!(
224 crate::dirs::sanitize_tool_id(prettier.id()),
225 std::path::PathBuf::from("npm/prettier")
226 );
227
228 let scoped = registry.get("npm:@Antfu/Ni").unwrap();
229 assert_eq!(scoped.id(), "npm:@antfu/ni");
230 assert_eq!(
231 crate::dirs::sanitize_tool_id(scoped.id()),
232 std::path::PathBuf::from("npm/@antfu/ni")
233 );
234 }
235
236 #[test]
237 fn rejects_invalid_dynamic_namespaced_backends() {
238 let registry = Registry::new();
239
240 for name in [
241 "npm:",
242 "npm:@antfu",
243 "npm:@antfu/ni/extra",
244 "github:",
245 "github:noslash",
246 "github:cli/cli/extra",
247 "cargo:http://example.test/ripgrep",
248 "go:Example.com/acme/tool",
249 "go:example.com/../tool",
250 "NPM:prettier",
251 "http:http://example.test/tool-{version}.zip",
252 "http:https://user@example.test/tool-{version}.zip",
253 "http:https://example.test/tool.zip",
254 ] {
255 assert!(
256 matches!(registry.get(name), Err(Error::UnknownBackend(id)) if id == name),
257 "expected `{name}` to remain an unknown backend"
258 );
259 }
260 }
261
262 #[test]
263 fn rejects_duplicate_dynamic_backend_prefixes() {
264 struct TestFactory;
265
266 impl DynamicBackendFactory for TestFactory {
267 fn prefix(&self) -> &'static str {
268 "test"
269 }
270
271 fn create(&self, _id: &str) -> Option<Arc<dyn Backend>> {
272 None
273 }
274 }
275
276 let error = match Registry::from_parts(
277 Vec::new(),
278 vec![Arc::new(TestFactory), Arc::new(TestFactory)],
279 ) {
280 Ok(_) => panic!("expected a duplicate dynamic backend prefix error"),
281 Err(error) => error,
282 };
283 assert!(error
284 .to_string()
285 .contains("duplicate dynamic backend prefix `test`"));
286 }
287}