Skip to main content

osdk_core/backend/
mod.rs

1//! The `Backend` trait — the uniform contract every SDK implements — plus the
2//! shared contexts and the backend registry.
3
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9
10use crate::config::Config;
11use crate::dirs::Dirs;
12use crate::error::{Error, Result};
13use crate::platform::Platform;
14use crate::source::Source;
15use crate::store::Cas;
16use crate::version::{ToolRequest, ToolVersion, VersionInfo};
17
18pub mod aube_host;
19pub mod bun;
20pub mod cargo_package;
21#[cfg(test)]
22mod contract;
23pub mod declarative;
24pub mod deno;
25pub mod dynamic;
26pub mod github;
27pub mod go;
28pub mod go_package;
29pub mod http;
30pub mod java;
31pub mod jvm_tools;
32pub mod native_tool;
33pub mod node;
34pub mod npm_cli;
35pub mod npm_package;
36pub mod pnpm;
37pub mod python;
38mod python_catalog;
39mod python_releases;
40pub mod registry;
41pub mod rust;
42pub mod yarn;
43
44/// Read-only context available to every backend operation.
45pub struct Ctx {
46    pub dirs: Dirs,
47    pub platform: Platform,
48    pub config: Config,
49    pub client: reqwest::Client,
50    pub cas: Arc<Cas>,
51    pub show_progress: bool,
52}
53
54/// Context for an install (adds progress preference; the pipeline handles locks).
55pub struct InstallCtx<'a> {
56    pub ctx: &'a Ctx,
57}
58
59/// The uniform interface for every SDK. Archive-based backends run the shared
60/// pipeline in `install`; delegate backends (rustup/corepack) shell out.
61#[async_trait]
62pub trait Backend: Send + Sync {
63    /// Canonical id, e.g. "node", "go", "python".
64    fn id(&self) -> &str;
65
66    /// Alternate names accepted on the CLI (e.g. "nodejs" -> "node").
67    fn aliases(&self) -> &[&str] {
68        &[]
69    }
70
71    /// The default source list for this backend (official + mirrors).
72    fn default_sources(&self) -> Vec<Source>;
73
74    /// A representative small URL used to speed-probe a source. Given a source's
75    /// download base, return a URL to fetch for measuring throughput.
76    fn probe_url(&self, ctx: &Ctx, source: &Source) -> Option<String>;
77
78    /// List installable versions (typically parsed from a remote index).
79    async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>>;
80
81    /// Resolve a request (e.g. `20`, `lts`) to a concrete version.
82    async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
83        // Default implementation: resolve against list_remote_versions.
84        use crate::version::{select_version, VersionSpec};
85        if let VersionSpec::Exact(v) = &req.spec {
86            let mut tv = ToolVersion::new(self.id(), v.clone());
87            tv.options = req.options.clone();
88            return Ok(tv);
89        }
90        let versions = self.list_remote_versions(ctx).await?;
91        let chosen = select_version(&req.spec, &versions).ok_or_else(|| Error::VersionResolve {
92            tool: self.id().to_string(),
93            spec: req.spec.to_string(),
94            hint: Some("no matching version found".into()),
95        })?;
96        let mut tv = ToolVersion::new(self.id(), chosen.version.clone());
97        tv.options = req.options.clone();
98        Ok(tv)
99    }
100
101    /// Install a concrete version.
102    async fn install(&self, ctx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()>;
103
104    /// Apply idempotent post-install actions to an already installed version.
105    fn ensure_post_install(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<()> {
106        Ok(())
107    }
108
109    /// Remove an installed version. Default removes the install dir.
110    async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
111        let dir = ctx.dirs.install_path(self.id(), &tv.version);
112        if dir.exists() {
113            std::fs::remove_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
114        }
115        Ok(())
116    }
117
118    /// List locally installed versions (dirs with a complete marker).
119    fn list_installed(&self, ctx: &Ctx) -> Result<Vec<String>> {
120        let base = ctx
121            .dirs
122            .installs
123            .join(crate::dirs::sanitize_tool_id(self.id()));
124        let mut out = Vec::new();
125        if base.exists() {
126            for entry in std::fs::read_dir(&base).map_err(|e| Error::io(&base, e))? {
127                let entry = entry.map_err(|e| Error::io(&base, e))?;
128                if !entry.path().is_dir() {
129                    continue;
130                }
131                let name = entry.file_name().to_string_lossy().to_string();
132                if name.starts_with('.') {
133                    continue;
134                }
135                if entry.path().join(".osdk-complete").is_file() {
136                    out.push(crate::dirs::decode_version_component(&name));
137                }
138            }
139        }
140        out.sort();
141        Ok(out)
142    }
143
144    /// Directories to add to PATH for an installed version (its `bin/` dirs).
145    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>>;
146
147    /// Environment variables to export when this version is active
148    /// (e.g. GOROOT, JAVA_HOME).
149    fn exec_env(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
150        Ok(BTreeMap::new())
151    }
152
153    /// Executable names this version exposes (used to generate shims).
154    fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>>;
155
156    /// Exact identity selected for a dynamic install. `None` retains inventory
157    /// discovery for backends whose artifact identity is learned at install.
158    fn dynamic_install_identity(
159        &self,
160        _ctx: &Ctx,
161        _tv: &ToolVersion,
162    ) -> Result<Option<crate::tool::InstallIdentity>> {
163        Ok(None)
164    }
165
166    /// Validate provider-specific evidence after shared inventory validation.
167    fn validate_dynamic_install(
168        &self,
169        _ctx: &Ctx,
170        _tv: &ToolVersion,
171        _install_root: &std::path::Path,
172        _identity: &crate::tool::InstallIdentity,
173    ) -> Result<bool> {
174        Ok(false)
175    }
176
177    /// Idiomatic version files this backend understands (e.g. `.nvmrc`).
178    fn idiomatic_files(&self) -> &[&str] {
179        &[]
180    }
181}
182
183/// Convenience: read the executable basenames present in a set of bin dirs.
184pub fn bin_names_in_dirs(dirs: &[PathBuf]) -> Vec<String> {
185    use std::collections::BTreeSet;
186    let mut names = BTreeSet::new();
187    for dir in dirs {
188        if let Ok(rd) = std::fs::read_dir(dir) {
189            for entry in rd.flatten() {
190                let path = entry.path();
191                if is_executable(&path) {
192                    if let Some(stem) = exe_stem(&path) {
193                        names.insert(stem);
194                    }
195                }
196            }
197        }
198    }
199    names.into_iter().collect()
200}
201
202#[cfg(unix)]
203fn is_executable(path: &std::path::Path) -> bool {
204    use std::os::unix::fs::PermissionsExt;
205    path.is_file()
206        && std::fs::metadata(path)
207            .map(|m| m.permissions().mode() & 0o111 != 0)
208            .unwrap_or(false)
209}
210
211#[cfg(windows)]
212fn is_executable(path: &std::path::Path) -> bool {
213    if !path.is_file() {
214        return false;
215    }
216    matches!(
217        path.extension()
218            .and_then(|e| e.to_str())
219            .map(|e| e.to_ascii_lowercase())
220            .as_deref(),
221        Some("exe") | Some("cmd") | Some("bat")
222    )
223}
224
225/// Basename without the platform executable extension.
226fn exe_stem(path: &std::path::Path) -> Option<String> {
227    let name = path.file_name()?.to_string_lossy().to_string();
228    #[cfg(windows)]
229    {
230        for ext in [".exe", ".cmd", ".bat"] {
231            if name.to_ascii_lowercase().ends_with(ext) {
232                return Some(name[..name.len() - ext.len()].to_string());
233            }
234        }
235    }
236    Some(name)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::version::{ToolRequest, VersionSpec};
243
244    struct MockBackend;
245
246    #[async_trait]
247    impl Backend for MockBackend {
248        fn id(&self) -> &str {
249            "mock"
250        }
251        fn default_sources(&self) -> Vec<Source> {
252            vec![]
253        }
254        fn probe_url(&self, _ctx: &Ctx, _s: &Source) -> Option<String> {
255            None
256        }
257        async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
258            Ok(vec![VersionInfo::stable("1.2.3")])
259        }
260        async fn install(&self, _ctx: &InstallCtx<'_>, _tv: &ToolVersion) -> Result<()> {
261            Ok(())
262        }
263        fn bin_paths(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<PathBuf>> {
264            Ok(vec![])
265        }
266        fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
267            Ok(vec![])
268        }
269    }
270
271    // Regression: the default resolve_version must carry request options into
272    // the resolved ToolVersion for EXACT specs too (rust profile, java
273    // distribution, python tag). Previously the exact-version early return
274    // dropped them.
275    #[tokio::test]
276    async fn exact_resolve_preserves_options() {
277        let mut req = ToolRequest {
278            backend: "mock".into(),
279            spec: VersionSpec::Exact("1.2.3".into()),
280            options: Default::default(),
281        };
282        req.options.insert("tag".into(), "20240224".into());
283
284        // Build a minimal Ctx; list_remote_versions isn't hit for exact specs.
285        let dirs = crate::dirs::Dirs::resolve_from(|k| match k {
286            "OSDK_DATA_DIR" => Some("/tmp/osdk-mock/data".into()),
287            "OSDK_CACHE_DIR" => Some("/tmp/osdk-mock/cache".into()),
288            "OSDK_CONFIG_DIR" => Some("/tmp/osdk-mock/cfg".into()),
289            _ => None,
290        })
291        .unwrap();
292        let ctx = Ctx {
293            dirs,
294            platform: crate::platform::Platform::current(),
295            config: crate::config::Config {
296                settings: Default::default(),
297                sources: Default::default(),
298                tools: Default::default(),
299                tool_configs: Default::default(),
300                global_tools: Default::default(),
301                global_tool_configs: Default::default(),
302                tool_origins: Default::default(),
303                aliases: Default::default(),
304                project_config_path: None,
305            },
306            client: reqwest::Client::new(),
307            cas: std::sync::Arc::new(crate::store::Cas::new("/tmp/osdk-mock/store")),
308            show_progress: false,
309        };
310        let tv = MockBackend.resolve_version(&ctx, &req).await.unwrap();
311        assert_eq!(tv.version, "1.2.3");
312        assert_eq!(tv.options.get("tag").map(|s| s.as_str()), Some("20240224"));
313    }
314
315    #[test]
316    fn list_installed_decodes_encoded_versions_and_preserves_legacy_names() {
317        let temporary = tempfile::tempdir().unwrap();
318        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
319            "OSDK_DATA_DIR" => Some(temporary.path().join("data").display().to_string()),
320            "OSDK_CACHE_DIR" => Some(temporary.path().join("cache").display().to_string()),
321            "OSDK_CONFIG_DIR" => Some(temporary.path().join("config").display().to_string()),
322            _ => None,
323        })
324        .unwrap();
325        for version in ["1.2.3", "release/2026", "Release-2026"] {
326            let install = dirs.install_path("mock", version);
327            std::fs::create_dir_all(&install).unwrap();
328            std::fs::write(install.join(".osdk-complete"), b"").unwrap();
329        }
330        let legacy = dirs.installs.join("mock").join("release%2F2026");
331        std::fs::create_dir_all(&legacy).unwrap();
332        std::fs::write(legacy.join(".osdk-complete"), b"").unwrap();
333        let ctx = Ctx {
334            dirs: dirs.clone(),
335            platform: crate::platform::Platform::current(),
336            config: crate::config::Config {
337                settings: Default::default(),
338                sources: Default::default(),
339                tools: Default::default(),
340                tool_configs: Default::default(),
341                global_tools: Default::default(),
342                global_tool_configs: Default::default(),
343                tool_origins: Default::default(),
344                aliases: Default::default(),
345                project_config_path: None,
346            },
347            client: reqwest::Client::new(),
348            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
349            show_progress: false,
350        };
351
352        assert_eq!(
353            MockBackend.list_installed(&ctx).unwrap(),
354            vec!["1.2.3", "Release-2026", "release%2F2026", "release/2026"]
355        );
356    }
357}