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(®istered_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;