Skip to main content

osdk_core/backend/
registry.rs

1//! Backend registry: maps tool ids / aliases to backend instances.
2
3use 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    /// Build the registry with all compiled-in backends.
20    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    /// Build the registry with compiled-in backends plus schema-1 TOML
40    /// definitions from `<config>/plugins` and `<data>/plugins`.
41    ///
42    /// Config definitions load first. Duplicate ids, aliases, or definitions
43    /// are rejected rather than allowing an external backend to shadow another.
44    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}