/ src / source / remote / discovery.rs
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  }