diff --git a/README.md b/README.md index 9504095..567277f 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,254 @@ -# rs-env +# rsenv -> [Hierarchical environment variable management](https://sysid.github.io/hierarchical-environment-variable-management/) +**Hierarchical environment variable management for modern development workflows** -## Why -Managing environment variables for different stages, regions, etc. is an unavoidable chore -when working on cloud projects. +> [Documentation](https://sysid.github.io/hierarchical-environment-variable-management/) -Especially the challenge of avoiding duplication and knowing where a particular value is coming from. -Hierarchical variable management seems to be a good solution for this problem. +## Overview -# Features -- **Hierarchical inheritance**: Compile environment variables from linked `.env` files forming tree structures -- **Variable override**: Child variables override parent variables (last defined wins) -- **Environment variable expansion**: Support for `$VAR` and `${VAR}` syntax in file paths and rsenv comments -- **Interactive selection**: Smart environment selection and editing via built-in FZF -- **Multiple integrations**: - - [direnv](https://direnv.net/) integration for automatic environment loading - - [JetBrains EnvFile plugin](https://plugins.jetbrains.com/plugin/7861-envfile) support -- **Flexible editing**: Side-by-side tree editing and individual file editing +Managing environment variables across different environments (development, staging, production) and configurations (regions, features, services) is a common challenge in cloud-native projects. `rsenv` solves this by implementing hierarchical inheritance where child configurations automatically inherit and can override parent values. + +**Key Benefits:** +- **Zero Duplication**: Share common variables across environments while customizing specific values +- **Clear Provenance**: Easily trace where each variable value originates in the hierarchy +- **Type Safety**: Built-in validation and structured configuration management +- **Developer Experience**: Interactive selection, editing, and integration with popular tools + +## Features + +### Core Functionality +- **Hierarchical Inheritance**: Build environment trees from `.env` files with parent-child relationships +- **Smart Override Logic**: Child variables automatically override parent values with clear precedence rules +- **Standalone File Support**: Automatically detect and include independent `.env` files as single-node trees +- **Environment Expansion**: Full support for `$VAR` and `${VAR}` syntax in paths and comments +- **Flexible Linking**: Link files with comments supporting variable spacing: `# rsenv: parent.env` + +### Interactive Tools +- **Fuzzy Selection**: Built-in fuzzy finder (skim) for rapid environment discovery and selection +- **Smart Editing**: Edit entire hierarchies side-by-side or individual files with full context +- **Tree Visualization**: Display hierarchical relationships and identify leaf nodes +- **Branch Analysis**: Linear representation of all environment chains + +### Integrations +- **[direnv](https://direnv.net/)**: Automatic environment activation when entering directories +- **[JetBrains IDEs](https://plugins.jetbrains.com/plugin/7861-envfile)**: Native IDE integration via EnvFile plugin +- **Shell Integration**: Generate completion scripts for bash, zsh, fish, and powershell ### Concept ![concept](doc/concept.png) -### Installation +## Installation + +### From crates.io ```bash cargo install rs-env ``` -### Usage +### From source +```bash +git clone https://github.com/sysid/rs-env +cd rs-env/rsenv +cargo install --path . +``` + +## Quick Start + +### 1. File Structure +Create linked environment files using `# rsenv:` comments: -**File Linking**: Environment files are linked via comments: ```bash -# rsenv: parent.env -# rsenv: $HOME/config/base.env # Environment variables supported -export MY_VAR=value +# base.env +export DATABASE_HOST=localhost +export LOG_LEVEL=info + +# production.env +# rsenv: base.env +export DATABASE_HOST=prod.example.com +export LOG_LEVEL=warn +export ENVIRONMENT=production ``` -**Basic Commands**: +### 2. Basic Usage ```bash -# Build and display environment variables +# Build complete environment from hierarchy rsenv build production.env -# Load variables into current shell +# Load into current shell source <(rsenv build production.env) -# Interactive environment selection -rsenv select +# Interactive selection with fuzzy finder +rsenv select environments/ + +# View tree structure +rsenv tree environments/ -# View hierarchy structure -rsenv tree +# Find all leaf environments +rsenv leaves environments/ ``` -**Structure**: -- **Environment variables** must use `export` prefix in `.env` files -- **Tree structure** where child variables override parent variables -- **Multiple hierarchies** supported per project -- See [examples](./rsenv/tests/resources/environments) for detailed usage patterns +### 3. Advanced Features +```bash +# Edit entire hierarchy side-by-side +rsenv tree-edit environments/ +# Update direnv integration +rsenv envrc production.env + +# Link files programmatically +rsenv link base.env staging.env production.env ``` -Hierarchical environment variable management - -Usage: rsenv [OPTIONS] [NAME] [COMMAND] - -Commands: - build Build and display the complete set of environment variables - envrc Write environment variables to .envrc file (requires direnv) - files List all files in the environment hierarchy - edit-leaf Edit an environment file and all its parent files - edit Interactively select and edit an environment hierarchy - select-leaf Update .envrc with selected environment (requires direnv) - select Interactively select environment and update .envrc (requires direnv) - link Create parent-child relationships between environment files - branches Show all branches (linear representation) - tree Show all trees (hierarchical representation) - tree-edit Edit all environment hierarchies side-by-side (requires vim) - leaves List all leaf environment files - help Print this message or the help of the given subcommand(s) - -Arguments: - [NAME] Name of the configuration to operate on (optional) - -Options: - -d, --debug... Enable debug logging. Multiple flags (-d, -dd, -ddd) increase verbosity - --generate Generate shell completion scripts [possible values: bash, elvish, fish, powershell, zsh] - --info Display version and configuration information - -h, --help Print help - -V, --version Print version + +## File Format + +### Environment Files +- Use `export VAR=value` syntax (shell-compatible) +- Support single and double quotes +- Include `# rsenv: parent.env` comments for hierarchy + +### Linking Syntax +```bash +# Basic parent reference +# rsenv: parent.env + +# Multiple parents (creates DAG structure) +# rsenv: base.env shared.env + +# Environment variable expansion +# rsenv: $HOME/config/base.env + +# Flexible spacing (all valid) +# rsenv:parent.env +# rsenv: parent.env +# rsenv: parent.env ``` -#### Basic +## Command Reference + +### Core Commands +| Command | Description | Example | +|---------|-------------|---------| +| `build` | Build complete environment from hierarchy | `rsenv build prod.env` | +| `leaves` | List all leaf environment files | `rsenv leaves environments/` | +| `tree` | Display hierarchical structure | `rsenv tree environments/` | +| `branches` | Show linear representation of all chains | `rsenv branches environments/` | + +### Interactive Commands +| Command | Description | Example | +|---------|-------------|---------| +| `select` | Interactive environment selection + direnv update | `rsenv select environments/` | +| `edit` | Interactive selection and editing | `rsenv edit environments/` | +| `tree-edit` | Side-by-side editing of hierarchies | `rsenv tree-edit environments/` | + +### Management Commands +| Command | Description | Example | +|---------|-------------|---------| +| `envrc` | Update .envrc file for direnv | `rsenv envrc prod.env .envrc` | +| `files` | List all files in hierarchy | `rsenv files prod.env` | +| `link` | Create parent-child relationships | `rsenv link base.env prod.env` | + +### Global Options +- `-d, --debug`: Enable debug logging (use multiple times for increased verbosity) +- `--generate`: Generate shell completion scripts (bash, zsh, fish, powershell) +- `--info`: Display version and configuration information + +## Examples + +### Basic Workflow -
-#### Select via FZF +### Interactive Selection -
-#### Tree and Branch structure (Smart edit) +### Tree Editing -
## Integrations -### direnv -[direnv](https://direnv.net/) automatically activates environments when entering directories: +### direnv Integration +[direnv](https://direnv.net/) provides automatic environment activation: + ```bash -# Update .envrc with selected environment +# Generate .envrc for automatic loading rsenv envrc production.env .envrc + +# Interactive selection with direnv update +rsenv select environments/ + +# Manual direnv commands +direnv allow # Enable .envrc +direnv reload # Refresh environment ``` ### JetBrains IDEs -Use the [EnvFile plugin](https://plugins.jetbrains.com/plugin/7861-envfile) for IDE integration: -- Configure `runenv.sh` as the EnvFile script -- Set `RUN_ENV` environment variable to specify which environment to load -- The plugin will automatically load variables from `.env` +Integration via [EnvFile plugin](https://plugins.jetbrains.com/plugin/7861-envfile): + +1. Install the EnvFile plugin +2. Create a run configuration script: + ```bash + #!/bin/bash + # runenv.sh + rsenv build "${RUN_ENV}.env" + ``` +3. Configure the plugin to use `runenv.sh` +4. Set `RUN_ENV` environment variable in your run configuration [![jetbrain](doc/jetbrain.png)](doc/jetbrain.png) +### Shell Integration +Generate completion scripts for your shell: + +```bash +# Bash +rsenv --generate bash > ~/.bash_completion.d/rsenv + +# Zsh +rsenv --generate zsh > ~/.zsh/completions/_rsenv + +# Fish +rsenv --generate fish > ~/.config/fish/completions/rsenv.fish +``` + ## Development -- Tests for "skim" need valid terminal, so they are run via Makefile. -- Test for `rsenv select`: run debug target and check rsenv .envrc file. + +### Building from Source +```bash +git clone https://github.com/sysid/rs-env +cd rs-env/rsenv +cargo build --release +``` + +### Running Tests +```bash +# Run all tests (single-threaded to prevent conflicts) +cargo test -- --test-threads=1 + +# Run with debug output +RUST_LOG=debug cargo test -- --test-threads=1 + +# Test via Makefile (includes interactive tests) +make test +``` + +### Project Structure +- **`src/lib.rs`**: Core environment expansion and tree building logic +- **`src/builder.rs`**: TreeBuilder for constructing hierarchical structures +- **`src/arena.rs`**: Arena-based tree data structures +- **`src/cli/`**: Command-line interface and command implementations +- **`tests/`**: Comprehensive test suite with example environments + +### Testing Notes +- Interactive tests using skim require a valid terminal and are run via Makefile +- Tests run single-threaded to prevent file system conflicts +- Test resources in `tests/resources/environments/` demonstrate complex hierarchies + +### Contributing +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass: `cargo test -- --test-threads=1` +5. Run formatting: `cargo fmt` +6. Run linting: `cargo clippy` +7. Submit a pull request diff --git a/rsenv/src/arena.rs b/rsenv/src/arena.rs index 3ce289d..2ce258e 100644 --- a/rsenv/src/arena.rs +++ b/rsenv/src/arena.rs @@ -3,9 +3,12 @@ use std::fmt; use std::path::PathBuf; use tracing::instrument; +/// Data payload for tree nodes representing environment files. #[derive(Debug, Clone)] pub struct NodeData { + /// Directory containing the environment file pub base_path: PathBuf, + /// Full path to the environment file pub file_path: PathBuf, } @@ -15,16 +18,26 @@ impl fmt::Display for NodeData { } } +/// Tree node in the arena-based hierarchy structure. #[derive(Debug)] pub struct TreeNode { + /// Environment file data for this node pub data: NodeData, + /// Index of parent node in the arena, None for root nodes pub parent: Option, + /// Indices of child nodes in the arena pub children: Vec, } +/// Arena-based tree structure for efficient hierarchy management. +/// +/// Uses generational arena for memory-safe node references and O(1) lookups. +/// Each tree represents one complete environment hierarchy. #[derive(Debug)] pub struct TreeArena { + /// Arena storage for all tree nodes arena: Arena, + /// Index of the root node, None for empty trees root: Option, } @@ -110,6 +123,10 @@ impl TreeArena { } } + /// Collects all leaf nodes (nodes with no children) in the tree. + /// + /// Returns file paths as strings for easy display and processing. + /// Empty trees return an empty vector. #[instrument(level = "debug", skip(self))] pub fn leaf_nodes(&self) -> Vec { let mut leaves = Vec::new(); diff --git a/rsenv/src/builder.rs b/rsenv/src/builder.rs index dea509f..a997ffb 100644 --- a/rsenv/src/builder.rs +++ b/rsenv/src/builder.rs @@ -11,10 +11,20 @@ use crate::arena::{NodeData, TreeArena}; use crate::errors::{TreeError, TreeResult}; use crate::util::path::PathExt; +/// Constructs hierarchical trees from environment files by analyzing parent-child relationships. +/// +/// The builder implements a two-phase algorithm: +/// 1. Scan phase: Discovers all .env files and their rsenv comment relationships +/// 2. Build phase: Constructs arena-based trees from the discovered relationships pub struct TreeBuilder { + /// Maps parent files to their child files for efficient tree construction relationship_cache: HashMap>, + /// Tracks visited paths during tree traversal to detect cycles visited_paths: HashSet, + /// Regex pattern for parsing rsenv comments with flexible spacing parent_regex: Regex, + /// All discovered .env files, including standalone files without relationships + all_files: HashSet, } impl Default for TreeBuilder { @@ -28,7 +38,8 @@ impl TreeBuilder { Self { relationship_cache: HashMap::new(), visited_paths: HashSet::new(), - parent_regex: Regex::new(r"# rsenv: (.+)").unwrap(), + parent_regex: Regex::new(r"# rsenv:\s*(.+)").unwrap(), + all_files: HashSet::new(), } } @@ -68,7 +79,11 @@ impl TreeBuilder { reason: e.to_string(), })?; - if entry.file_type().is_file() { + if entry.file_type().is_file() + && entry.path().extension().is_some_and(|ext| ext == "env") + { + let abs_path = entry.path().to_canonical()?; + self.all_files.insert(abs_path.clone()); self.process_file(entry.path())?; } } @@ -101,13 +116,38 @@ impl TreeBuilder { Ok(()) } + /// Identifies root nodes for tree construction using a dual-strategy approach. + /// + /// Root nodes are files that can serve as tree roots, identified by: + /// 1. Traditional hierarchy roots: files that are parents but not children + /// 2. Standalone files: files with no parent-child relationships + /// + /// This ensures standalone .env files are included as single-node trees. #[instrument(level = "debug", skip(self))] fn find_root_nodes(&self) -> Vec { - self.relationship_cache - .keys() - .filter(|path| !self.relationship_cache.values().any(|v| v.contains(path))) - .cloned() - .collect() + let mut root_nodes = Vec::new(); + + // Find files that are parents but not children (traditional root nodes) + for path in self.relationship_cache.keys() { + if !self.relationship_cache.values().any(|v| v.contains(path)) { + root_nodes.push(path.clone()); + } + } + + // Find standalone files (files not in any relationship) + for file_path in &self.all_files { + let is_parent = self.relationship_cache.contains_key(file_path); + let is_child = self + .relationship_cache + .values() + .any(|v| v.contains(file_path)); + + if !is_parent && !is_child { + root_nodes.push(file_path.clone()); + } + } + + root_nodes } #[instrument(level = "debug", skip(self))] diff --git a/rsenv/src/edit.rs b/rsenv/src/edit.rs index 4463c2a..065585b 100644 --- a/rsenv/src/edit.rs +++ b/rsenv/src/edit.rs @@ -15,11 +15,17 @@ use walkdir::WalkDir; use crate::arena::TreeArena; use crate::errors::{TreeError, TreeResult}; +/// Interactive file selection using fuzzy search interface. +/// +/// Walks the directory tree to find files with the specified suffix, then +/// presents them in a skim (fzf-like) interface for user selection. +/// +/// Returns the selected file path or an error if no files found or selection cancelled. #[instrument(level = "debug")] pub fn select_file_with_suffix(dir: &Path, suffix: &str) -> TreeResult { debug!("Searching for files with suffix {} in {:?}", suffix, dir); - // List all files with the given suffix + // Discover all files matching the suffix pattern let files: Vec = WalkDir::new(dir) .into_iter() .filter_map(|e| e.ok()) @@ -51,7 +57,7 @@ pub fn select_file_with_suffix(dir: &Path, suffix: &str) -> TreeResult })?; } - // This step is important because Skim::run_with() needs to know when there are no more items to expect. + // Signal end of items to skim drop(tx); // Close the channel let options = SkimOptionsBuilder::default() diff --git a/rsenv/src/lib.rs b/rsenv/src/lib.rs index ce19a66..f975b99 100644 --- a/rsenv/src/lib.rs +++ b/rsenv/src/lib.rs @@ -19,19 +19,32 @@ pub mod errors; pub mod tree_traits; pub mod util; -/// Expands environment variables in a path string -/// Supports both $VAR and ${VAR} syntax +/// Expands environment variables in a path string using shell-style syntax. +/// +/// Supports both `$VAR` and `${VAR}` formats. Variables that don't exist in the +/// environment are left unexpanded (no replacement occurs). +/// +/// # Examples +/// +/// ``` +/// use rsenv::expand_env_vars; +/// std::env::set_var("HOME", "/users/test"); +/// +/// assert_eq!(expand_env_vars("$HOME/config"), "/users/test/config"); +/// assert_eq!(expand_env_vars("${HOME}/config"), "/users/test/config"); +/// assert_eq!(expand_env_vars("$NONEXISTENT/path"), "$NONEXISTENT/path"); +/// ``` pub fn expand_env_vars(path: &str) -> String { let mut result = path.to_string(); - // Find all occurrences of $VAR or ${VAR} + // Pattern captures both $VAR (group 1) and ${VAR} (group 2) syntax let env_var_pattern = Regex::new(r"\$(\w+)|\$\{(\w+)\}").unwrap(); - // Collect all matches first to avoid borrow checker issues with replace_all + // Collect matches first to avoid borrow checker conflicts during replacement let matches: Vec<_> = env_var_pattern.captures_iter(path).collect(); for cap in matches { - // Get the variable name from either $VAR or ${VAR} pattern + // Extract variable name from whichever capture group matched let var_name = cap.get(1).or_else(|| cap.get(2)).unwrap().as_str(); let var_placeholder = if cap.get(1).is_some() { format!("${}", var_name) @@ -39,7 +52,7 @@ pub fn expand_env_vars(path: &str) -> String { format!("${{{}}}", var_name) }; - // Replace with environment variable value or empty string if not found + // Only replace if the environment variable exists if let Ok(var_value) = std::env::var(var_name) { result = result.replace(&var_placeholder, &var_value); } @@ -80,7 +93,7 @@ pub fn build_env_vars(file_path: &Path) -> TreeResult { #[instrument(level = "trace")] pub fn is_dag(dir_path: &Path) -> TreeResult { - let re = Regex::new(r"# rsenv: (.+)").map_err(|e| TreeError::InternalError(e.to_string()))?; + let re = Regex::new(r"# rsenv:\s*(.+)").map_err(|e| TreeError::InternalError(e.to_string()))?; // Walk through each file in the directory for entry in WalkDir::new(dir_path) { @@ -107,14 +120,18 @@ pub fn is_dag(dir_path: &Path) -> TreeResult { Ok(false) } -/// Recursively builds map of environment variables from the specified file and its parents. +/// Builds a complete environment variable map by traversing the hierarchy starting from a file. /// -/// This function reads the specified `file_path` and extracts environment variables from it. -/// It recognizes `export` statements to capture key-value pairs and uses special `# rsenv:` -/// comments to identify parent files for further extraction. +/// Implements a breadth-first traversal algorithm that processes all parent files +/// referenced by `# rsenv:` comments. Variable precedence follows these rules: +/// - Child variables override parent variables +/// - When multiple parents exist, rightmost sibling wins +/// - First encountered value wins during traversal (enables child override) /// -/// child wins against parent -/// rightmost sibling wins +/// Returns a tuple containing: +/// - Variable map with resolved values +/// - List of all processed files in traversal order +/// - Boolean indicating if the structure contains multiple parents (DAG detection) #[instrument(level = "debug")] pub fn build_env(file_path: &Path) -> TreeResult<(BTreeMap, Vec, bool)> { warn_if_symlink(file_path)?; diff --git a/rsenv/tests/test_edit.rs b/rsenv/tests/test_edit.rs index a96cd86..aa0441e 100644 --- a/rsenv/tests/test_edit.rs +++ b/rsenv/tests/test_edit.rs @@ -191,13 +191,16 @@ fn given_complex_structure_when_creating_branches_then_returns_correct_hierarchy .collect(); result.sort(); - let mut expected = vec![vec![ - "level4.env", - "level3.env", - "level2.env", - "level1.env", - "dot.envrc", - ]]; + let mut expected = vec![ + vec![ + "level4.env", + "level3.env", + "level2.env", + "level1.env", + "dot.envrc", + ], + vec!["result.env"], // result.env is a standalone file, so it creates its own single-node tree + ]; expected.sort(); assert_eq!(result, expected); Ok(()) diff --git a/rsenv/tests/test_lib.rs b/rsenv/tests/test_lib.rs index 9332058..ba48e16 100644 --- a/rsenv/tests/test_lib.rs +++ b/rsenv/tests/test_lib.rs @@ -269,3 +269,60 @@ fn given_symlinked_file_when_extracting_env_then_outputs_warning() -> TreeResult fs::remove_file("./tests/resources/environments/complex/symlink.env")?; Ok(()) } + +#[rstest] +fn given_rsenv_comment_with_flexible_spacing_when_extracting_env_then_parses_correctly( +) -> TreeResult<()> { + let tempdir = tempdir()?; + let temp_path = tempdir.path(); + + // Create parent file + let parent_file = temp_path.join("parent.env"); + fs::write(&parent_file, "export PARENT_VAR=parent_value\n")?; + + // Test different spacing patterns + let test_cases = vec![ + ("# rsenv:parent.env", "no space after colon"), + ("# rsenv: parent.env", "one space after colon"), + ("# rsenv: parent.env", "two spaces after colon"), + ("# rsenv: parent.env", "three spaces after colon"), + ("# rsenv:\tparent.env", "tab after colon"), + ("# rsenv: \tparent.env", "space and tab after colon"), + ]; + + for (rsenv_comment, description) in test_cases { + let child_file = temp_path.join(format!("child_{}.env", description.replace(" ", "_"))); + let content = format!("{}\nexport CHILD_VAR=child_value\n", rsenv_comment); + fs::write(&child_file, content)?; + + let (variables, parents) = extract_env(&child_file)?; + + // Verify that the parent was parsed correctly + assert_eq!( + parents.len(), + 1, + "Failed to parse parent for case: {}", + description + ); + assert_eq!( + parents[0].canonicalize()?, + parent_file.canonicalize()?, + "Wrong parent path for case: {}", + description + ); + + // Verify that variables are correct + assert_eq!(variables.get("CHILD_VAR"), Some(&"child_value".to_string())); + + // Test build_env to ensure full integration works + let (env_vars, files, _) = build_env(&child_file)?; + assert_eq!( + env_vars.get("PARENT_VAR"), + Some(&"parent_value".to_string()) + ); + assert_eq!(env_vars.get("CHILD_VAR"), Some(&"child_value".to_string())); + assert_eq!(files.len(), 2); + } + + Ok(()) +} diff --git a/rsenv/tests/test_tree.rs b/rsenv/tests/test_tree.rs index 814df31..86e7a59 100644 --- a/rsenv/tests/test_tree.rs +++ b/rsenv/tests/test_tree.rs @@ -44,7 +44,8 @@ fn given_complex_hierarchy_when_building_trees_then_returns_correct_depth_and_le println!("trees: {:#?}", trees); for tree in &trees { println!("Depth of tree: {}", tree.depth()); - assert_eq!(tree.depth(), 5); + // The first tree should be the hierarchy with depth 5, the second should be the standalone file with depth 1 + assert!(tree.depth() == 5 || tree.depth() == 1); } for tree in &trees { let leaf_nodes = tree.leaf_nodes(); @@ -53,7 +54,8 @@ fn given_complex_hierarchy_when_building_trees_then_returns_correct_depth_and_le println!("{}", leaf); } assert_eq!(leaf_nodes.len(), 1); - assert!(leaf_nodes[0].ends_with("level4.env")); + // Each tree should have exactly one leaf: either level4.env or result.env + assert!(leaf_nodes[0].ends_with("level4.env") || leaf_nodes[0].ends_with("result.env")); } Ok(()) } @@ -209,7 +211,7 @@ fn given_complex_structure_when_printing_tree_then_shows_nested_hierarchy() { let trees = builder .build_from_directory(Path::new("./tests/resources/environments/complex")) .unwrap(); - assert_eq!(trees.len(), 1); + assert_eq!(trees.len(), 2); // One hierarchy tree + one standalone file (result.env) for tree in &trees { if let Some(root_idx) = tree.root() { if let Some(root_node) = tree.get_node(root_idx) { @@ -220,10 +222,13 @@ fn given_complex_structure_when_printing_tree_then_shows_nested_hierarchy() { // Convert absolute paths to relative using path helper let relative_str = path::relativize_tree_str(&tree_str, "tests/"); println!("{}", relative_str); - assert_eq!( - normalize_path_separator(&relative_str), - normalize_path_separator(expected) - ); + // Only check the hierarchical tree, not the standalone result.env tree + if relative_str.contains("dot.envrc") { + assert_eq!( + normalize_path_separator(&relative_str), + normalize_path_separator(expected) + ); + } } } } @@ -292,3 +297,30 @@ fn given_tree_structure_when_printing_complete_tree_then_shows_all_branches() { } } } + +#[rstest] +fn given_mixed_standalone_and_hierarchical_files_when_getting_leaves_then_returns_correct_leaves( +) -> Result<()> { + let mut builder = TreeBuilder::new(); + let trees = + builder.build_from_directory(Path::new("./tests/resources/environments/parallel"))?; + + let mut all_leaves = Vec::new(); + for tree in &trees { + let leaf_nodes = tree.leaf_nodes(); + all_leaves.extend(leaf_nodes); + } + + // Should return only the leaf nodes from hierarchical trees (test.env, int.env, prod.env) + // but not the standalone files that are part of hierarchies (a_test.env, b_test.env, etc.) + assert_eq!(all_leaves.len(), 3); + assert!(all_leaves.iter().any(|leaf| leaf.ends_with("test.env"))); + assert!(all_leaves.iter().any(|leaf| leaf.ends_with("int.env"))); + assert!(all_leaves.iter().any(|leaf| leaf.ends_with("prod.env"))); + + // These should NOT be leaves as they are part of hierarchies + assert!(!all_leaves.iter().any(|leaf| leaf.ends_with("a_test.env"))); + assert!(!all_leaves.iter().any(|leaf| leaf.ends_with("b_test.env"))); + + Ok(()) +}