From 5f279e94c1a83a4360e58a15c97346b708b58e5f Mon Sep 17 00:00:00 2001 From: sysid Date: Wed, 2 Jul 2025 21:25:59 +0200 Subject: [PATCH 1/3] feat: support flexible spacing in rsenv comments Allow zero or more spaces after the colon in rsenv parent file comments. Previously required exactly one space, now accepts patterns like: - # rsenv:parent.env (no space) - # rsenv: parent.env (one space) - # rsenv: parent.env (multiple spaces) - # rsenv:\tparent.env (tab) --- rsenv/src/builder.rs | 2 +- rsenv/src/lib.rs | 2 +- rsenv/tests/test_lib.rs | 57 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/rsenv/src/builder.rs b/rsenv/src/builder.rs index dea509f..f034d80 100644 --- a/rsenv/src/builder.rs +++ b/rsenv/src/builder.rs @@ -28,7 +28,7 @@ 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(), } } diff --git a/rsenv/src/lib.rs b/rsenv/src/lib.rs index ce19a66..79a75af 100644 --- a/rsenv/src/lib.rs +++ b/rsenv/src/lib.rs @@ -80,7 +80,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) { 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(()) +} From f3004dd12e3578c412b911a5f235f0bfdc6676b0 Mon Sep 17 00:00:00 2001 From: sysid Date: Wed, 2 Jul 2025 21:50:07 +0200 Subject: [PATCH 2/3] fix: include standalone files as leaves in tree commands Previously, the leaves command would return empty results for directories containing only standalone environment files (files without rsenv comments). Now standalone files are correctly identified as root nodes and returned as leaves. Changes: - Track all .env files during directory scanning, not just those with parent relationships - Include standalone files (files not part of any parent-child relationship) as root nodes - Update TreeBuilder to create single-node trees for standalone files - Add test coverage for mixed standalone and hierarchical file scenarios --- rsenv/src/builder.rs | 36 +++++++++++++++++++++++++----- rsenv/tests/test_edit.rs | 17 +++++++++------ rsenv/tests/test_tree.rs | 47 ++++++++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/rsenv/src/builder.rs b/rsenv/src/builder.rs index f034d80..df873ac 100644 --- a/rsenv/src/builder.rs +++ b/rsenv/src/builder.rs @@ -15,6 +15,7 @@ pub struct TreeBuilder { relationship_cache: HashMap>, visited_paths: HashSet, parent_regex: Regex, + all_files: HashSet, } impl Default for TreeBuilder { @@ -29,6 +30,7 @@ impl TreeBuilder { relationship_cache: HashMap::new(), visited_paths: HashSet::new(), parent_regex: Regex::new(r"# rsenv:\s*(.+)").unwrap(), + all_files: HashSet::new(), } } @@ -68,7 +70,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())?; } } @@ -103,11 +109,29 @@ impl TreeBuilder { #[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/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_tree.rs b/rsenv/tests/test_tree.rs index 814df31..cf38fc5 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,31 @@ 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(()) +} From 10907aa33927793a72d53afc40e6cf7a784a4a05 Mon Sep 17 00:00:00 2001 From: sysid Date: Wed, 2 Jul 2025 22:05:33 +0200 Subject: [PATCH 3/3] update documenation and readme --- README.md | 283 ++++++++++++++++++++++++++++----------- rsenv/src/arena.rs | 17 +++ rsenv/src/builder.rs | 16 +++ rsenv/src/edit.rs | 10 +- rsenv/src/lib.rs | 41 ++++-- rsenv/tests/test_tree.rs | 1 - 6 files changed, 276 insertions(+), 92 deletions(-) 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 df873ac..a997ffb 100644 --- a/rsenv/src/builder.rs +++ b/rsenv/src/builder.rs @@ -11,10 +11,19 @@ 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, } @@ -107,6 +116,13 @@ 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 { let mut root_nodes = Vec::new(); 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 79a75af..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); } @@ -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_tree.rs b/rsenv/tests/test_tree.rs index cf38fc5..86e7a59 100644 --- a/rsenv/tests/test_tree.rs +++ b/rsenv/tests/test_tree.rs @@ -298,7 +298,6 @@ 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<()> {