Thanks to visit codestin.com
Credit goes to docs.rs

Skip to main content

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}