discovery.rs
1 //! Provider binary discovery module. 2 //! 3 //! This module handles finding external provider binaries in the configured 4 //! providers path. All providers must be installed in a single configured 5 //! directory, following the naming convention `ecolog-provider-<name>`. 6 //! 7 //! ## Discovery Strategy 8 //! 9 //! 1. User configures `providers_path` (default: `~/.local/share/ecolog/providers`) 10 //! 2. User enables specific providers in config 11 //! 3. LSP looks for `{providers_path}/ecolog-provider-{name}` 12 //! 4. If binary not found, returns error with installation instructions 13 //! 14 //! ## No Auto-Discovery 15 //! 16 //! Unlike some systems that scan PATH or multiple directories, this module 17 //! uses explicit configuration only. This provides: 18 //! - Predictable behavior 19 //! - Security (no arbitrary PATH execution) 20 //! - Easy auditing and management 21 22 use std::collections::HashMap; 23 use std::path::{Path, PathBuf}; 24 25 use crate::error::SourceError; 26 27 /// Default providers directory path. 28 #[cfg(target_os = "macos")] 29 pub const DEFAULT_PROVIDERS_PATH: &str = "~/.local/share/ecolog/providers"; 30 31 #[cfg(target_os = "linux")] 32 pub const DEFAULT_PROVIDERS_PATH: &str = "~/.local/share/ecolog/providers"; 33 34 #[cfg(target_os = "windows")] 35 pub const DEFAULT_PROVIDERS_PATH: &str = "%LOCALAPPDATA%\\ecolog\\providers"; 36 37 /// Provider binary naming prefix. 38 pub const PROVIDER_BINARY_PREFIX: &str = "ecolog-provider-"; 39 40 /// Information about a discovered provider binary. 41 #[derive(Debug, Clone)] 42 pub struct ProviderBinaryInfo { 43 /// Provider ID (e.g., "doppler", "aws"). 44 pub id: String, 45 /// Full path to the binary. 46 pub path: PathBuf, 47 /// Whether the binary is executable. 48 pub executable: bool, 49 } 50 51 /// Provider discovery configuration. 52 #[derive(Debug, Clone)] 53 pub struct DiscoveryConfig { 54 /// Path to the providers directory. 55 pub providers_path: PathBuf, 56 /// Provider-specific binary overrides. 57 pub binary_overrides: HashMap<String, PathBuf>, 58 } 59 60 impl Default for DiscoveryConfig { 61 fn default() -> Self { 62 Self { 63 providers_path: expand_path(DEFAULT_PROVIDERS_PATH), 64 binary_overrides: HashMap::new(), 65 } 66 } 67 } 68 69 impl DiscoveryConfig { 70 /// Creates a new discovery config with the given providers path. 71 pub fn new(providers_path: impl AsRef<Path>) -> Self { 72 Self { 73 providers_path: providers_path.as_ref().to_path_buf(), 74 binary_overrides: HashMap::new(), 75 } 76 } 77 78 /// Adds a binary override for a specific provider. 79 pub fn with_override(mut self, provider_id: &str, binary_path: impl AsRef<Path>) -> Self { 80 self.binary_overrides 81 .insert(provider_id.to_string(), binary_path.as_ref().to_path_buf()); 82 self 83 } 84 } 85 86 /// Provider discovery service. 87 pub struct ProviderDiscovery { 88 config: DiscoveryConfig, 89 } 90 91 impl ProviderDiscovery { 92 /// Creates a new discovery service with the given config. 93 pub fn new(config: DiscoveryConfig) -> Self { 94 Self { config } 95 } 96 97 /// Creates a discovery service with default configuration. 98 pub fn with_defaults() -> Self { 99 Self::new(DiscoveryConfig::default()) 100 } 101 102 /// Finds the binary for a specific provider. 103 /// 104 /// Returns the path to the provider binary if found and executable, 105 /// or an error with installation instructions if not found. 106 pub fn find_provider(&self, provider_id: &str) -> Result<PathBuf, SourceError> { 107 // Check for binary override first 108 if let Some(override_path) = self.config.binary_overrides.get(provider_id) { 109 return self.validate_binary(provider_id, override_path); 110 } 111 112 // Look in the configured providers path 113 let binary_name = format!("{}{}", PROVIDER_BINARY_PREFIX, provider_id); 114 let binary_path = self.config.providers_path.join(&binary_name); 115 116 // On Windows, try with .exe extension 117 #[cfg(target_os = "windows")] 118 let binary_path = if !binary_path.exists() { 119 let with_exe = self.config.providers_path.join(format!("{}.exe", binary_name)); 120 if with_exe.exists() { 121 with_exe 122 } else { 123 binary_path 124 } 125 } else { 126 binary_path 127 }; 128 129 self.validate_binary(provider_id, &binary_path) 130 } 131 132 /// Validates that a binary exists and is executable. 133 fn validate_binary(&self, provider_id: &str, path: &Path) -> Result<PathBuf, SourceError> { 134 if !path.exists() { 135 return Err(SourceError::Remote { 136 provider: provider_id.into(), 137 reason: format!( 138 "Provider binary not found at {}. \n\n\ 139 Install via:\n \ 140 cargo install ecolog-provider-{} --root {}\n\n\ 141 Or download from:\n \ 142 https://github.com/ecolog/ecolog-provider-{}/releases", 143 path.display(), 144 provider_id, 145 self.config.providers_path.display(), 146 provider_id 147 ), 148 }); 149 } 150 151 #[cfg(unix)] 152 { 153 use std::os::unix::fs::PermissionsExt; 154 let metadata = std::fs::metadata(path).map_err(|e| SourceError::Remote { 155 provider: provider_id.into(), 156 reason: format!("Cannot read binary metadata: {}", e), 157 })?; 158 159 let permissions = metadata.permissions(); 160 if permissions.mode() & 0o111 == 0 { 161 return Err(SourceError::Remote { 162 provider: provider_id.into(), 163 reason: format!( 164 "Provider binary is not executable: {}. \n\ 165 Run: chmod +x {}", 166 path.display(), 167 path.display() 168 ), 169 }); 170 } 171 } 172 173 Ok(path.to_path_buf()) 174 } 175 176 /// Lists all provider binaries in the providers directory. 177 /// 178 /// This scans the directory for binaries matching the naming convention. 179 /// Note: This is for informational purposes only, not for auto-discovery. 180 pub fn list_installed(&self) -> Vec<ProviderBinaryInfo> { 181 let mut providers = Vec::new(); 182 183 if !self.config.providers_path.exists() { 184 return providers; 185 } 186 187 let entries = match std::fs::read_dir(&self.config.providers_path) { 188 Ok(entries) => entries, 189 Err(_) => return providers, 190 }; 191 192 for entry in entries.flatten() { 193 let file_name = entry.file_name(); 194 let name = file_name.to_string_lossy(); 195 196 // Check if it matches the naming convention 197 if let Some(provider_id) = name.strip_prefix(PROVIDER_BINARY_PREFIX) { 198 // Remove .exe suffix on Windows 199 #[cfg(target_os = "windows")] 200 let provider_id = provider_id.strip_suffix(".exe").unwrap_or(provider_id); 201 202 let path = entry.path(); 203 let executable = is_executable(&path); 204 205 providers.push(ProviderBinaryInfo { 206 id: provider_id.to_string(), 207 path, 208 executable, 209 }); 210 } 211 } 212 213 // Sort by provider ID for consistent ordering 214 providers.sort_by(|a, b| a.id.cmp(&b.id)); 215 216 providers 217 } 218 219 /// Returns the configured providers path. 220 pub fn providers_path(&self) -> &Path { 221 &self.config.providers_path 222 } 223 224 /// Checks if a provider is installed (binary exists and is executable). 225 pub fn is_installed(&self, provider_id: &str) -> bool { 226 self.find_provider(provider_id).is_ok() 227 } 228 } 229 230 /// Expands path with home directory and environment variables. 231 fn expand_path(path: &str) -> PathBuf { 232 let expanded = if path.starts_with('~') { 233 if let Some(home) = dirs_home() { 234 path.replacen('~', &home.to_string_lossy(), 1) 235 } else { 236 path.to_string() 237 } 238 } else { 239 path.to_string() 240 }; 241 242 // Expand environment variables 243 #[cfg(target_os = "windows")] 244 let expanded = expand_env_vars_windows(&expanded); 245 246 PathBuf::from(expanded) 247 } 248 249 /// Gets the user's home directory. 250 fn dirs_home() -> Option<PathBuf> { 251 #[cfg(unix)] 252 { 253 std::env::var_os("HOME").map(PathBuf::from) 254 } 255 #[cfg(windows)] 256 { 257 std::env::var_os("USERPROFILE").map(PathBuf::from) 258 } 259 } 260 261 /// Expands Windows environment variables like %LOCALAPPDATA%. 262 #[cfg(target_os = "windows")] 263 fn expand_env_vars_windows(path: &str) -> String { 264 let mut result = path.to_string(); 265 let re = regex::Regex::new(r"%([^%]+)%").unwrap(); 266 267 for cap in re.captures_iter(path) { 268 if let Some(var_name) = cap.get(1) { 269 if let Ok(value) = std::env::var(var_name.as_str()) { 270 result = result.replace(&cap[0], &value); 271 } 272 } 273 } 274 275 result 276 } 277 278 /// Checks if a path is an executable file. 279 fn is_executable(path: &Path) -> bool { 280 if !path.is_file() { 281 return false; 282 } 283 284 #[cfg(unix)] 285 { 286 use std::os::unix::fs::PermissionsExt; 287 if let Ok(metadata) = std::fs::metadata(path) { 288 return metadata.permissions().mode() & 0o111 != 0; 289 } 290 false 291 } 292 293 #[cfg(windows)] 294 { 295 // On Windows, check for executable extensions 296 if let Some(ext) = path.extension() { 297 let ext = ext.to_string_lossy().to_lowercase(); 298 return ext == "exe" || ext == "cmd" || ext == "bat"; 299 } 300 false 301 } 302 303 #[cfg(not(any(unix, windows)))] 304 { 305 true // Assume executable on other platforms 306 } 307 } 308 309 #[cfg(test)] 310 mod tests { 311 use super::*; 312 use std::fs; 313 use tempfile::TempDir; 314 315 #[test] 316 fn test_default_config() { 317 let config = DiscoveryConfig::default(); 318 assert!(!config.providers_path.as_os_str().is_empty()); 319 } 320 321 #[test] 322 fn test_find_provider_not_found() { 323 let temp_dir = TempDir::new().unwrap(); 324 let config = DiscoveryConfig::new(temp_dir.path()); 325 let discovery = ProviderDiscovery::new(config); 326 327 let result = discovery.find_provider("nonexistent"); 328 assert!(result.is_err()); 329 } 330 331 #[test] 332 #[cfg(unix)] 333 fn test_find_provider_found() { 334 use std::os::unix::fs::PermissionsExt; 335 336 let temp_dir = TempDir::new().unwrap(); 337 let binary_path = temp_dir.path().join("ecolog-provider-test"); 338 339 // Create a dummy executable 340 fs::write(&binary_path, "#!/bin/bash\necho test").unwrap(); 341 let mut perms = fs::metadata(&binary_path).unwrap().permissions(); 342 perms.set_mode(0o755); 343 fs::set_permissions(&binary_path, perms).unwrap(); 344 345 let config = DiscoveryConfig::new(temp_dir.path()); 346 let discovery = ProviderDiscovery::new(config); 347 348 let result = discovery.find_provider("test"); 349 assert!(result.is_ok()); 350 assert_eq!(result.unwrap(), binary_path); 351 } 352 353 #[test] 354 #[cfg(unix)] 355 fn test_find_provider_not_executable() { 356 let temp_dir = TempDir::new().unwrap(); 357 let binary_path = temp_dir.path().join("ecolog-provider-test"); 358 359 // Create a non-executable file 360 fs::write(&binary_path, "not executable").unwrap(); 361 362 let config = DiscoveryConfig::new(temp_dir.path()); 363 let discovery = ProviderDiscovery::new(config); 364 365 let result = discovery.find_provider("test"); 366 assert!(result.is_err()); 367 assert!(result.unwrap_err().to_string().contains("not executable")); 368 } 369 370 #[test] 371 fn test_binary_override() { 372 let temp_dir = TempDir::new().unwrap(); 373 let custom_path = temp_dir.path().join("custom-provider"); 374 375 #[cfg(unix)] 376 { 377 use std::os::unix::fs::PermissionsExt; 378 fs::write(&custom_path, "#!/bin/bash").unwrap(); 379 let mut perms = fs::metadata(&custom_path).unwrap().permissions(); 380 perms.set_mode(0o755); 381 fs::set_permissions(&custom_path, perms).unwrap(); 382 } 383 384 #[cfg(windows)] 385 { 386 let custom_path = temp_dir.path().join("custom-provider.exe"); 387 fs::write(&custom_path, "binary content").unwrap(); 388 } 389 390 let config = DiscoveryConfig::new(temp_dir.path()).with_override("test", &custom_path); 391 let discovery = ProviderDiscovery::new(config); 392 393 let result = discovery.find_provider("test"); 394 assert!(result.is_ok()); 395 } 396 397 #[test] 398 #[cfg(unix)] 399 fn test_list_installed() { 400 use std::os::unix::fs::PermissionsExt; 401 402 let temp_dir = TempDir::new().unwrap(); 403 404 // Create some provider binaries 405 for name in &["ecolog-provider-doppler", "ecolog-provider-aws"] { 406 let path = temp_dir.path().join(name); 407 fs::write(&path, "#!/bin/bash").unwrap(); 408 let mut perms = fs::metadata(&path).unwrap().permissions(); 409 perms.set_mode(0o755); 410 fs::set_permissions(&path, perms).unwrap(); 411 } 412 413 // Create a non-provider file 414 fs::write(temp_dir.path().join("other-file"), "not a provider").unwrap(); 415 416 let config = DiscoveryConfig::new(temp_dir.path()); 417 let discovery = ProviderDiscovery::new(config); 418 419 let installed = discovery.list_installed(); 420 assert_eq!(installed.len(), 2); 421 assert_eq!(installed[0].id, "aws"); 422 assert_eq!(installed[1].id, "doppler"); 423 assert!(installed[0].executable); 424 assert!(installed[1].executable); 425 } 426 427 #[test] 428 fn test_is_installed() { 429 let temp_dir = TempDir::new().unwrap(); 430 let config = DiscoveryConfig::new(temp_dir.path()); 431 let discovery = ProviderDiscovery::new(config); 432 433 assert!(!discovery.is_installed("doppler")); 434 } 435 }