1use 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
44pub 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
54pub struct InstallCtx<'a> {
56 pub ctx: &'a Ctx,
57}
58
59#[async_trait]
62pub trait Backend: Send + Sync {
63 fn id(&self) -> &str;
65
66 fn aliases(&self) -> &[&str] {
68 &[]
69 }
70
71 fn default_sources(&self) -> Vec<Source>;
73
74 fn probe_url(&self, ctx: &Ctx, source: &Source) -> Option<String>;
77
78 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>>;
80
81 async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
83 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 async fn install(&self, ctx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()>;
103
104 fn ensure_post_install(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<()> {
106 Ok(())
107 }
108
109 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 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 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>>;
146
147 fn exec_env(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
150 Ok(BTreeMap::new())
151 }
152
153 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>>;
155
156 fn dynamic_install_identity(
159 &self,
160 _ctx: &Ctx,
161 _tv: &ToolVersion,
162 ) -> Result<Option<crate::tool::InstallIdentity>> {
163 Ok(None)
164 }
165
166 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 fn idiomatic_files(&self) -> &[&str] {
179 &[]
180 }
181}
182
183pub 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
225fn 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 #[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 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}