workspace_tools/lib.rs
1//! Universal workspace-relative path resolution for Rust projects
2//!
3//! This crate provides consistent, reliable path management regardless of execution context
4//! or working directory. It solves common path resolution issues in software projects by
5//! leveraging cargo's environment variable injection system.
6//!
7//! ## problem solved
8//!
9//! - **execution context dependency** : paths break when code runs from different directories
10//! - **environment inconsistency** : different developers have different working directory habits
11//! - **testing fragility** : tests fail when run from different locations
12//! - **ci/cd brittleness** : automated systems may execute from unexpected directories
13//!
14//! ## quick start
15//!
16//! 1. Configure cargo in workspace root `.cargo/config.toml` :
17//! ```toml
18//! [env]
19//! WORKSPACE_PATH = { value = ".", relative = true }
20//! ```
21//!
22//! 2. Use in your code :
23//! ```rust
24//! use workspace_tools::{ workspace, WorkspaceError };
25//!
26//! # fn main() -> Result< (), WorkspaceError >
27//! # {
28//! // get workspace instance
29//! let ws = workspace()?;
30//!
31//! // resolve workspace-relative paths
32//! let config_path = ws.config_dir().join( "app.toml" );
33//! let data_path = ws.data_dir().join( "cache.db" );
34//! # Ok( () )
35//! # }
36//! ```
37//!
38//! ## workspace resolution strategies
39//!
40//! the crate supports multiple resolution strategies to work in both development and
41//! installed contexts. the `workspace()` function tries strategies in priority order:
42//!
43//! 1. **cargo workspace** - detected via `Cargo.toml` metadata (development)
44//! 2. **`WORKSPACE_PATH` env** - set by `.cargo/config.toml` (development)
45//! 3. **git root** - searches for `.git` directory with `Cargo.toml` (development)
46//! 4. **`$PRO` env** - user-configured project root (installed applications)
47//! 5. **`$HOME` directory** - universal fallback (installed applications)
48//! 6. **current directory** - last resort fallback
49//!
50//! ### for installed applications
51//!
52//! when your cli tool is installed via `cargo install`, workspace resolution automatically
53//! falls back to user-configured locations:
54//!
55//! ```bash
56//! # option 1: $PRO (recommended for multi-project users)
57//! export PRO=~/pro
58//! mkdir -p ~/pro/secret
59//!
60//! # option 2: $HOME (simple for casual users)
61//! mkdir -p ~/secret
62//! ```
63//!
64//! this enables installed binaries to load workspace-level secrets and configurations
65//! without requiring `WORKSPACE_PATH` to be set globally.
66//!
67//! ## features
68//!
69//! - **`glob`** : enables pattern-based resource discovery
70//! - **`secrets`** : provides secure configuration file handling utilities
71//! - **`secure`** : enables memory-safe secret handling with the secrecy crate
72//! - **`serde`** : provides configuration loading with serde support
73//! - **`validation`** : enables configuration validation with JSON Schema
74//!
75//! ## security best practices
76//!
77//! when using the `secure` feature for secret management :
78//!
79//! - secrets are wrapped in `SecretString` types that prevent accidental exposure
80//! - debug output automatically redacts secret values
81//! - secrets require explicit `expose_secret()` calls for access
82//! - use the `SecretInjectable` trait for automatic configuration injection
83//! - validate secret strength with `validate_secret()` method
84//! - secrets are zeroized from memory when dropped
85
86#![ warn( missing_docs ) ]
87
88use std ::
89{
90 env,
91 path :: { Path, PathBuf },
92};
93
94use std ::collections ::HashMap;
95
96#[ cfg( feature = "glob" ) ]
97use glob ::glob;
98
99#[ cfg( feature = "secrets" ) ]
100use std ::fs;
101
102#[ cfg( feature = "validation" ) ]
103use jsonschema ::Validator;
104
105#[ cfg( feature = "validation" ) ]
106use schemars ::JsonSchema;
107
108#[ cfg( feature = "secure" ) ]
109use secrecy :: { SecretString, ExposeSecret };
110
111
112/// workspace path resolution errors
113#[ derive( Debug, Clone ) ]
114#[ non_exhaustive ]
115pub enum WorkspaceError
116{
117 /// configuration parsing error
118 ConfigurationError( String ),
119 /// environment variable not found
120 EnvironmentVariableMissing( String ),
121 /// glob pattern error
122 #[ cfg( feature = "glob" ) ]
123 GlobError( String ),
124 /// io error during file operations
125 IoError( String ),
126 /// path does not exist
127 PathNotFound( PathBuf ),
128 /// path is outside workspace boundaries
129 PathOutsideWorkspace( PathBuf ),
130 /// cargo metadata error
131 CargoError( String ),
132 /// toml parsing error
133 TomlError( String ),
134 /// serde deserialization error
135 #[ cfg( feature = "serde" ) ]
136 SerdeError( String ),
137 /// config validation error
138 #[ cfg( feature = "validation" ) ]
139 ValidationError( String ),
140 /// secret validation error
141 #[ cfg( feature = "secure" ) ]
142 SecretValidationError( String ),
143 /// secret injection error
144 #[ cfg( feature = "secure" ) ]
145 SecretInjectionError( String ),
146}
147
148impl core::fmt::Display for WorkspaceError
149{
150 #[ inline ]
151 #[ allow( clippy::elidable_lifetime_names ) ]
152 fn fmt< 'a >( &self, f: &mut core::fmt::Formatter< 'a > ) -> core::fmt::Result
153 {
154 match self
155 {
156 WorkspaceError::ConfigurationError( msg ) =>
157 write!( f, "configuration error: {msg}" ),
158 WorkspaceError::EnvironmentVariableMissing( var ) =>
159 write!( f, "environment variable '{var}' not found. ensure .cargo/config.toml is properly configured with WORKSPACE_PATH" ),
160 #[ cfg( feature = "glob" ) ]
161 WorkspaceError::GlobError( msg ) =>
162 write!( f, "glob pattern error: {msg}" ),
163 WorkspaceError::IoError( msg ) =>
164 write!( f, "io error: {msg}" ),
165 WorkspaceError::PathNotFound( path ) =>
166 write!( f, "path not found: {}. ensure the workspace structure is properly initialized", path.display() ),
167 WorkspaceError::PathOutsideWorkspace( path ) =>
168 write!( f, "path is outside workspace boundaries: {}", path.display() ),
169 WorkspaceError::CargoError( msg ) =>
170 write!( f, "cargo metadata error: {msg}" ),
171 WorkspaceError::TomlError( msg ) =>
172 write!( f, "toml parsing error: {msg}" ),
173 #[ cfg( feature = "serde" ) ]
174 WorkspaceError::SerdeError( msg ) =>
175 write!( f, "serde error: {msg}" ),
176 #[ cfg( feature = "validation" ) ]
177 WorkspaceError::ValidationError( msg ) =>
178 write!( f, "config validation error: {msg}" ),
179 #[ cfg( feature = "secure" ) ]
180 WorkspaceError::SecretValidationError( msg ) =>
181 write!( f, "secret validation error: {msg}" ),
182 #[ cfg( feature = "secure" ) ]
183 WorkspaceError::SecretInjectionError( msg ) =>
184 write!( f, "secret injection error: {msg}" ),
185 }
186 }
187}
188
189impl core ::error ::Error for WorkspaceError {}
190
191/// result type for workspace operations
192pub type Result< T > = core ::result ::Result< T, WorkspaceError >;
193
194/// trait for types that support automatic secret injection
195///
196/// configuration types can implement this trait to enable automatic
197/// secret injection from workspace secret files
198#[ cfg( feature = "secure" ) ]
199pub trait SecretInjectable
200{
201 /// inject a secret value for the given key
202 ///
203 /// # Errors
204 ///
205 /// returns error if the key is not recognized or injection fails
206 fn inject_secret( &mut self, key: &str, value: String ) -> Result< () >;
207
208 /// validate all injected secrets meet security requirements
209 ///
210 /// # Errors
211 ///
212 /// returns error if any secret fails validation
213 fn validate_secrets( &self ) -> Result< () >;
214}
215
216/// workspace path resolver providing centralized access to workspace-relative paths
217///
218/// the workspace struct encapsulates workspace root detection and provides methods
219/// for resolving standard directory paths and joining workspace-relative paths safely.
220#[ derive( Debug, Clone ) ]
221pub struct Workspace
222{
223 root: PathBuf,
224}
225
226impl Workspace
227{
228 /// create workspace from a given root path
229 ///
230 /// # Arguments
231 ///
232 /// * `root` - the root directory path for the workspace
233 ///
234 /// # Examples
235 ///
236 /// ```rust
237 /// use workspace_tools ::Workspace;
238 /// use std ::path ::PathBuf;
239 ///
240 /// let workspace = Workspace ::new( PathBuf ::from( "/path/to/workspace" ) );
241 /// ```
242 #[ must_use ]
243 #[ inline ]
244 pub fn new< P: Into< PathBuf > >( root: P ) -> Self
245 {
246 let root = root.into();
247 let root = Self ::cleanup_path( root );
248 Self { root }
249 }
250
251 /// resolve workspace from environment variables
252 ///
253 /// reads the `WORKSPACE_PATH` environment variable set by cargo configuration
254 /// and validates that the workspace root exists.
255 ///
256 /// # errors
257 ///
258 /// returns error if :
259 /// - `WORKSPACE_PATH` environment variable is not set
260 /// - the path specified by `WORKSPACE_PATH` does not exist
261 ///
262 /// # examples
263 ///
264 /// ```rust
265 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
266 /// use workspace_tools ::Workspace;
267 ///
268 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
269 /// let workspace = Workspace ::resolve()?;
270 /// println!( "workspace root: {}", workspace.root().display() );
271 /// # Ok(())
272 /// # }
273 /// ```
274 ///
275 /// # Errors
276 ///
277 /// Returns an error if the workspace path environment variable is not set or the path doesn't exist.
278 #[ inline ]
279 pub fn resolve() -> Result< Self >
280 {
281 let root = Self ::get_env_path( "WORKSPACE_PATH" )?;
282
283 if !root.exists()
284 {
285 return Err( WorkspaceError::PathNotFound( root ) );
286 }
287
288 Ok( Self { root } )
289 }
290
291 /// resolve workspace with extended fallback strategies
292 ///
293 /// tries multiple strategies to find workspace root, including user-configured
294 /// locations for installed CLI applications:
295 ///
296 /// 1. cargo workspace detection (developer context)
297 /// 2. `WORKSPACE_PATH` environment variable (cargo operations)
298 /// 3. git repository root with Cargo.toml (developer context)
299 /// 4. `$PRO` environment variable (user-configured project root)
300 /// 5. `$HOME` directory (universal fallback)
301 /// 6. current working directory (last resort)
302 ///
303 /// this method is designed for CLI applications that need to work both during
304 /// development (via `cargo run`) and after installation (via `cargo install`).
305 ///
306 /// # examples
307 ///
308 /// ```rust
309 /// use workspace_tools ::Workspace;
310 ///
311 /// // this will always succeed with some workspace root
312 /// let workspace = Workspace ::resolve_with_extended_fallbacks();
313 /// ```
314 ///
315 /// # resolution priority
316 ///
317 /// **developer contexts** (cargo operations):
318 /// - `from_cargo_workspace()` → finds cargo workspace via metadata
319 /// - `resolve()` → uses `WORKSPACE_PATH` from .cargo/config.toml
320 /// - `from_git_root()` → searches upward for .git + Cargo.toml
321 ///
322 /// **user contexts** (installed binaries):
323 /// - `from_pro_env()` → uses `$PRO` environment variable
324 /// - `from_home_dir()` → uses `$HOME` or `%USERPROFILE%`
325 ///
326 /// **fallback**:
327 /// - `from_cwd()` → current working directory
328 #[ must_use ]
329 #[ inline ]
330 pub fn resolve_with_extended_fallbacks() -> Self
331 {
332 Self ::from_cargo_workspace()
333 .or_else( |_| Self ::resolve() )
334 .or_else( |_| Self ::from_git_root() )
335 .or_else( |_| Self ::from_pro_env() ) // ← NEW: $PRO fallback
336 .or_else( |_| Self ::from_home_dir() ) // ← NEW: $HOME fallback
337 .unwrap_or_else( |_| Self ::from_cwd() )
338 }
339
340 /// resolve workspace with fallback strategies
341 ///
342 /// # deprecated
343 ///
344 /// use `resolve_with_extended_fallbacks()` instead. this method lacks
345 /// support for installed CLI application contexts ($PRO and $HOME fallbacks).
346 ///
347 /// # migration
348 ///
349 /// ```rust
350 /// // old:
351 /// # use workspace_tools ::Workspace;
352 /// let ws = Workspace ::resolve_or_fallback();
353 ///
354 /// // new:
355 /// let ws = Workspace ::resolve_with_extended_fallbacks();
356 /// ```
357 ///
358 /// # examples
359 ///
360 /// ```rust
361 /// use workspace_tools ::Workspace;
362 ///
363 /// // this will always succeed with some workspace root
364 /// let workspace = Workspace ::resolve_or_fallback();
365 /// ```
366 #[ deprecated(
367 since = "0.8.0",
368 note = "use `resolve_with_extended_fallbacks()` for installed CLI app support"
369 ) ]
370 #[ must_use ]
371 #[ inline ]
372 pub fn resolve_or_fallback() -> Self
373 {
374 {
375 Self ::from_cargo_workspace()
376 .or_else( |_| Self ::resolve() )
377 .or_else( |_| Self ::from_current_dir() )
378 .or_else( |_| Self ::from_git_root() )
379 .unwrap_or_else( |_| Self ::from_cwd() )
380 }
381 }
382
383 /// create workspace from current working directory
384 ///
385 /// # Errors
386 ///
387 /// returns error if current directory cannot be accessed
388 #[ inline ]
389 pub fn from_current_dir() -> Result< Self >
390 {
391 let root = env ::current_dir()
392 .map_err( | e | WorkspaceError::IoError( e.to_string() ) )?;
393 Ok( Self { root } )
394 }
395
396 /// create workspace from git repository root
397 ///
398 /// searches upward from current directory for .git directory
399 ///
400 /// # Errors
401 ///
402 /// returns error if current directory cannot be accessed or no .git directory found
403 #[ inline ]
404 pub fn from_git_root() -> Result< Self >
405 {
406 let mut current = env ::current_dir()
407 .map_err( | e | WorkspaceError::IoError( e.to_string() ) )?;
408
409 loop
410 {
411 if current.join( ".git" ).exists()
412 {
413 return Ok( Self { root: current } );
414 }
415
416 match current.parent()
417 {
418 Some( parent ) => current = parent.to_path_buf(),
419 None => return Err( WorkspaceError::PathNotFound( current ) ),
420 }
421 }
422 }
423
424 /// create workspace from current working directory (infallible)
425 ///
426 /// this method will not fail - it uses current directory or root as fallback
427 #[ must_use ]
428 #[ inline ]
429 pub fn from_cwd() -> Self
430 {
431 let root = env ::current_dir().unwrap_or_else( |_| PathBuf ::from( "/" ) );
432 Self { root }
433 }
434
435 /// create workspace from $PRO environment variable
436 ///
437 /// intended for users who organize projects under a common root directory.
438 /// the $PRO environment variable should point to the projects root.
439 ///
440 /// # setup
441 ///
442 /// ```bash
443 /// # linux/mac
444 /// export PRO=~/pro
445 /// echo 'export PRO=~/pro' >> ~/.bashrc
446 ///
447 /// # windows
448 /// set PRO=%USERPROFILE%\pro
449 /// setx PRO "%USERPROFILE%\pro"
450 /// ```
451 ///
452 /// # examples
453 ///
454 /// ```rust
455 /// use workspace_tools ::Workspace;
456 ///
457 /// // user has: export PRO=~/pro
458 /// # std ::env ::set_var( "PRO", std ::env ::current_dir().unwrap() );
459 /// let workspace = Workspace ::from_pro_env().unwrap();
460 /// // workspace.root() → /home/user/pro
461 /// ```
462 ///
463 /// # Errors
464 ///
465 /// returns error if:
466 /// - $PRO environment variable is not set
467 /// - path specified by $PRO does not exist
468 ///
469 /// # use cases
470 ///
471 /// - installed CLI tools needing workspace-level secrets
472 /// - multi-project users with organized directory structure
473 /// - CI/CD environments with standardized project layouts
474 #[ inline ]
475 pub fn from_pro_env() -> Result< Self >
476 {
477 let pro_path = env ::var( "PRO" )
478 .map_err( |_| WorkspaceError::EnvironmentVariableMissing( "PRO".to_string() ) )?;
479
480 let root = PathBuf ::from( pro_path );
481
482 if !root.exists()
483 {
484 return Err( WorkspaceError::PathNotFound( root ) );
485 }
486
487 let root = Self ::cleanup_path( root );
488 Ok( Self { root } )
489 }
490
491 /// create workspace from user home directory
492 ///
493 /// universal fallback using the standard home directory location.
494 /// works cross-platform by checking both unix ($HOME) and windows (%USERPROFILE%).
495 ///
496 /// # examples
497 ///
498 /// ```rust
499 /// use workspace_tools ::Workspace;
500 ///
501 /// let workspace = Workspace ::from_home_dir().unwrap();
502 /// // linux/mac: workspace.root() → /home/user
503 /// // windows: workspace.root() → C:\Users\user
504 /// ```
505 ///
506 /// # Errors
507 ///
508 /// returns error if:
509 /// - neither $HOME nor %USERPROFILE% environment variables are set
510 /// - resolved path does not exist
511 ///
512 /// # use cases
513 ///
514 /// - simple secret storage in ~/secret/ directory
515 /// - casual users without complex project organization
516 /// - minimal configuration requirement for CLI tools
517 #[ inline ]
518 pub fn from_home_dir() -> Result< Self >
519 {
520 let home_path = env ::var( "HOME" )
521 .or_else( |_| env ::var( "USERPROFILE" ) ) // windows compatibility
522 .map_err( |_| WorkspaceError::EnvironmentVariableMissing(
523 "HOME or USERPROFILE".to_string()
524 ) )?;
525
526 let root = PathBuf ::from( home_path );
527
528 if !root.exists()
529 {
530 return Err( WorkspaceError::PathNotFound( root ) );
531 }
532
533 let root = Self ::cleanup_path( root );
534 Ok( Self { root } )
535 }
536
537 /// get workspace root directory
538 ///
539 /// # Path Normalization Guarantees
540 ///
541 /// the returned path is guaranteed to be:
542 /// - absolute (not relative)
543 /// - normalized (no `/./ ` or trailing `/.`)
544 /// - preserves symlinks (does not resolve to canonical path)
545 ///
546 /// # examples
547 ///
548 /// ```rust
549 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
550 /// use workspace_tools ::workspace;
551 ///
552 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
553 /// let ws = workspace()?;
554 /// let root = ws.root();
555 ///
556 /// // always absolute
557 /// assert!( root.is_absolute() );
558 ///
559 /// // never contains "/./"
560 /// assert!( !root.to_string_lossy().contains( "/./" ) );
561 ///
562 /// // never ends with "/."
563 /// assert!( !root.to_string_lossy().ends_with( "/." ) );
564 ///
565 /// // clean path joining
566 /// let secret_dir = root.join( "secret" );
567 /// // produces: "/path/to/workspace/secret" not "/path/to/workspace/./secret"
568 /// # Ok(())
569 /// # }
570 /// ```
571 #[ must_use ]
572 #[ inline ]
573 pub fn root( &self ) -> &Path
574 {
575 &self.root
576 }
577
578 /// join path components relative to workspace root
579 ///
580 /// # examples
581 ///
582 /// ```rust
583 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
584 /// use workspace_tools ::workspace;
585 ///
586 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
587 /// let ws = workspace()?;
588 /// let config_file = ws.join( "config/app.toml" );
589 /// # Ok(())
590 /// # }
591 /// ```
592 #[ inline ]
593 pub fn join< P: AsRef< Path > >( &self, path: P ) -> PathBuf
594 {
595 self.root.join( path )
596 }
597
598 /// get standard configuration directory
599 ///
600 /// returns `workspace_root/config`
601 #[ must_use ]
602 #[ inline ]
603 pub fn config_dir( &self ) -> PathBuf
604 {
605 self.root.join( "config" )
606 }
607
608 /// get standard data directory
609 ///
610 /// returns `workspace_root/data`
611 #[ must_use ]
612 #[ inline ]
613 pub fn data_dir( &self ) -> PathBuf
614 {
615 self.root.join( "data" )
616 }
617
618 /// get standard logs directory
619 ///
620 /// returns `workspace_root/logs`
621 #[ must_use ]
622 #[ inline ]
623 pub fn logs_dir( &self ) -> PathBuf
624 {
625 self.root.join( "logs" )
626 }
627
628 /// get standard documentation directory
629 ///
630 /// returns `workspace_root/docs`
631 #[ must_use ]
632 #[ inline ]
633 pub fn docs_dir( &self ) -> PathBuf
634 {
635 self.root.join( "docs" )
636 }
637
638 /// get standard tests directory
639 ///
640 /// returns `workspace_root/tests`
641 #[ must_use ]
642 #[ inline ]
643 pub fn tests_dir( &self ) -> PathBuf
644 {
645 self.root.join( "tests" )
646 }
647
648 /// get workspace metadata directory
649 ///
650 /// returns `workspace_root/.workspace`
651 #[ must_use ]
652 #[ inline ]
653 pub fn workspace_dir( &self ) -> PathBuf
654 {
655 self.root.join( ".workspace" )
656 }
657
658 /// get path to workspace cargo.toml
659 ///
660 /// returns `workspace_root/Cargo.toml`
661 #[ must_use ]
662 #[ inline ]
663 pub fn cargo_toml( &self ) -> PathBuf
664 {
665 self.root.join( "Cargo.toml" )
666 }
667
668 /// get path to workspace readme
669 ///
670 /// returns `workspace_root/readme.md`
671 #[ must_use ]
672 #[ inline ]
673 pub fn readme( &self ) -> PathBuf
674 {
675 self.root.join( "readme.md" )
676 }
677
678 /// validate workspace structure
679 ///
680 /// checks that workspace root exists and is accessible
681 ///
682 /// # Errors
683 ///
684 /// returns error if workspace root is not accessible or is not a directory
685 #[ inline ]
686 pub fn validate( &self ) -> Result< () >
687 {
688 if !self.root.exists()
689 {
690 return Err( WorkspaceError::PathNotFound( self.root.clone() ) );
691 }
692
693 if !self.root.is_dir()
694 {
695 return Err( WorkspaceError::ConfigurationError(
696 format!( "workspace root is not a directory: {}", self.root.display() )
697 ) );
698 }
699
700 Ok( () )
701 }
702
703 /// check if a path is within workspace boundaries
704 ///
705 /// # examples
706 ///
707 /// ```rust
708 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
709 /// use workspace_tools ::workspace;
710 ///
711 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
712 /// let ws = workspace()?;
713 /// let config_path = ws.join( "config/app.toml" );
714 ///
715 /// assert!( ws.is_workspace_file( &config_path ) );
716 /// assert!( !ws.is_workspace_file( "/etc/passwd" ) );
717 /// # Ok(())
718 /// # }
719 /// ```
720 #[ inline ]
721 pub fn is_workspace_file< P: AsRef< Path > >( &self, path: P ) -> bool
722 {
723 path.as_ref().starts_with( &self.root )
724 }
725
726 /// normalize path for cross-platform compatibility
727 ///
728 /// resolves symbolic links and canonicalizes the path
729 ///
730 /// # Errors
731 ///
732 /// returns error if path cannot be canonicalized or does not exist
733 #[ inline ]
734 pub fn normalize_path< P: AsRef< Path > >( &self, path: P ) -> Result< PathBuf >
735 {
736 let path = self.join( path );
737 path.canonicalize()
738 .map_err( | e | WorkspaceError::IoError( format!( "failed to normalize path {} : {}", path.display(), e ) ) )
739 }
740
741 /// get environment variable as path
742 fn get_env_path( key: &str ) -> Result< PathBuf >
743 {
744 let value = env ::var( key )
745 .map_err( |_| WorkspaceError::EnvironmentVariableMissing( key.to_string() ) )?;
746
747 // reject empty paths
748 if value.is_empty()
749 {
750 return Err( WorkspaceError::PathNotFound( PathBuf ::from( "" ) ) );
751 }
752
753 let path = PathBuf ::from( value );
754
755 // if relative path, resolve against current directory
756 let absolute = if path.is_relative()
757 {
758 env ::current_dir()
759 .map_err( | e | WorkspaceError::IoError( e.to_string() ) )?
760 .join( path )
761 }
762 else
763 {
764 path
765 };
766
767 // normalize to remove trailing "." and other redundancies
768 Ok( Self ::cleanup_path( absolute ) )
769 }
770
771 /// cleanup path by removing redundant components
772 ///
773 /// removes trailing `/.` and `/./` components without resolving symlinks
774 fn cleanup_path< P: AsRef< Path > >( path: P ) -> PathBuf
775 {
776 // manual normalization without canonicalization (preserves symlinks)
777 let mut normalized = PathBuf::new();
778 let mut components = path.as_ref().components().peekable();
779
780 while let Some( component ) = components.next()
781 {
782 use std ::path ::Component;
783 match component
784 {
785 Component ::CurDir =>
786 {
787 // skip "." unless it's the only component
788 if normalized.as_os_str().is_empty() && components.peek().is_none()
789 {
790 normalized.push( "." );
791 }
792 }
793 Component ::ParentDir =>
794 {
795 // handle ".." by popping parent
796 if !normalized.pop()
797 {
798 // if we cant pop (at root), keep the ParentDir
799 normalized.push( component );
800 }
801 }
802 _ => normalized.push( component ),
803 }
804 }
805
806 normalized
807 }
808
809 /// find configuration file by name
810 ///
811 /// searches for configuration files in standard locations :
812 /// - config/{name}.toml
813 /// - config/{name}.yaml
814 /// - config/{name}.json
815 /// - .{name}.toml (dotfile in workspace root)
816 ///
817 /// # Errors
818 ///
819 /// returns error if no configuration file with the given name is found
820 ///
821 /// # examples
822 ///
823 /// ```rust
824 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
825 /// use workspace_tools ::workspace;
826 ///
827 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
828 /// let ws = workspace()?;
829 ///
830 /// // looks for config/database.toml, config/database.yaml, etc.
831 /// if let Ok( config_path ) = ws.find_config( "database" )
832 /// {
833 /// println!( "found config at: {}", config_path.display() );
834 /// }
835 /// # Ok(())
836 /// # }
837 /// ```
838 pub fn find_config( &self, name: &str ) -> Result< PathBuf >
839 {
840 let candidates = vec!
841 [
842 self.config_dir().join( format!( "{name}.toml" ) ),
843 self.config_dir().join( format!( "{name}.yaml" ) ),
844 self.config_dir().join( format!( "{name}.yml" ) ),
845 self.config_dir().join( format!( "{name}.json" ) ),
846 self.root.join( format!( ".{name}.toml" ) ),
847 self.root.join( format!( ".{name}.yaml" ) ),
848 self.root.join( format!( ".{name}.yml" ) ),
849 ];
850
851 for candidate in candidates
852 {
853 if candidate.exists()
854 {
855 return Ok( candidate );
856 }
857 }
858
859 Err( WorkspaceError::PathNotFound(
860 self.config_dir().join( format!( "{name}.toml" ) )
861 ) )
862 }
863}
864
865// cargo integration types and implementations
866/// cargo metadata information for workspace
867#[ derive( Debug, Clone ) ]
868pub struct CargoMetadata
869{
870 /// root directory of the cargo workspace
871 pub workspace_root: PathBuf,
872 /// list of workspace member packages
873 pub members: Vec< CargoPackage >,
874 /// workspace-level dependencies
875 pub workspace_dependencies: HashMap< String, String >,
876}
877
878/// information about a cargo package within a workspace
879#[ derive( Debug, Clone ) ]
880pub struct CargoPackage
881{
882 /// package name
883 pub name: String,
884 /// package version
885 pub version: String,
886 /// path to the package's Cargo.toml
887 pub manifest_path: PathBuf,
888 /// root directory of the package
889 pub package_root: PathBuf,
890}
891
892// serde integration types
893#[ cfg( feature = "serde" ) ]
894/// trait for configuration types that can be merged
895pub trait ConfigMerge: Sized
896{
897 /// merge this configuration with another, returning the merged result
898 #[ must_use ]
899 fn merge( self, other: Self ) -> Self;
900}
901
902#[ cfg( feature = "serde" ) ]
903/// workspace-aware serde deserializer
904#[ derive( Debug ) ]
905pub struct WorkspaceDeserializer< 'ws >
906{
907 /// reference to workspace for path resolution
908 pub workspace: &'ws Workspace,
909}
910
911#[ cfg( feature = "serde" ) ]
912/// custom serde field for workspace-relative paths
913#[ derive( Debug, Clone, PartialEq ) ]
914pub struct WorkspacePath( pub PathBuf );
915
916// conditional compilation for optional features
917
918#[ cfg( feature = "glob" ) ]
919impl Workspace
920{
921 /// find files matching a glob pattern within the workspace
922 ///
923 /// # Errors
924 ///
925 /// returns error if the glob pattern is invalid or if there are errors reading the filesystem
926 ///
927 /// # examples
928 ///
929 /// ```rust
930 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
931 /// use workspace_tools ::workspace;
932 ///
933 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
934 /// let ws = workspace()?;
935 ///
936 /// // find all rust source files
937 /// let rust_files = ws.find_resources( "src/**/*.rs" )?;
938 ///
939 /// // find all configuration files
940 /// let configs = ws.find_resources( "config/**/*.toml" )?;
941 /// # Ok(())
942 /// # }
943 /// ```
944 pub fn find_resources( &self, pattern: &str ) -> Result< Vec< PathBuf > >
945 {
946 let full_pattern = self.join( pattern );
947 let pattern_str = full_pattern.to_string_lossy();
948
949 let mut results = Vec ::new();
950
951 for entry in glob( &pattern_str )
952 .map_err( | e | WorkspaceError::GlobError( e.to_string() ) )?
953 {
954 match entry
955 {
956 Ok( path ) => results.push( path ),
957 Err( e ) => return Err( WorkspaceError::GlobError( e.to_string() ) ),
958 }
959 }
960
961 Ok( results )
962 }
963
964}
965
966#[ cfg( feature = "secrets" ) ]
967impl Workspace
968{
969 /// get secrets directory path
970 ///
971 /// returns `workspace_root/secret`
972 #[ must_use ]
973 pub fn secret_dir( &self ) -> PathBuf
974 {
975 self.root.join( "secret" )
976 }
977
978 /// get path to secret configuration file
979 ///
980 /// returns `workspace_root/secret/{name}`
981 #[ must_use ]
982 pub fn secret_file( &self, name: &str ) -> PathBuf
983 {
984 self.secret_dir().join( name )
985 }
986
987 /// load secrets from a file in the workspace secrets directory
988 ///
989 /// supports shell script format (KEY=value lines) and loads secrets from filenames
990 /// within the workspace `secret/` directory
991 ///
992 /// # Path Resolution
993 ///
994 /// Files are resolved as: `workspace_root/secret/{filename}`
995 ///
996 /// **Important** : This method expects a filename, not a path. If you need to load
997 /// from a path, use `load_secrets_from_path()` instead.
998 ///
999 /// # examples
1000 ///
1001 /// ```rust
1002 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1003 /// use workspace_tools ::workspace;
1004 ///
1005 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1006 /// let ws = workspace()?;
1007 ///
1008 /// // ✅ Correct usage - simple filenames only
1009 /// // let secrets = ws.load_secrets_from_file( "-secrets.sh" )?; // -> secret/-secrets.sh
1010 /// // let dev = ws.load_secrets_from_file( "development.env" )?; // -> secret/development.env
1011 ///
1012 /// // ❌ Common mistake - using paths (will emit warning)
1013 /// // let secrets = ws.load_secrets_from_file( "config/secrets.env" )?; // DON'T DO THIS
1014 ///
1015 /// // ✅ For paths, use the path-specific method instead
1016 /// // let path_secrets = ws.load_secrets_from_path( "config/secrets.env" )?; // -> workspace/config/secrets.env
1017 /// # Ok(())
1018 /// # }
1019 /// ```
1020 ///
1021 /// # Errors
1022 ///
1023 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1024 pub fn load_secrets_from_file( &self, filename: &str ) -> Result< HashMap< String, String > >
1025 {
1026 Self::warn_if_path_like( filename );
1027 self.try_load_secrets_with_fallback( filename )
1028 }
1029
1030 /// load a specific secret key with fallback to environment
1031 ///
1032 /// tries to load from secret file first, then falls back to environment variable
1033 /// this method uses filename-based resolution (looks in secret/ directory)
1034 ///
1035 /// # Path Resolution
1036 ///
1037 /// Files are resolved as: `workspace_root/secret/{filename}`
1038 ///
1039 /// # Fallback Strategy
1040 ///
1041 /// 1. First attempts to load from secrets file
1042 /// 2. If key not found in file or file doesn't exist, checks environment variables
1043 /// 3. If neither source contains the key, returns error
1044 ///
1045 /// # examples
1046 ///
1047 /// ```rust
1048 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1049 /// use workspace_tools ::workspace;
1050 ///
1051 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1052 /// let ws = workspace()?;
1053 ///
1054 /// // ✅ Correct usage - filename only
1055 /// match ws.load_secret_key( "API_KEY", "-secrets.sh" ) // -> secret/-secrets.sh
1056 /// {
1057 /// Ok( key ) => println!( "loaded api key from file or environment" ),
1058 /// Err( e ) => println!( "api key not found: {}", e ),
1059 /// }
1060 ///
1061 /// // ❌ Common mistake - using paths (will emit warning)
1062 /// // let key = ws.load_secret_key( "API_KEY", "config/secrets.env" )?; // DON'T DO THIS
1063 /// # Ok(())
1064 /// # }
1065 /// ```
1066 ///
1067 /// # Errors
1068 ///
1069 /// returns error if the key is not found in either the secret file or environment variables
1070 pub fn load_secret_key( &self, key_name: &str, filename: &str ) -> Result< String >
1071 {
1072 let secret_file_path = self.secret_file( filename );
1073
1074 // try loading from secret file first
1075 if let Ok( secrets ) = self.load_secrets_from_file( filename )
1076 {
1077 if let Some( value ) = secrets.get( key_name )
1078 {
1079 return Ok( value.clone() );
1080 }
1081 }
1082
1083 // fallback to environment variable
1084 env ::var( key_name )
1085 .map_err( |_| WorkspaceError::ConfigurationError(
1086 format!(
1087 "{} not found in secrets file '{}' (resolved to: {}) or environment variables",
1088 key_name,
1089 filename,
1090 secret_file_path.display()
1091 )
1092 ))
1093 }
1094
1095 /// parse key-value file content
1096 ///
1097 /// supports multiple formats :
1098 /// - shell script format with comments and quotes
1099 /// - export statements: `export KEY=VALUE`
1100 /// - standard dotenv format: `KEY=VALUE`
1101 /// - mixed formats in same file
1102 fn parse_key_value_file( content: &str ) -> HashMap< String, String >
1103 {
1104 let mut secrets = HashMap ::new();
1105
1106 for line in content.lines()
1107 {
1108 let line = line.trim();
1109
1110 // skip empty lines and comments
1111 if line.is_empty() || line.starts_with( '#' )
1112 {
1113 continue;
1114 }
1115
1116 // handle export statements by stripping 'export ' prefix
1117 let processed_line = if line.starts_with( "export " )
1118 {
1119 line.strip_prefix( "export " ).unwrap_or( line ).trim()
1120 }
1121 else
1122 {
1123 line
1124 };
1125
1126 // parse KEY=VALUE format
1127 if let Some( ( key, value ) ) = processed_line.split_once( '=' )
1128 {
1129 let key = key.trim();
1130 let value = value.trim();
1131
1132 // remove quotes if present
1133 let value = if ( value.starts_with( '"' ) && value.ends_with( '"' ) ) ||
1134 ( value.starts_with( '\'' ) && value.ends_with( '\'' ) )
1135 {
1136 &value[ 1..value.len() - 1 ]
1137 }
1138 else
1139 {
1140 value
1141 };
1142
1143 secrets.insert( key.to_string(), value.to_string() );
1144 }
1145 }
1146
1147 secrets
1148 }
1149
1150 /// warn if filename contains path separators
1151 ///
1152 /// emits warning to stderr if filename looks like a path rather than a simple filename
1153 /// this helps users understand they should use path-specific methods for paths
1154 fn warn_if_path_like( filename: &str )
1155 {
1156 if filename.contains( '/' ) || filename.contains( '\\' )
1157 {
1158 eprintln!(
1159 "⚠️ Warning: '{filename}' contains path separators. Use load_secrets_from_path() for paths."
1160 );
1161 }
1162 }
1163
1164 /// try loading secrets from fallback chain
1165 ///
1166 /// implements automatic fallback with proper corner case handling:
1167 /// 1. local workspace: `workspace_root/secret/{filename}`
1168 /// 2. `$PRO` workspace: `$PRO/secret/{filename}` (if `$PRO` set and valid)
1169 /// 3. `$HOME` directory: `$HOME/secret/{filename}` (if `$HOME`/`$USERPROFILE` set and valid)
1170 ///
1171 /// uses path canonicalization to avoid reading same file multiple times
1172 fn try_load_secrets_with_fallback( &self, filename: &str ) -> Result< HashMap< String, String > >
1173 {
1174 let mut tried_paths = Vec ::new();
1175 let mut canonical_paths = std ::collections ::HashSet ::new();
1176
1177 // 1. try local workspace first
1178 let local_path = self.secret_file( filename );
1179 tried_paths.push( format!( " - {} (local workspace)", local_path.display() ) );
1180
1181 if let Some( canonical ) = Self::try_canonicalize( &local_path )
1182 {
1183 canonical_paths.insert( canonical );
1184 if local_path.exists()
1185 {
1186 match Self::read_secret_file_validated( &local_path )
1187 {
1188 Ok( content ) => return Ok( Self::parse_key_value_file( &content ) ),
1189 Err( e ) => return Err( e ),
1190 }
1191 }
1192 }
1193
1194 // 2. try $PRO workspace if different
1195 if let Ok( pro_env ) = env::var( "PRO" )
1196 {
1197 if !pro_env.trim().is_empty()
1198 {
1199 if let Ok( pro_ws ) = Workspace::from_pro_env()
1200 {
1201 let pro_path = pro_ws.secret_file( filename );
1202 if let Some( canonical ) = Self::try_canonicalize( &pro_path )
1203 {
1204 if !canonical_paths.contains( &canonical )
1205 {
1206 canonical_paths.insert( canonical );
1207 tried_paths.push( format!( " - {} ($PRO workspace)", pro_path.display() ) );
1208 if pro_path.exists()
1209 {
1210 match Self::read_secret_file_validated( &pro_path )
1211 {
1212 Ok( content ) => return Ok( Self::parse_key_value_file( &content ) ),
1213 Err( e ) => return Err( e ),
1214 }
1215 }
1216 }
1217 }
1218 }
1219 }
1220 }
1221
1222 // 3. try $HOME workspace if different
1223 #[ cfg( not( target_os = "windows" ) ) ]
1224 let home_env_var = "HOME";
1225 #[ cfg( target_os = "windows" ) ]
1226 let home_env_var = "USERPROFILE";
1227
1228 if let Ok( home_env ) = env::var( home_env_var )
1229 {
1230 if !home_env.trim().is_empty()
1231 {
1232 if let Ok( home_ws ) = Workspace::from_home_dir()
1233 {
1234 let home_path = home_ws.secret_file( filename );
1235 if let Some( canonical ) = Self::try_canonicalize( &home_path )
1236 {
1237 if !canonical_paths.contains( &canonical )
1238 {
1239 canonical_paths.insert( canonical );
1240 tried_paths.push( format!( " - {} ($HOME directory)", home_path.display() ) );
1241 if home_path.exists()
1242 {
1243 match Self::read_secret_file_validated( &home_path )
1244 {
1245 Ok( content ) => return Ok( Self::parse_key_value_file( &content ) ),
1246 Err( e ) => return Err( e ),
1247 }
1248 }
1249 }
1250 }
1251 }
1252 }
1253 }
1254
1255 // none found - return error with helpful message including available files
1256 let mut error_msg = format!(
1257 "Secrets file '{}' not found in any location.\n\nTried:\n{}",
1258 filename,
1259 tried_paths.join( "\n" )
1260 );
1261
1262 if let Ok( available_files ) = self.list_secrets_files()
1263 {
1264 if !available_files.is_empty()
1265 {
1266 error_msg.push_str( "\n\nAvailable files: " );
1267 error_msg.push_str( &available_files.join( ", " ) );
1268 }
1269 }
1270
1271 error_msg.push_str( "\n\nCreate secret file in one of the above locations." );
1272 Err( WorkspaceError::ConfigurationError( error_msg ) )
1273 }
1274
1275 /// try to canonicalize path, return None if it fails
1276 ///
1277 /// used for path deduplication to handle symlinks and path normalization
1278 fn try_canonicalize( path: &Path ) -> Option< PathBuf >
1279 {
1280 path.canonicalize().ok()
1281 }
1282
1283 /// read secret file with validation checks
1284 ///
1285 /// validates file type (must be regular file) and size (max 10MB)
1286 /// provides clear error messages for common issues
1287 fn read_secret_file_validated( path: &Path ) -> Result< String >
1288 {
1289 let metadata = fs::metadata( path )
1290 .map_err( | e | WorkspaceError::IoError( format!( "Failed to read secrets file\n Absolute path: {}\n Error: {}", path.display(), e ) ) )?;
1291
1292 // validate file type - must be regular file
1293 if !metadata.is_file()
1294 {
1295 let file_type = if metadata.is_dir() { "directory" }
1296 else if metadata.file_type().is_symlink() { "symbolic link" }
1297 else { "special file (device, socket, or pipe)" };
1298
1299 return Err( WorkspaceError::ConfigurationError( format!(
1300 "Secrets file is a {}, not a regular file\n Path: {}",
1301 file_type, path.display()
1302 ) ) );
1303 }
1304
1305 // validate file size - max 10MB to prevent OOM
1306 const MAX_SIZE: u64 = 10 * 1024 * 1024;
1307 if metadata.len() > MAX_SIZE
1308 {
1309 return Err( WorkspaceError::ConfigurationError( format!(
1310 "Secrets file too large ({} bytes, max {} bytes)\n Path: {}\n Hint: Secret files should be small key-value files",
1311 metadata.len(), MAX_SIZE, path.display()
1312 ) ) );
1313 }
1314
1315 fs::read_to_string( path )
1316 .map_err( | e | WorkspaceError::IoError( format!( "Failed to read secrets file\n Absolute path: {}\n Error: {}", path.display(), e ) ) )
1317 }
1318
1319 /// list available secrets files in the secrets directory
1320 ///
1321 /// returns vector of filenames (not full paths) found in secret/ directory
1322 ///
1323 /// # examples
1324 ///
1325 /// ```rust
1326 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1327 /// use workspace_tools ::workspace;
1328 ///
1329 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1330 /// let ws = workspace()?;
1331 /// let files = ws.list_secrets_files()?;
1332 /// println!( "Available secret files: {:?}", files );
1333 /// # Ok(())
1334 /// # }
1335 /// ```
1336 ///
1337 /// # Errors
1338 ///
1339 /// returns error if the secrets directory cannot be read
1340 pub fn list_secrets_files( &self ) -> Result< Vec< String > >
1341 {
1342 let secret_dir = self.secret_dir();
1343
1344 if !secret_dir.exists()
1345 {
1346 return Ok( Vec ::new() );
1347 }
1348
1349 let entries = fs ::read_dir( &secret_dir )
1350 .map_err( | e | WorkspaceError::IoError( format!( "failed to read secrets directory {} : {}", secret_dir.display(), e ) ) )?;
1351
1352 let mut files = Vec ::new();
1353
1354 for entry in entries
1355 {
1356 let entry = entry
1357 .map_err( | e | WorkspaceError::IoError( format!( "failed to read directory entry: {e}" ) ) )?;
1358
1359 let path = entry.path();
1360
1361 if path.is_file()
1362 {
1363 if let Some( filename ) = path.file_name()
1364 {
1365 if let Some( filename_str ) = filename.to_str()
1366 {
1367 files.push( filename_str.to_string() );
1368 }
1369 }
1370 }
1371 }
1372
1373 files.sort();
1374 Ok( files )
1375 }
1376
1377 /// check if a secrets file exists
1378 ///
1379 /// returns true if the file exists in the secrets directory
1380 ///
1381 /// # examples
1382 ///
1383 /// ```rust
1384 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1385 /// use workspace_tools ::workspace;
1386 ///
1387 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1388 /// let ws = workspace()?;
1389 ///
1390 /// if ws.secrets_file_exists( "-secrets.sh" )
1391 /// {
1392 /// println!( "secrets file found" );
1393 /// }
1394 /// # Ok(())
1395 /// # }
1396 /// ```
1397 #[ must_use ]
1398 pub fn secrets_file_exists( &self, secret_file_name: &str ) -> bool
1399 {
1400 self.secret_file( secret_file_name ).exists()
1401 }
1402
1403 /// get resolved path for secrets file (for debugging)
1404 ///
1405 /// returns the full path where the secrets file would be located
1406 ///
1407 /// # examples
1408 ///
1409 /// ```rust
1410 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1411 /// use workspace_tools ::workspace;
1412 ///
1413 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1414 /// let ws = workspace()?;
1415 /// let path = ws.resolve_secrets_path( "-secrets.sh" );
1416 /// println!( "Secrets file would be at: {}", path.display() );
1417 /// # Ok(())
1418 /// # }
1419 /// ```
1420 #[ must_use ]
1421 pub fn resolve_secrets_path( &self, secret_file_name: &str ) -> PathBuf
1422 {
1423 self.secret_file( secret_file_name )
1424 }
1425
1426 /// load secrets from workspace-relative path
1427 ///
1428 /// loads secrets from a file specified as a path relative to the workspace root
1429 /// use this method when you need to load secrets from custom locations
1430 ///
1431 /// # Path Resolution
1432 ///
1433 /// Files are resolved as: `workspace_root/{relative_path}`
1434 ///
1435 /// # examples
1436 ///
1437 /// ```rust
1438 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1439 /// use workspace_tools ::workspace;
1440 ///
1441 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1442 /// let ws = workspace()?;
1443 ///
1444 /// // load from config/secrets.env (workspace_root/config/secrets.env)
1445 /// // let secrets = ws.load_secrets_from_path( "config/secrets.env" )?;
1446 ///
1447 /// // load from nested directory
1448 /// // let nested = ws.load_secrets_from_path( "lib/project/secret/api.env" )?;
1449 /// # Ok(())
1450 /// # }
1451 /// ```
1452 ///
1453 /// # Errors
1454 ///
1455 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1456 pub fn load_secrets_from_path( &self, relative_path: &str ) -> Result< HashMap< String, String > >
1457 {
1458 let secret_file = self.join( relative_path );
1459 let content = Self::read_secret_file_validated( &secret_file )?;
1460 Ok( Self::parse_key_value_file( &content ) )
1461 }
1462
1463 /// load secrets from absolute path
1464 ///
1465 /// loads secrets from a file specified as an absolute filesystem path
1466 /// use this method when you need to load secrets from locations outside the workspace
1467 ///
1468 /// # examples
1469 ///
1470 /// ```rust
1471 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1472 /// use workspace_tools ::workspace;
1473 /// use std ::path ::Path;
1474 ///
1475 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1476 /// let ws = workspace()?;
1477 ///
1478 /// // load from absolute path
1479 /// let absolute_path = Path ::new( "/etc/secrets/production.env" );
1480 /// // let secrets = ws.load_secrets_from_absolute_path( absolute_path )?;
1481 /// # Ok(())
1482 /// # }
1483 /// ```
1484 ///
1485 /// # Errors
1486 ///
1487 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1488 pub fn load_secrets_from_absolute_path( &self, absolute_path: &Path ) -> Result< HashMap< String, String > >
1489 {
1490 let content = Self::read_secret_file_validated( absolute_path )?;
1491 Ok( Self::parse_key_value_file( &content ) )
1492 }
1493
1494 /// load secrets with verbose debug information
1495 ///
1496 /// provides detailed path resolution and validation information for debugging
1497 /// use this method when troubleshooting secret loading issues
1498 ///
1499 /// # examples
1500 ///
1501 /// ```rust
1502 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1503 /// use workspace_tools ::workspace;
1504 ///
1505 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1506 /// let ws = workspace()?;
1507 ///
1508 /// // load with debug output
1509 /// match ws.load_secrets_with_debug( "-secrets.sh" )
1510 /// {
1511 /// Ok( secrets ) => println!( "Loaded {} secrets", secrets.len() ),
1512 /// Err( e ) => println!( "Failed to load secrets: {}", e ),
1513 /// }
1514 /// # Ok(())
1515 /// # }
1516 /// ```
1517 ///
1518 /// # Errors
1519 ///
1520 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1521 pub fn load_secrets_with_debug( &self, secret_file_name: &str ) -> Result< HashMap< String, String > >
1522 {
1523 println!( "🔍 Debug: Loading secrets with detailed information" );
1524 println!( " Parameter: '{secret_file_name}'" );
1525
1526 // check for path-like parameter
1527 if secret_file_name.contains( '/' ) || secret_file_name.contains( '\\' )
1528 {
1529 println!( " ⚠️ Parameter contains path separators - consider using load_secrets_from_path()" );
1530 }
1531
1532 let secret_file = self.secret_file( secret_file_name );
1533 println!( " Resolved path: {}", secret_file.display() );
1534 println!( " File exists: {}", secret_file.exists() );
1535
1536 // show available files for context
1537 match self.list_secrets_files()
1538 {
1539 Ok( files ) =>
1540 {
1541 if files.is_empty()
1542 {
1543 println!( " Available files: none (secrets directory: {})", self.secret_dir().display() );
1544 }
1545 else
1546 {
1547 println!( " Available files: {}", files.join( ", " ) );
1548 }
1549 }
1550 Err( e ) => println!( " Could not list available files: {e}" ),
1551 }
1552
1553 // attempt to load normally
1554 match self.load_secrets_from_file( secret_file_name )
1555 {
1556 Ok( secrets ) =>
1557 {
1558 println!( " ✅ Successfully loaded {} secrets", secrets.len() );
1559 for key in secrets.keys()
1560 {
1561 println!( " - {key}" );
1562 }
1563 Ok( secrets )
1564 }
1565 Err( e ) =>
1566 {
1567 println!( " ❌ Failed to load secrets: {e}" );
1568 Err( e )
1569 }
1570 }
1571 }
1572}
1573
1574#[ cfg( feature = "secure" ) ]
1575/// trait for converting plain types to secure memory-protected types
1576///
1577/// this trait provides a generic way to convert regular strings and collections
1578/// into their secure counterparts that use memory protection and zeroization
1579trait AsSecure
1580{
1581 /// the secure version of this type
1582 type Secure;
1583
1584 /// convert this value into its secure equivalent
1585 fn into_secure( self ) -> Self::Secure;
1586}
1587
1588#[ cfg( feature = "secure" ) ]
1589impl AsSecure for String
1590{
1591 type Secure = SecretString;
1592
1593 fn into_secure( self ) -> Self::Secure
1594 {
1595 SecretString::new( self )
1596 }
1597}
1598
1599#[ cfg( feature = "secure" ) ]
1600impl AsSecure for HashMap< String, String >
1601{
1602 type Secure = HashMap< String, SecretString >;
1603
1604 fn into_secure( self ) -> Self::Secure
1605 {
1606 self.into_iter()
1607 .map( | ( key, value ) | ( key, SecretString::new( value ) ) )
1608 .collect()
1609 }
1610}
1611
1612#[ cfg( feature = "secure" ) ]
1613impl Workspace
1614{
1615 /// load secrets from a file in the workspace secrets directory with memory-safe handling
1616 ///
1617 /// returns secrets as `SecretString` types for enhanced security
1618 /// supports shell script format (KEY=value lines) and loads secrets from filenames
1619 /// within the workspace `secret/` directory
1620 ///
1621 /// # Path Resolution
1622 ///
1623 /// Files are resolved as: `workspace_root/secret/{filename}`
1624 ///
1625 /// **Important** : This method expects a filename, not a path. If you need to load
1626 /// from a path, use `load_secrets_from_path_secure()` instead.
1627 ///
1628 /// # examples
1629 ///
1630 /// ```rust
1631 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1632 /// use workspace_tools ::workspace;
1633 /// use secrecy ::ExposeSecret;
1634 ///
1635 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1636 /// let ws = workspace()?;
1637 ///
1638 /// // ✅ Correct usage - simple filenames only
1639 /// // let secrets = ws.load_secrets_secure( "-secrets.sh" )?; // -> secret/-secrets.sh
1640 /// // let dev = ws.load_secrets_secure( "development.env" )?; // -> secret/development.env
1641 ///
1642 /// // Access secret values (requires explicit expose_secret() call)
1643 /// // if let Some( api_key ) = secrets.get( "API_KEY" )
1644 /// // {
1645 /// // println!( "loaded api key: {}", api_key.expose_secret() );
1646 /// // }
1647 ///
1648 /// // ❌ Common mistake - using paths (will emit warning)
1649 /// // let secrets = ws.load_secrets_secure( "config/secrets.env" )?; // DON'T DO THIS
1650 ///
1651 /// // ✅ For paths, use the path-specific method instead
1652 /// // let path_secrets = ws.load_secrets_from_path_secure( "config/secrets.env" )?;
1653 /// # Ok(())
1654 /// # }
1655 /// ```
1656 ///
1657 /// # Errors
1658 ///
1659 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1660 pub fn load_secrets_secure( &self, filename: &str ) -> Result< HashMap< String, SecretString > >
1661 {
1662 self.load_secrets_from_file( filename ).map( AsSecure::into_secure )
1663 }
1664
1665 /// load a specific secret key with memory-safe handling and fallback to environment
1666 ///
1667 /// tries to load from secret file first, then falls back to environment variable
1668 /// returns `SecretString` for enhanced security
1669 ///
1670 /// # Errors
1671 ///
1672 /// returns error if the key is not found in either the secret file or environment variables
1673 ///
1674 /// # examples
1675 ///
1676 /// ```rust
1677 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1678 /// use workspace_tools ::workspace;
1679 /// use secrecy ::ExposeSecret;
1680 ///
1681 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1682 /// let ws = workspace()?;
1683 ///
1684 /// // looks for API_KEY in secret/-secrets.sh, then in environment
1685 /// match ws.load_secret_key_secure( "API_KEY", "-secrets.sh" )
1686 /// {
1687 /// Ok( key ) => println!( "loaded api key: {}", key.expose_secret() ),
1688 /// Err( _ ) => println!( "api key not found" ),
1689 /// }
1690 /// # Ok(())
1691 /// # }
1692 /// ```
1693 pub fn load_secret_key_secure( &self, key_name: &str, filename: &str ) -> Result< SecretString >
1694 {
1695 self.load_secret_key( key_name, filename ).map( AsSecure::into_secure )
1696 }
1697
1698 /// get environment variable as `SecretString` for memory-safe handling
1699 ///
1700 /// # examples
1701 ///
1702 /// ```rust
1703 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1704 /// use workspace_tools ::workspace;
1705 /// use secrecy ::ExposeSecret;
1706 ///
1707 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1708 /// let ws = workspace()?;
1709 ///
1710 /// if let Some( token ) = ws.env_secret( "GITHUB_TOKEN" )
1711 /// {
1712 /// println!( "using secure token: {}", token.expose_secret() );
1713 /// }
1714 /// # Ok(())
1715 /// # }
1716 /// ```
1717 #[ must_use ]
1718 pub fn env_secret( &self, key: &str ) -> Option< SecretString >
1719 {
1720 env ::var( key ).ok().map( SecretString ::new )
1721 }
1722
1723 /// validate secret strength and security requirements
1724 ///
1725 /// checks for common security issues like weak passwords, common patterns, etc.
1726 ///
1727 /// # Errors
1728 ///
1729 /// returns error if the secret does not meet minimum security requirements
1730 ///
1731 /// # examples
1732 ///
1733 /// ```rust
1734 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1735 /// use workspace_tools ::workspace;
1736 ///
1737 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1738 /// let ws = workspace()?;
1739 ///
1740 /// // this will fail - too weak
1741 /// assert!( ws.validate_secret( "123" ).is_err() );
1742 ///
1743 /// // this will pass - strong secret
1744 /// assert!( ws.validate_secret( "super-strong-secret-2024!" ).is_ok() );
1745 /// # Ok(())
1746 /// # }
1747 /// ```
1748 pub fn validate_secret( &self, secret: &str ) -> Result< () >
1749 {
1750 if secret.len() < 8
1751 {
1752 return Err( WorkspaceError::SecretValidationError(
1753 "secret must be at least 8 characters long".to_string()
1754 ) );
1755 }
1756
1757 if secret == "123" || secret == "password" || secret == "secret" || secret.to_lowercase() == "test"
1758 {
1759 return Err( WorkspaceError::SecretValidationError(
1760 "secret is too weak or uses common patterns".to_string()
1761 ) );
1762 }
1763
1764 // check for reasonable complexity (at least some variety)
1765 let has_letter = secret.chars().any( char ::is_alphabetic );
1766 let has_number = secret.chars().any( char ::is_numeric );
1767 let has_special = secret.chars().any( | c | !c.is_alphanumeric() );
1768
1769 if !( has_letter || has_number || has_special )
1770 {
1771 return Err( WorkspaceError::SecretValidationError(
1772 "secret should contain letters, numbers, or special characters".to_string()
1773 ) );
1774 }
1775
1776 Ok( () )
1777 }
1778
1779 /// load configuration with automatic secret injection
1780 ///
1781 /// replaces `${VAR_NAME}` placeholders in configuration with values from secret files
1782 ///
1783 /// # Errors
1784 ///
1785 /// returns error if configuration file cannot be read or secret injection fails
1786 ///
1787 /// # examples
1788 ///
1789 /// ```rust,no_run
1790 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1791 /// use workspace_tools ::workspace;
1792 ///
1793 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1794 /// let ws = workspace()?;
1795 ///
1796 /// // loads config.toml and replaces ${SECRET} with values from secrets.sh
1797 /// let config = ws.load_config_with_secret_injection( "config.toml", "secrets.sh" )?;
1798 /// # Ok(())
1799 /// # }
1800 /// ```
1801 pub fn load_config_with_secret_injection( &self, config_file: &str, secret_file: &str ) -> Result< String >
1802 {
1803 // load the configuration file
1804 let config_path = self.join( config_file );
1805 let config_content = Self::read_file_to_string( &config_path )?;
1806
1807 // load secrets securely
1808 let secrets = self.load_secrets_secure( secret_file )?;
1809
1810 // perform template substitution
1811 let mut result = config_content;
1812 for ( key, secret_value ) in secrets
1813 {
1814 let placeholder = format!( "${{{key}}}" );
1815 let replacement = secret_value.expose_secret();
1816 result = result.replace( &placeholder, replacement );
1817 }
1818
1819 // check for unresolved placeholders
1820 if result.contains( "${" )
1821 {
1822 return Err( WorkspaceError::SecretInjectionError(
1823 "configuration contains unresolved placeholders - check secret file completeness".to_string()
1824 ) );
1825 }
1826
1827 Ok( result )
1828 }
1829
1830 /// load configuration with automatic secret injection using `SecretInjectable` trait
1831 ///
1832 /// loads secrets from file and injects them into the configuration type
1833 ///
1834 /// # Errors
1835 ///
1836 /// returns error if secret loading or injection fails
1837 ///
1838 /// # examples
1839 ///
1840 /// ```rust,no_run
1841 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1842 /// # #[ cfg(feature = "secure") ] {
1843 /// use workspace_tools :: { workspace, SecretInjectable };
1844 ///
1845 /// #[ derive(Debug) ]
1846 /// struct AppConfig {
1847 /// database_url: String,
1848 /// api_key: String,
1849 /// }
1850 ///
1851 /// impl SecretInjectable for AppConfig
1852 /// {
1853 /// fn inject_secret(&mut self, key: &str, value: String) -> workspace_tools ::Result< () >
1854 /// {
1855 /// match key
1856 /// {
1857 /// "DATABASE_URL" => self.database_url = value,
1858 /// "API_KEY" => self.api_key = value,
1859 /// _ => return Err(workspace_tools ::WorkspaceError::SecretInjectionError(
1860 /// format!("unknown secret key: {}", key)
1861 /// )),
1862 /// }
1863 /// Ok(())
1864 /// }
1865 ///
1866 /// fn validate_secrets( &self ) -> workspace_tools ::Result< () > {
1867 /// if self.api_key.is_empty() {
1868 /// return Err(workspace_tools ::WorkspaceError::SecretValidationError(
1869 /// "api_key cannot be empty".to_string()
1870 /// ));
1871 /// }
1872 /// Ok(())
1873 /// }
1874 /// }
1875 ///
1876 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1877 /// let ws = workspace()?;
1878 /// let mut config = AppConfig { database_url: String ::new(), api_key: String ::new() };
1879 ///
1880 /// // config gets secrets injected from secret/-config.sh
1881 /// config = ws.load_config_with_secrets( config, "-config.sh" )?;
1882 /// # }
1883 /// # Ok(())
1884 /// # }
1885 /// ```
1886 pub fn load_config_with_secrets< T: SecretInjectable >( &self, mut config: T, secret_file: &str ) -> Result< T >
1887 {
1888 // load secrets securely
1889 let secrets = self.load_secrets_secure( secret_file )?;
1890
1891 // inject each secret into the configuration
1892 for ( key, secret_value ) in secrets
1893 {
1894 config.inject_secret( &key, secret_value.expose_secret().clone() )?;
1895 }
1896
1897 // validate the final configuration
1898 config.validate_secrets()?;
1899
1900 Ok( config )
1901 }
1902
1903 /// load secrets from workspace-relative path with memory-safe handling
1904 ///
1905 /// loads secrets from a file specified as a path relative to the workspace root
1906 /// returns secrets as `SecretString` types for enhanced security
1907 ///
1908 /// # Path Resolution
1909 ///
1910 /// Files are resolved as: `workspace_root/{relative_path}`
1911 ///
1912 /// # examples
1913 ///
1914 /// ```rust
1915 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1916 /// use workspace_tools ::workspace;
1917 /// use secrecy ::ExposeSecret;
1918 ///
1919 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1920 /// let ws = workspace()?;
1921 ///
1922 /// // load from config/secrets.env (workspace_root/config/secrets.env)
1923 /// // let secrets = ws.load_secrets_from_path_secure( "config/secrets.env" )?;
1924 /// # Ok(())
1925 /// # }
1926 /// ```
1927 ///
1928 /// # Errors
1929 ///
1930 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1931 pub fn load_secrets_from_path_secure( &self, relative_path: &str ) -> Result< HashMap< String, SecretString > >
1932 {
1933 self.load_secrets_from_path( relative_path ).map( AsSecure::into_secure )
1934 }
1935
1936 /// load secrets from absolute path with memory-safe handling
1937 ///
1938 /// loads secrets from a file specified as an absolute filesystem path
1939 /// returns secrets as `SecretString` types for enhanced security
1940 ///
1941 /// # examples
1942 ///
1943 /// ```rust
1944 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1945 /// use workspace_tools ::workspace;
1946 /// use secrecy ::ExposeSecret;
1947 /// use std ::path ::Path;
1948 ///
1949 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1950 /// let ws = workspace()?;
1951 ///
1952 /// // load from absolute path
1953 /// // let absolute_path = Path ::new( "/etc/secrets/production.env" );
1954 /// // let secrets = ws.load_secrets_from_absolute_path_secure( absolute_path )?;
1955 /// # Ok(())
1956 /// # }
1957 /// ```
1958 ///
1959 /// # Errors
1960 ///
1961 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1962 pub fn load_secrets_from_absolute_path_secure( &self, absolute_path: &Path ) -> Result< HashMap< String, SecretString > >
1963 {
1964 self.load_secrets_from_absolute_path( absolute_path ).map( AsSecure::into_secure )
1965 }
1966
1967 /// load secrets with verbose debug information and memory-safe handling
1968 ///
1969 /// provides detailed path resolution and validation information for debugging
1970 /// returns secrets as `SecretString` types for enhanced security
1971 ///
1972 /// # examples
1973 ///
1974 /// ```rust
1975 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
1976 /// use workspace_tools ::workspace;
1977 /// use secrecy ::ExposeSecret;
1978 ///
1979 /// # std ::env ::set_var( "WORKSPACE_PATH", std ::env ::current_dir().unwrap() );
1980 /// let ws = workspace()?;
1981 ///
1982 /// // load with debug output
1983 /// match ws.load_secrets_with_debug_secure( "-secrets.sh" )
1984 /// {
1985 /// Ok( secrets ) => println!( "Loaded {} secrets", secrets.len() ),
1986 /// Err( e ) => println!( "Failed to load secrets: {}", e ),
1987 /// }
1988 /// # Ok(())
1989 /// # }
1990 /// ```
1991 ///
1992 /// # Errors
1993 ///
1994 /// returns error if the file cannot be read, doesn't exist, or contains invalid format
1995 pub fn load_secrets_with_debug_secure( &self, secret_file_name: &str ) -> Result< HashMap< String, SecretString > >
1996 {
1997 self.load_secrets_with_debug( secret_file_name ).map( AsSecure::into_secure )
1998 }
1999
2000}
2001
2002impl Workspace
2003{
2004 /// create workspace from cargo workspace root (auto-detected)
2005 ///
2006 /// traverses up directory tree looking for `Cargo.toml` with `[workspace]` section
2007 /// or workspace member that references a workspace root
2008 ///
2009 /// # Errors
2010 ///
2011 /// returns error if no cargo workspace is found or if cargo.toml cannot be parsed
2012 ///
2013 /// # examples
2014 ///
2015 /// ```rust
2016 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
2017 /// use workspace_tools ::Workspace;
2018 ///
2019 /// let workspace = Workspace ::from_cargo_workspace()?;
2020 /// println!( "cargo workspace root: {}", workspace.root().display() );
2021 /// # Ok(())
2022 /// # }
2023 /// ```
2024 pub fn from_cargo_workspace() -> Result< Self >
2025 {
2026 let workspace_root = Self ::find_cargo_workspace()?;
2027 let workspace_root = Self ::cleanup_path( workspace_root );
2028 Ok( Self { root: workspace_root } )
2029 }
2030
2031 /// create workspace from specific cargo.toml path
2032 ///
2033 /// # Errors
2034 ///
2035 /// returns error if the manifest path does not exist or cannot be parsed
2036 pub fn from_cargo_manifest< P: AsRef< Path > >( manifest_path: P ) -> Result< Self >
2037 {
2038 let manifest_path = manifest_path.as_ref();
2039
2040 if !manifest_path.exists()
2041 {
2042 return Err( WorkspaceError::PathNotFound( manifest_path.to_path_buf() ) );
2043 }
2044
2045 let workspace_root = if manifest_path.file_name() == Some( std ::ffi ::OsStr ::new( "Cargo.toml" ) )
2046 {
2047 manifest_path.parent()
2048 .ok_or_else( || WorkspaceError::ConfigurationError( "invalid manifest path".to_string() ) )?
2049 .to_path_buf()
2050 }
2051 else
2052 {
2053 manifest_path.to_path_buf()
2054 };
2055
2056 // normalize the path before creating workspace
2057 let workspace_root = Self ::cleanup_path( workspace_root );
2058
2059 Ok( Self { root: workspace_root } )
2060 }
2061
2062 /// get cargo metadata for this workspace
2063 ///
2064 /// # Errors
2065 ///
2066 /// returns error if cargo metadata command fails or workspace is not a cargo workspace
2067 pub fn cargo_metadata( &self ) -> Result< CargoMetadata >
2068 {
2069 let cargo_toml = self.cargo_toml();
2070
2071 if !cargo_toml.exists()
2072 {
2073 return Err( WorkspaceError::CargoError( "not a cargo workspace".to_string() ) );
2074 }
2075
2076 // use cargo_metadata crate for robust metadata extraction
2077 let metadata = cargo_metadata ::MetadataCommand ::new()
2078 .manifest_path( &cargo_toml )
2079 .exec()
2080 .map_err( | e | WorkspaceError::CargoError( e.to_string() ) )?;
2081
2082 let mut members = Vec ::new();
2083 let mut workspace_dependencies = HashMap ::new();
2084
2085 // extract workspace member information
2086 for package in metadata.workspace_packages()
2087 {
2088 members.push( CargoPackage {
2089 name: package.name.clone(),
2090 version: package.version.to_string(),
2091 manifest_path: package.manifest_path.clone().into(),
2092 package_root: package.manifest_path
2093 .parent()
2094 .unwrap_or( &package.manifest_path )
2095 .into(),
2096 } );
2097 }
2098
2099 // extract workspace dependencies if available
2100 if let Some( deps ) = metadata.workspace_metadata.get( "dependencies" )
2101 {
2102 if let Some( deps_map ) = deps.as_object()
2103 {
2104 for ( name, version ) in deps_map
2105 {
2106 if let Some( version_str ) = version.as_str()
2107 {
2108 workspace_dependencies.insert( name.clone(), version_str.to_string() );
2109 }
2110 }
2111 }
2112 }
2113
2114 Ok( CargoMetadata {
2115 workspace_root: metadata.workspace_root.into(),
2116 members,
2117 workspace_dependencies,
2118 } )
2119 }
2120
2121 /// check if this workspace is a cargo workspace
2122 #[ must_use ]
2123 pub fn is_cargo_workspace( &self ) -> bool
2124 {
2125 let cargo_toml = self.cargo_toml();
2126
2127 if !cargo_toml.exists()
2128 {
2129 return false;
2130 }
2131
2132 // check if Cargo.toml contains workspace section
2133 if let Ok( content ) = std ::fs ::read_to_string( &cargo_toml )
2134 {
2135 if let Ok( parsed ) = toml ::from_str :: < toml ::Value >( &content )
2136 {
2137 return parsed.get( "workspace" ).is_some();
2138 }
2139 }
2140
2141 false
2142 }
2143
2144 /// get workspace members (if cargo workspace)
2145 ///
2146 /// # Errors
2147 ///
2148 /// returns error if not a cargo workspace or cargo metadata fails
2149 pub fn workspace_members( &self ) -> Result< Vec< PathBuf > >
2150 {
2151 let metadata = self.cargo_metadata()?;
2152 Ok( metadata.members.into_iter().map( | pkg | pkg.package_root ).collect() )
2153 }
2154
2155 /// find cargo workspace root by traversing up directory tree
2156 fn find_cargo_workspace() -> Result< PathBuf >
2157 {
2158 let mut current = std ::env ::current_dir()
2159 .map_err( | e | WorkspaceError::IoError( e.to_string() ) )?;
2160
2161 loop
2162 {
2163 let manifest = current.join( "Cargo.toml" );
2164 if manifest.exists()
2165 {
2166 let content = std ::fs ::read_to_string( &manifest )
2167 .map_err( | e | WorkspaceError::IoError( e.to_string() ) )?;
2168
2169 let parsed: toml ::Value = toml ::from_str( &content )
2170 .map_err( | e | WorkspaceError::TomlError( e.to_string() ) )?;
2171
2172 // check if this is a workspace root
2173 if parsed.get( "workspace" ).is_some()
2174 {
2175 return Ok( current );
2176 }
2177
2178 // check if this is a workspace member pointing to a parent workspace
2179 if let Some( package ) = parsed.get( "package" )
2180 {
2181 if package.get( "workspace" ).is_some()
2182 {
2183 // continue searching upward for the actual workspace root
2184 }
2185 }
2186 }
2187
2188 match current.parent()
2189 {
2190 Some( parent ) => current = parent.to_path_buf(),
2191 None => return Err( WorkspaceError::PathNotFound( current ) ),
2192 }
2193 }
2194 }
2195}
2196
2197#[ cfg( any( feature = "serde", feature = "validation", feature = "secure" ) ) ]
2198impl Workspace
2199{
2200 /// internal helper to read file with error wrapping
2201 ///
2202 /// provides consistent error messages across all file reading operations
2203 fn read_file_to_string< P: AsRef< Path > >( path: P ) -> Result< String >
2204 {
2205 let path = path.as_ref();
2206 std ::fs ::read_to_string( path )
2207 .map_err( | e | WorkspaceError::IoError(
2208 format!( "failed to read {}: {}", path.display(), e )
2209 ) )
2210 }
2211
2212 /// internal helper to detect file format from extension
2213 ///
2214 /// returns format string (toml/json/yaml/yml) based on file extension
2215 fn detect_format< P: AsRef< Path > >( path: P ) -> String
2216 {
2217 path.as_ref()
2218 .extension()
2219 .and_then( | ext | ext.to_str() )
2220 .unwrap_or( "toml" )
2221 .to_string()
2222 }
2223}
2224
2225#[ cfg( feature = "serde" ) ]
2226impl Workspace
2227{
2228
2229 /// internal helper to parse config content based on format
2230 fn parse_content< T >( content: &str, format: &str ) -> Result< T >
2231 where
2232 T: serde ::de ::DeserializeOwned,
2233 {
2234 match format
2235 {
2236 "toml" => toml ::from_str( content )
2237 .map_err( | e | WorkspaceError::SerdeError( format!( "toml error: {e}" ) ) ),
2238 "json" => serde_json ::from_str( content )
2239 .map_err( | e | WorkspaceError::SerdeError( format!( "json error: {e}" ) ) ),
2240 "yaml" | "yml" => serde_yaml ::from_str( content )
2241 .map_err( | e | WorkspaceError::SerdeError( format!( "yaml error: {e}" ) ) ),
2242 _ => Err( WorkspaceError::ConfigurationError(
2243 format!( "unsupported format: {format}" )
2244 ) ),
2245 }
2246 }
2247
2248 /// internal helper to serialize config content based on format
2249 fn serialize_content< T >( config: &T, format: &str ) -> Result< String >
2250 where
2251 T: serde ::Serialize,
2252 {
2253 match format
2254 {
2255 "toml" => toml ::to_string_pretty( config )
2256 .map_err( | e | WorkspaceError::SerdeError( format!( "toml error: {e}" ) ) ),
2257 "json" => serde_json ::to_string_pretty( config )
2258 .map_err( | e | WorkspaceError::SerdeError( format!( "json error: {e}" ) ) ),
2259 "yaml" | "yml" => serde_yaml ::to_string( config )
2260 .map_err( | e | WorkspaceError::SerdeError( format!( "yaml error: {e}" ) ) ),
2261 _ => Err( WorkspaceError::ConfigurationError(
2262 format!( "unsupported format: {format}" )
2263 ) ),
2264 }
2265 }
2266
2267 /// load configuration with automatic format detection
2268 ///
2269 /// # Errors
2270 ///
2271 /// returns error if configuration file is not found or cannot be deserialized
2272 ///
2273 /// # examples
2274 ///
2275 /// ```rust,no_run
2276 /// use workspace_tools ::workspace;
2277 /// use serde ::Deserialize;
2278 ///
2279 /// #[ derive( Deserialize ) ]
2280 /// struct AppConfig
2281 /// {
2282 /// name: String,
2283 /// port: u16,
2284 /// }
2285 ///
2286 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
2287 /// let ws = workspace()?;
2288 /// // looks for config/app.toml, config/app.yaml, config/app.json
2289 /// let config: AppConfig = ws.load_config( "app" )?;
2290 /// # Ok(())
2291 /// # }
2292 /// ```
2293 pub fn load_config< T >( &self, name: &str ) -> Result< T >
2294 where
2295 T: serde ::de ::DeserializeOwned,
2296 {
2297 let config_path = self.find_config( name )?;
2298 self.load_config_from( config_path )
2299 }
2300
2301 /// load configuration from specific file
2302 ///
2303 /// # Errors
2304 ///
2305 /// returns error if file cannot be read or deserialized
2306 pub fn load_config_from< T, P >( &self, path: P ) -> Result< T >
2307 where
2308 T: serde ::de ::DeserializeOwned,
2309 P: AsRef< Path >,
2310 {
2311 let path = path.as_ref();
2312 let content = Self::read_file_to_string( path )?;
2313 let format = Self::detect_format( path );
2314 Self::parse_content( &content, &format )
2315 }
2316
2317 /// save configuration with format matching the original
2318 ///
2319 /// # Errors
2320 ///
2321 /// returns error if configuration cannot be serialized or written to file
2322 pub fn save_config< T >( &self, name: &str, config: &T ) -> Result< () >
2323 where
2324 T: serde ::Serialize,
2325 {
2326 let config_path = self.find_config( name )
2327 .or_else( |_| Ok( self.config_dir().join( format!( "{name}.toml" ) ) ) )?;
2328
2329 self.save_config_to( config_path, config )
2330 }
2331
2332 /// save configuration to specific file with format detection
2333 ///
2334 /// # Errors
2335 ///
2336 /// returns error if configuration cannot be serialized or written to file
2337 pub fn save_config_to< T, P >( &self, path: P, config: &T ) -> Result< () >
2338 where
2339 T: serde ::Serialize,
2340 P: AsRef< Path >,
2341 {
2342 let path = path.as_ref();
2343 let format = Self::detect_format( path );
2344 let content = Self::serialize_content( config, &format )?;
2345
2346 // ensure parent directory exists
2347 if let Some( parent ) = path.parent()
2348 {
2349 std ::fs ::create_dir_all( parent )
2350 .map_err( | e | WorkspaceError::IoError( format!( "failed to create directory {}: {}", parent.display(), e ) ) )?;
2351 }
2352
2353 // atomic write using temporary file
2354 let temp_path = path.with_extension( format!( "{format}.tmp" ) );
2355 std ::fs ::write( &temp_path, content )
2356 .map_err( | e | WorkspaceError::IoError( format!( "failed to write temporary file {}: {}", temp_path.display(), e ) ) )?;
2357
2358 std ::fs ::rename( &temp_path, path )
2359 .map_err( | e | WorkspaceError::IoError( format!( "failed to rename {} to {}: {}", temp_path.display(), path.display(), e ) ) )?;
2360
2361 Ok( () )
2362 }
2363
2364 /// load and merge multiple configuration layers
2365 ///
2366 /// # Errors
2367 ///
2368 /// returns error if any configuration file cannot be loaded or merged
2369 pub fn load_config_layered< T >( &self, names: &[ &str ] ) -> Result< T >
2370 where
2371 T: serde ::de ::DeserializeOwned + ConfigMerge,
2372 {
2373 let mut result: Option< T > = None;
2374
2375 for name in names
2376 {
2377 if let Ok( config ) = self.load_config :: < T >( name )
2378 {
2379 result = Some( match result
2380 {
2381 Some( existing ) => existing.merge( config ),
2382 None => config,
2383 } );
2384 }
2385 }
2386
2387 result.ok_or_else( || WorkspaceError::ConfigurationError( "no configuration files found".to_string() ) )
2388 }
2389
2390 /// update configuration partially
2391 ///
2392 /// # Errors
2393 ///
2394 /// returns error if configuration cannot be loaded, updated, or saved
2395 pub fn update_config< T, U >( &self, name: &str, updates: U ) -> Result< T >
2396 where
2397 T: serde ::de ::DeserializeOwned + serde ::Serialize,
2398 U: serde ::Serialize,
2399 {
2400 // load existing configuration
2401 let existing: T = self.load_config( name )?;
2402
2403 // serialize both to json for merging
2404 let existing_json = serde_json ::to_value( &existing )
2405 .map_err( | e | WorkspaceError::SerdeError( format!( "failed to serialize existing config: {e}" ) ) )?;
2406
2407 let updates_json = serde_json ::to_value( updates )
2408 .map_err( | e | WorkspaceError::SerdeError( format!( "failed to serialize updates: {e}" ) ) )?;
2409
2410 // merge json objects
2411 let merged = Self ::merge_json_objects( existing_json, updates_json )?;
2412
2413 // deserialize back to target type
2414 let merged_config: T = serde_json ::from_value( merged )
2415 .map_err( | e | WorkspaceError::SerdeError( format!( "failed to deserialize merged config: {e}" ) ) )?;
2416
2417 // save updated configuration
2418 self.save_config( name, &merged_config )?;
2419
2420 Ok( merged_config )
2421 }
2422
2423 /// merge two json objects recursively
2424 fn merge_json_objects( mut base: serde_json ::Value, updates: serde_json ::Value ) -> Result< serde_json ::Value >
2425 {
2426 match ( &mut base, updates )
2427 {
2428 ( serde_json ::Value ::Object( ref mut base_map ), serde_json ::Value ::Object( updates_map ) ) =>
2429 {
2430 for ( key, value ) in updates_map
2431 {
2432 match base_map.get_mut( &key )
2433 {
2434 Some( existing ) if existing.is_object() && value.is_object() =>
2435 {
2436 *existing = Self ::merge_json_objects( existing.clone(), value )?;
2437 }
2438 _ =>
2439 {
2440 base_map.insert( key, value );
2441 }
2442 }
2443 }
2444 }
2445 ( _, updates_value ) =>
2446 {
2447 base = updates_value;
2448 }
2449 }
2450
2451 Ok( base )
2452 }
2453}
2454
2455#[ cfg( feature = "serde" ) ]
2456impl serde ::Serialize for WorkspacePath
2457{
2458 fn serialize< S >( &self, serializer: S ) -> core ::result ::Result< S ::Ok, S ::Error >
2459 where
2460 S: serde ::Serializer,
2461 {
2462 self.0.serialize( serializer )
2463 }
2464}
2465
2466#[ cfg( feature = "serde" ) ]
2467impl< 'de > serde ::Deserialize< 'de > for WorkspacePath
2468{
2469 fn deserialize< D >( deserializer: D ) -> core ::result ::Result< Self, D ::Error >
2470 where
2471 D: serde ::Deserializer< 'de >,
2472 {
2473 let path = PathBuf ::deserialize( deserializer )?;
2474 Ok( WorkspacePath( path ) )
2475 }
2476}
2477
2478#[ cfg( feature = "validation" ) ]
2479impl Workspace
2480{
2481 /// internal helper to parse content to JSON value for validation
2482 fn parse_to_json( content: &str, format: &str ) -> Result< serde_json ::Value >
2483 {
2484 match format
2485 {
2486 "toml" =>
2487 {
2488 let toml_value: toml ::Value = toml ::from_str( content )
2489 .map_err( | e | WorkspaceError::SerdeError( format!( "toml parse: {e}" ) ) )?;
2490 serde_json ::to_value( toml_value )
2491 .map_err( | e | WorkspaceError::SerdeError( format!( "toml→json: {e}" ) ) )
2492 }
2493 "json" => serde_json ::from_str( content )
2494 .map_err( | e | WorkspaceError::SerdeError( format!( "json parse: {e}" ) ) ),
2495 "yaml" | "yml" =>
2496 {
2497 let yaml_value: serde_yaml ::Value = serde_yaml ::from_str( content )
2498 .map_err( | e | WorkspaceError::SerdeError( format!( "yaml parse: {e}" ) ) )?;
2499 serde_json ::to_value( yaml_value )
2500 .map_err( | e | WorkspaceError::SerdeError( format!( "yaml→json: {e}" ) ) )
2501 }
2502 _ => Err( WorkspaceError::ConfigurationError(
2503 format!( "unsupported format: {format}" )
2504 ) ),
2505 }
2506 }
2507
2508 /// internal helper to validate JSON against schema
2509 fn validate_against_schema(
2510 json_value: &serde_json ::Value,
2511 schema: &Validator
2512 ) -> Result< () >
2513 {
2514 if let Err( validation_errors ) = schema.validate( json_value )
2515 {
2516 let errors: Vec< String > = validation_errors
2517 .map( | error | format!( "{}: {}", error.instance_path, error ) )
2518 .collect();
2519 return Err( WorkspaceError::ValidationError(
2520 format!( "validation failed: {}", errors.join( "; " ) )
2521 ) );
2522 }
2523 Ok( () )
2524 }
2525
2526 /// load and validate configuration against a json schema
2527 ///
2528 /// # Errors
2529 ///
2530 /// returns error if configuration cannot be loaded, schema is invalid, or validation fails
2531 ///
2532 /// # examples
2533 ///
2534 /// ```rust,no_run
2535 /// use workspace_tools ::workspace;
2536 /// use serde :: { Deserialize };
2537 /// use schemars ::JsonSchema;
2538 ///
2539 /// #[ derive( Deserialize, JsonSchema ) ]
2540 /// struct AppConfig
2541 /// {
2542 /// name: String,
2543 /// port: u16,
2544 /// }
2545 ///
2546 /// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
2547 /// let ws = workspace()?;
2548 /// let config: AppConfig = ws.load_config_with_validation( "app" )?;
2549 /// # Ok(())
2550 /// # }
2551 /// ```
2552 pub fn load_config_with_validation< T >( &self, name: &str ) -> Result< T >
2553 where
2554 T: serde ::de ::DeserializeOwned + JsonSchema,
2555 {
2556 // generate schema from type
2557 let schema = schemars ::schema_for!( T );
2558 let schema_json = serde_json ::to_value( &schema )
2559 .map_err( | e | WorkspaceError::ValidationError( format!( "failed to serialize schema: {e}" ) ) )?;
2560
2561 // compile schema for validation
2562 let compiled_schema = Validator ::new( &schema_json )
2563 .map_err( | e | WorkspaceError::ValidationError( format!( "failed to compile schema: {e}" ) ) )?;
2564
2565 self.load_config_with_schema( name, &compiled_schema )
2566 }
2567
2568 /// load and validate configuration against a provided json schema
2569 ///
2570 /// # Errors
2571 ///
2572 /// returns error if configuration cannot be loaded or validation fails
2573 pub fn load_config_with_schema< T >( &self, name: &str, schema: &Validator ) -> Result< T >
2574 where
2575 T: serde ::de ::DeserializeOwned,
2576 {
2577 let config_path = self.find_config( name )?;
2578 self.load_config_from_with_schema( config_path, schema )
2579 }
2580
2581 /// load and validate configuration from specific file with schema
2582 ///
2583 /// # Errors
2584 ///
2585 /// returns error if file cannot be read, parsed, or validated
2586 pub fn load_config_from_with_schema< T, P >( &self, path: P, schema: &Validator ) -> Result< T >
2587 where
2588 T: serde ::de ::DeserializeOwned,
2589 P: AsRef< Path >,
2590 {
2591 let path = path.as_ref();
2592 let content = Self::read_file_to_string( path )?;
2593 let format = Self::detect_format( path );
2594
2595 // parse to json value first for validation
2596 let json_value = Self::parse_to_json( &content, &format )?;
2597
2598 // validate against schema
2599 Self::validate_against_schema( &json_value, schema )?;
2600
2601 // if validation passes, deserialize to target type
2602 serde_json ::from_value( json_value )
2603 .map_err( | e | WorkspaceError::SerdeError( format!( "deserialization error: {e}" ) ) )
2604 }
2605
2606 /// validate configuration content against schema without loading
2607 ///
2608 /// # Errors
2609 ///
2610 /// returns error if content cannot be parsed or validation fails
2611 pub fn validate_config_content( content: &str, schema: &Validator, format: &str ) -> Result< () >
2612 {
2613 // parse content to json value
2614 let json_value = Self::parse_to_json( content, format )?;
2615
2616 // validate against schema
2617 Self::validate_against_schema( &json_value, schema )
2618 }
2619}
2620
2621/// testing utilities for workspace functionality
2622#[ cfg( feature = "testing" ) ]
2623pub mod testing
2624{
2625 use super ::Workspace;
2626 use tempfile ::TempDir;
2627
2628 /// create a temporary workspace for testing
2629 ///
2630 /// returns a tuple of (`temp_dir`, workspace) where `temp_dir` must be kept alive
2631 /// for the duration of the test to prevent the directory from being deleted
2632 ///
2633 /// # Panics
2634 ///
2635 /// panics if temporary directory creation fails or workspace resolution fails
2636 ///
2637 /// # examples
2638 ///
2639 /// ```rust
2640 /// #[ cfg( test ) ]
2641 /// mod tests
2642 /// {
2643 /// use workspace_tools ::testing ::create_test_workspace;
2644 ///
2645 /// #[ test ]
2646 /// fn test_my_feature()
2647 /// {
2648 /// let ( _temp_dir, workspace ) = create_test_workspace();
2649 ///
2650 /// // test with isolated workspace
2651 /// let config = workspace.config_dir().join( "test.toml" );
2652 /// assert!( config.starts_with( workspace.root() ) );
2653 /// }
2654 /// }
2655 /// ```
2656 #[ must_use ]
2657 #[ inline ]
2658 pub fn create_test_workspace() -> ( TempDir, Workspace )
2659 {
2660 let temp_dir = TempDir ::new().unwrap_or_else( | e | panic!( "failed to create temp directory: {e}" ) );
2661 std ::env ::set_var( "WORKSPACE_PATH", temp_dir.path() );
2662 let workspace = Workspace ::resolve().unwrap_or_else( | e | panic!( "failed to resolve test workspace: {e}" ) );
2663 ( temp_dir, workspace )
2664 }
2665
2666 /// create test workspace with standard directory structure
2667 ///
2668 /// creates a temporary workspace with config/, data/, logs/, docs/, tests/ directories
2669 ///
2670 /// # Panics
2671 ///
2672 /// panics if temporary directory creation fails or if any standard directory creation fails
2673 #[ must_use ]
2674 #[ inline ]
2675 pub fn create_test_workspace_with_structure() -> ( TempDir, Workspace )
2676 {
2677 let ( temp_dir, workspace ) = create_test_workspace();
2678
2679 // create standard directories
2680 let base_dirs = vec!
2681 [
2682 workspace.config_dir(),
2683 workspace.data_dir(),
2684 workspace.logs_dir(),
2685 workspace.docs_dir(),
2686 workspace.tests_dir(),
2687 workspace.workspace_dir(),
2688 ];
2689
2690 #[ cfg( feature = "secrets" ) ]
2691 let all_dirs = {
2692 let mut dirs = base_dirs;
2693 dirs.push( workspace.secret_dir() );
2694 dirs
2695 };
2696
2697 #[ cfg( not( feature = "secrets" ) ) ]
2698 let all_dirs = base_dirs;
2699
2700 for dir in all_dirs
2701 {
2702 std ::fs ::create_dir_all( &dir )
2703 .unwrap_or_else( | e | panic!( "failed to create directory {} : {}", dir.display(), e ) );
2704 }
2705
2706 ( temp_dir, workspace )
2707 }
2708}
2709
2710/// convenience function to get workspace instance with extended fallbacks
2711///
2712/// uses `Workspace ::resolve_with_extended_fallbacks()` which tries multiple
2713/// strategies including $PRO and $HOME for installed CLI applications.
2714/// always succeeds by falling back through multiple strategies.
2715///
2716/// # note
2717///
2718/// this function always succeeds (never returns Err), but maintains `Result`
2719/// return type for backward compatibility. you can safely `.unwrap()` the result.
2720///
2721/// # Errors
2722///
2723/// this function never returns an error. it always succeeds by falling back
2724/// through multiple resolution strategies. the `Result` return type is maintained
2725/// for backward compatibility only.
2726///
2727/// # examples
2728///
2729/// ```rust
2730/// # fn main() -> Result< (), workspace_tools ::WorkspaceError > {
2731/// use workspace_tools ::workspace;
2732///
2733/// // works without WORKSPACE_PATH set (uses fallbacks)
2734/// let ws = workspace()?;
2735/// let config_dir = ws.config_dir();
2736/// # Ok(())
2737/// # }
2738/// ```
2739///
2740/// # resolution priority
2741///
2742/// 1. cargo workspace (development context)
2743/// 2. `WORKSPACE_PATH` environment variable
2744/// 3. git repository root
2745/// 4. `$PRO` environment variable (installed apps)
2746/// 5. `$HOME` directory (universal fallback)
2747/// 6. current working directory
2748#[ inline ]
2749pub fn workspace() -> Result< Workspace >
2750{
2751 Ok( Workspace ::resolve_with_extended_fallbacks() )
2752}