/ src / lib.rs
lib.rs
  1  //! # Abundantis
  2  //!
  3  //! High-performance unified environment variable management from multiple sources.
  4  //!
  5  //! ## Quick Start
  6  //!
  7  #![cfg_attr(
  8      feature = "async",
  9      doc = r#"
 10  ```no_run
 11  use abundantis::{Abundantis, config::MonorepoProviderType};
 12  
 13  # #[tokio::main]
 14  # async fn example() -> abundantis::Result<()> {
 15  let _abundantis = Abundantis::builder()
 16      .root(".")
 17      .provider(MonorepoProviderType::Custom)
 18      .with_shell()
 19      .env_files(vec![".env", ".env.local"])
 20      .build()
 21      .await?;
 22  
 23  # Ok(())
 24  # }
 25  ```
 26  "#
 27  )]
 28  #![cfg_attr(
 29      not(feature = "async"),
 30      doc = r#"
 31  ```no_run
 32  use abundantis::{Abundantis, config::MonorepoProviderType};
 33  
 34  # fn example() -> abundantis::Result<()> {
 35  let _abundantis = Abundantis::builder()
 36      .root(".")
 37      .provider(MonorepoProviderType::Custom)
 38      .with_shell()
 39      .env_files(vec![".env", ".env.local"])
 40      .build()?;
 41  
 42  # Ok(())
 43  # }
 44  ```
 45  "#
 46  )]
 47  //!
 48  //! ## Architecture
 49  //!
 50  //! ```text
 51  //! ┌─────────────────────────────────────────────────────┐
 52  //! │                    Abundantis                 │
 53  //! │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │
 54  //! │  │  Source     │  │ Resolution  │  │  Workspace  │  │
 55  //! │  │  Registry   │──│    Engine   │──│   Manager   │  │
 56  //! │  └─────────────┘  └─────────────┘  └─────────────┘  │
 57  //! └─────────────────────────────────────────────────────────────┘
 58  //! ```
 59  //!
 60  //! ## Features
 61  //!
 62  //! - **Plugin Architecture**: Add custom sources via `EnvSource` trait
 63  //! - **Multiple Sources**: File, shell, memory, remote (prepared for future)
 64  //! - **Dependency Resolution**: Full interpolation with cycle detection
 65  //! - **Workspace Support**: Monorepo providers (Turbo, Nx, Lerna, etc.)
 66  //! - **Event System**: Async event bus for reactive updates
 67  //! - **Multi-level Cache**: Hot LRU cache + warm TTL cache
 68  //!
 69  //! ## Performance
 70  //!
 71  //! - Zero-copy parsing via `korni`
 72  //! - SIMD interpolation via `germi`
 73  //! - Lock-free concurrent access with `dashmap`
 74  //! - Cache-friendly data structures with `hashbrown`
 75  //! - Small string optimization with `compact_str`
 76  
 77  pub mod config;
 78  pub mod error;
 79  pub mod events;
 80  pub mod path_cache;
 81  pub mod resolution;
 82  pub mod selection;
 83  pub mod source;
 84  pub mod workspace;
 85  
 86  pub mod watch;
 87  pub mod watch_manager;
 88  
 89  use std::collections::HashMap;
 90  use std::path::{Path, PathBuf};
 91  use std::sync::Arc;
 92  
 93  #[cfg(feature = "async")]
 94  use maybe_async::must_be_async;
 95  #[cfg(not(feature = "async"))]
 96  use maybe_async::must_be_sync;
 97  
 98  pub use config::{
 99      AbundantisConfig, CacheConfig, InterpolationConfig, MonorepoProviderType, ResolutionConfig,
100      SourceDefaults, SourcesConfig,
101  };
102  pub use error::{AbundantisError, Diagnostic, DiagnosticCode, DiagnosticSeverity, Result};
103  #[cfg(feature = "async")]
104  pub use events::{AbundantisEvent, EventBus, EventSubscriber};
105  pub use path_cache::PathCache;
106  pub use resolution::{
107      CacheKey, DependencyGraph, ResolutionCache, ResolutionEngine, ResolvedVariable,
108  };
109  #[cfg(feature = "async")]
110  pub use source::AsyncEnvSource;
111  #[cfg(feature = "file")]
112  pub use source::FileSource;
113  #[cfg(feature = "file")]
114  pub use source::FileSourceManager;
115  #[cfg(feature = "shell")]
116  pub use source::ShellSource;
117  pub use source::{
118      EnvSource, MemorySource, ParsedVariable, Priority, SourceCapabilities, SourceId,
119      SourceRefreshOptions, SourceType, VariableSource,
120  };
121  #[cfg(all(feature = "watch", feature = "async"))]
122  pub use watch_manager::WatchManager;
123  pub use workspace::{PackageInfo, WorkspaceContext, WorkspaceManager};
124  
125  pub const VERSION: &str = env!("CARGO_PKG_VERSION");
126  
127  /// Options for Abundantis refresh operations.
128  
129  #[derive(Debug, Clone, Default)]
130  pub struct RefreshOptions {
131      pub preserve_file_config: bool,
132      /// Preserve shell source configuration
133      pub preserve_shell_config: bool,
134  
135      pub preserve_remote_config: bool,
136  
137      pub preserve_precedence: bool,
138  }
139  
140  impl RefreshOptions {
141      pub fn preserve_all() -> Self {
142          Self {
143              preserve_file_config: true,
144              preserve_shell_config: true,
145              preserve_remote_config: true,
146              preserve_precedence: true,
147          }
148      }
149  
150      pub fn reset_all() -> Self {
151          Self::default()
152      }
153  }
154  
155  pub struct Abundantis {
156      pub config: AbundantisConfig,
157      pub registry: Arc<source::SourceRegistry>,
158      pub resolution: Arc<resolution::ResolutionEngine>,
159      pub workspace: Arc<parking_lot::RwLock<workspace::WorkspaceManager>>,
160      cache: Arc<resolution::ResolutionCache>,
161      selector: Arc<selection::ActiveFileSelector>,
162      global_active_files: parking_lot::RwLock<Option<Vec<String>>>,
163      directory_active_files: parking_lot::RwLock<HashMap<PathBuf, Vec<String>>>,
164      path_to_source_id: parking_lot::RwLock<HashMap<PathBuf, source::SourceId>>,
165      path_cache: path_cache::PathCache,
166      #[cfg(feature = "async")]
167      event_bus: Arc<events::EventBus>,
168      #[cfg(not(feature = "async"))]
169      event_bus: Arc<events::EventBus>,
170  }
171  
172  impl Abundantis {
173      pub fn builder() -> core::AbundantisBuilder {
174          core::AbundantisBuilder::default()
175      }
176  
177      #[cfg_attr(feature = "async", must_be_async)]
178      #[cfg_attr(not(feature = "async"), must_be_sync)]
179      pub async fn get_for_file(
180          &self,
181          key: &str,
182          file_path: &std::path::Path,
183      ) -> crate::Result<Option<Arc<ResolvedVariable>>> {
184          let context = {
185              let workspace = self.workspace.read();
186              workspace
187                  .context_for_file(file_path)
188                  .ok_or_else(|| AbundantisError::Config {
189                      message: format!(
190                          "No workspace context found for file: {}",
191                          file_path.display()
192                      ),
193                      path: Some(file_path.to_path_buf()),
194                  })?
195          };
196  
197          let active_files = self.active_env_files(file_path);
198          self.get_in_context_with_filter(key, &context, &active_files)
199              .await
200      }
201  
202      #[cfg_attr(feature = "async", must_be_async)]
203      #[cfg_attr(not(feature = "async"), must_be_sync)]
204      pub async fn get_in_context(
205          &self,
206          key: &str,
207          context: &workspace::WorkspaceContext,
208      ) -> crate::Result<Option<Arc<ResolvedVariable>>> {
209          self.resolution.resolve(key, context, &self.registry).await
210      }
211  
212      #[cfg_attr(feature = "async", must_be_async)]
213      #[cfg_attr(not(feature = "async"), must_be_sync)]
214      pub async fn all_for_file(
215          &self,
216          file_path: &std::path::Path,
217      ) -> crate::Result<Vec<Arc<ResolvedVariable>>> {
218          let context = {
219              let workspace = self.workspace.read();
220              workspace
221                  .context_for_file(file_path)
222                  .ok_or_else(|| AbundantisError::Config {
223                      message: format!(
224                          "No workspace context found for file: {}",
225                          file_path.display()
226                      ),
227                      path: Some(file_path.to_path_buf()),
228                  })?
229          };
230  
231          let active_files = self.active_env_files(file_path);
232          self.all_in_context_with_filter(&context, &active_files)
233              .await
234      }
235  
236      #[cfg_attr(feature = "async", must_be_async)]
237      #[cfg_attr(not(feature = "async"), must_be_sync)]
238      pub async fn all_in_context(
239          &self,
240          context: &workspace::WorkspaceContext,
241      ) -> crate::Result<Vec<Arc<ResolvedVariable>>> {
242          self.resolution.all_variables(context, &self.registry).await
243      }
244  
245      #[cfg_attr(feature = "async", must_be_async)]
246      #[cfg_attr(not(feature = "async"), must_be_sync)]
247      async fn get_in_context_with_filter(
248          &self,
249          key: &str,
250          context: &workspace::WorkspaceContext,
251          active_files: &[PathBuf],
252      ) -> crate::Result<Option<Arc<ResolvedVariable>>> {
253          let file_source_ids = self.get_source_ids_for_paths(active_files);
254          self.resolution
255              .resolve_with_filter(key, context, &self.registry, Some(&file_source_ids))
256              .await
257      }
258  
259      #[cfg_attr(feature = "async", must_be_async)]
260      #[cfg_attr(not(feature = "async"), must_be_sync)]
261      async fn all_in_context_with_filter(
262          &self,
263          context: &workspace::WorkspaceContext,
264          active_files: &[PathBuf],
265      ) -> crate::Result<Vec<Arc<ResolvedVariable>>> {
266          let file_source_ids = self.get_source_ids_for_paths(active_files);
267  
268          self.resolution
269              .all_variables_with_filter(context, &self.registry, Some(&file_source_ids))
270              .await
271      }
272  
273      #[cfg(feature = "async")]
274      pub async fn refresh(&self, options: RefreshOptions) -> Result<()> {
275          self.refresh_inner(&options)?;
276          self.event_bus
277              .publish_async(events::AbundantisEvent::CacheInvalidated { scope: None })
278              .await;
279          Ok(())
280      }
281  
282      #[cfg(not(feature = "async"))]
283      pub fn refresh(&self, options: RefreshOptions) -> Result<()> {
284          self.refresh_inner(&options)
285      }
286  
287      fn refresh_inner(&self, options: &RefreshOptions) -> Result<()> {
288          let file_config_backup = if options.preserve_file_config {
289              let current_global = self.global_active_files.read().clone();
290  
291              if current_global.is_none() {
292                  let workspace = self.workspace.read();
293                  let root = workspace.root().to_path_buf();
294                  drop(workspace);
295  
296                  let auto_files = self.active_env_files(&root);
297                  let patterns: Vec<String> = auto_files
298                      .iter()
299                      .map(|p| p.to_string_lossy().to_string())
300                      .collect();
301  
302                  Some((
303                      if patterns.is_empty() {
304                          None
305                      } else {
306                          Some(patterns)
307                      },
308                      self.directory_active_files.read().clone(),
309                  ))
310              } else {
311                  Some((current_global, self.directory_active_files.read().clone()))
312              }
313          } else {
314              None
315          };
316  
317          let source_options = source::SourceRefreshOptions {
318              preserve_config: options.preserve_file_config,
319          };
320  
321          for source in self.registry.sync_sources_by_priority() {
322              source.refresh(&source_options);
323          }
324  
325          {
326              let workspace = self.workspace.write();
327              workspace.refresh()?;
328          }
329  
330          self.rediscover_file_sources()?;
331  
332          if let Some((global, directory)) = file_config_backup {
333              *self.global_active_files.write() = global;
334              *self.directory_active_files.write() = directory;
335          }
336  
337          self.cache.clear();
338          self.path_to_source_id.write().clear();
339  
340          Ok(())
341      }
342  
343      pub fn event_bus(&self) -> &events::EventBus {
344          &self.event_bus
345      }
346  
347      pub fn config(&self) -> &AbundantisConfig {
348          &self.config
349      }
350  
351      pub fn stats(&self) -> AbundantisStats {
352          AbundantisStats {
353              cached_variables: self.cache.len(),
354              source_count: self.registry.source_count(),
355          }
356      }
357  
358      pub fn set_active_files(&self, patterns: &[impl AsRef<str>]) {
359          let patterns_vec: Vec<String> = patterns.iter().map(|p| p.as_ref().to_string()).collect();
360          *self.global_active_files.write() = Some(patterns_vec);
361          self.path_to_source_id.write().clear();
362          self.cache.clear();
363      }
364  
365      pub fn set_active_files_for_directory(
366          &self,
367          directory: impl AsRef<Path>,
368          patterns: &[impl AsRef<str>],
369      ) {
370          let dir_path = directory.as_ref().to_path_buf();
371          let canonical_dir = self.path_cache.canonicalize(&dir_path);
372          let patterns_vec: Vec<String> = patterns.iter().map(|p| p.as_ref().to_string()).collect();
373          self.directory_active_files
374              .write()
375              .insert(canonical_dir, patterns_vec);
376          self.path_to_source_id.write().clear();
377          self.cache.clear();
378      }
379  
380      pub fn active_env_files(&self, file_path: impl AsRef<Path>) -> Vec<PathBuf> {
381          let workspace = self.workspace.read();
382          let global = self.global_active_files.read();
383          let directory_scoped = self.directory_active_files.read();
384  
385          self.selector.compute_active_files(
386              file_path.as_ref(),
387              global.as_deref(),
388              &directory_scoped,
389              &workspace,
390          )
391      }
392  
393      pub fn clear_active_files(&self) {
394          *self.global_active_files.write() = None;
395          self.path_to_source_id.write().clear();
396          self.cache.clear();
397      }
398  
399      pub fn clear_active_files_for_directory(&self, directory: impl AsRef<Path>) {
400          let dir_path = directory.as_ref().to_path_buf();
401          let canonical_dir = self.path_cache.canonicalize(&dir_path);
402          self.directory_active_files.write().remove(&canonical_dir);
403          self.path_to_source_id.write().clear();
404          self.cache.clear();
405      }
406  
407      pub fn clear_all_active_files(&self) {
408          self.clear_active_files();
409          self.directory_active_files.write().clear();
410      }
411  
412      #[cfg(feature = "async")]
413      pub async fn set_root(&self, new_root: impl AsRef<Path>) -> Result<()> {
414          self.set_root_inner(new_root.as_ref())?;
415          self.event_bus
416              .publish_async(events::AbundantisEvent::CacheInvalidated { scope: None })
417              .await;
418          Ok(())
419      }
420  
421      #[cfg(not(feature = "async"))]
422      pub fn set_root(&self, new_root: impl AsRef<Path>) -> Result<()> {
423          self.set_root_inner(new_root.as_ref())
424      }
425  
426      fn set_root_inner(&self, new_root: &Path) -> Result<()> {
427          let new_root = new_root.canonicalize().map_err(AbundantisError::Io)?;
428  
429          tracing::info!("Changing workspace root to: {:?}", new_root);
430  
431          let mut workspace_config = self.config.workspace.clone();
432  
433          if workspace_config.provider.is_none() {
434              if let Some(detected) = workspace::provider::ProviderRegistry::detect(&new_root) {
435                  tracing::info!("Auto-detected workspace provider: {:?}", detected);
436                  workspace_config.provider = Some(detected);
437              } else {
438                  tracing::info!("No workspace provider detected, defaulting to custom");
439                  workspace_config.provider = Some(config::MonorepoProviderType::Custom);
440                  if workspace_config.roots.is_empty() {
441                      workspace_config.roots.push(".".into());
442                  }
443              }
444          }
445  
446          let new_workspace = workspace::WorkspaceManager::with_root(new_root, &workspace_config)?;
447  
448          {
449              let mut workspace = self.workspace.write();
450              *workspace = new_workspace;
451          }
452  
453          self.rediscover_file_sources()?;
454  
455          self.cache.clear();
456          self.path_to_source_id.write().clear();
457  
458          Ok(())
459      }
460  
461      fn get_source_ids_for_paths(
462          &self,
463          paths: &[PathBuf],
464      ) -> std::collections::HashSet<source::SourceId> {
465          let mut cache = self.path_to_source_id.write();
466          let mut result = std::collections::HashSet::new();
467  
468          for path in paths {
469              let canonical = self.path_cache.canonicalize(path);
470  
471              if let Some(source_id) = cache.get(&canonical) {
472                  result.insert(source_id.clone());
473                  continue;
474              }
475  
476              let source_id = source::SourceId::from(format!("file:{}", canonical.display()));
477              cache.insert(canonical, source_id.clone());
478              result.insert(source_id);
479          }
480  
481          result
482      }
483  
484      #[cfg(feature = "file")]
485      fn rediscover_file_sources(&self) -> Result<()> {
486          use std::collections::HashSet;
487  
488          let workspace = self.workspace.read();
489          let mut discovered_paths: HashSet<PathBuf> = HashSet::new();
490  
491          for package in workspace.packages() {
492              for pattern in &self.config.workspace.env_files {
493                  let full_pattern = package.root.join(pattern.as_str());
494                  let pattern_str = full_pattern.to_string_lossy();
495  
496                  if let Ok(paths) = glob::glob(&pattern_str) {
497                      for entry in paths.flatten() {
498                          if entry.is_file() {
499                              if let Ok(canonical) = entry.canonicalize() {
500                                  discovered_paths.insert(canonical);
501                              } else {
502                                  discovered_paths.insert(entry);
503                              }
504                          }
505                      }
506                  }
507              }
508          }
509  
510          for path in &discovered_paths {
511              let source_id = source::SourceId::from(format!("file:{}", path.display()));
512              if !self.registry.is_registered(&source_id) {
513                  if let Ok(file_source) = source::FileSource::new(path) {
514                      tracing::info!("Discovered new env file: {}", path.display());
515                      self.registry
516                          .register_sync(Arc::new(file_source) as Arc<dyn source::EnvSource>);
517                  }
518              }
519          }
520  
521          let registered_paths = self.registry.registered_file_paths();
522          for registered_path in registered_paths {
523              if !discovered_paths.contains(&registered_path) && !registered_path.exists() {
524                  let source_id =
525                      source::SourceId::from(format!("file:{}", registered_path.display()));
526                  tracing::info!("Removing deleted env file: {}", registered_path.display());
527                  self.registry.unregister_sync(&source_id);
528              }
529          }
530  
531          Ok(())
532      }
533  
534      #[cfg(not(feature = "file"))]
535      fn rediscover_file_sources(&self) -> Result<()> {
536          Ok(())
537      }
538  }
539  
540  #[derive(Debug, Clone)]
541  pub struct AbundantisStats {
542      pub cached_variables: usize,
543      pub source_count: usize,
544  }
545  
546  mod core;