rsenv/
lib.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fs::{symlink_metadata, File};
4use std::io::{BufRead, BufReader};
5use std::path::{Path, PathBuf};
6
7use crate::errors::{TreeError, TreeResult};
8use crate::util::path::{ensure_file_exists, PathExt};
9use regex::Regex;
10use tracing::{debug, instrument};
11use walkdir::WalkDir;
12
13pub mod arena;
14pub mod builder;
15pub mod cli;
16pub mod edit;
17pub mod envrc;
18pub mod errors;
19pub mod tree_traits;
20pub mod util;
21
22/// Expands environment variables in a path string using shell-style syntax.
23///
24/// Supports both `$VAR` and `${VAR}` formats. Variables that don't exist in the
25/// environment are left unexpanded (no replacement occurs).
26///
27/// # Examples
28///
29/// ```
30/// use rsenv::expand_env_vars;
31/// std::env::set_var("HOME", "/users/test");
32///
33/// assert_eq!(expand_env_vars("$HOME/config"), "/users/test/config");
34/// assert_eq!(expand_env_vars("${HOME}/config"), "/users/test/config");
35/// assert_eq!(expand_env_vars("$NONEXISTENT/path"), "$NONEXISTENT/path");
36/// ```
37pub fn expand_env_vars(path: &str) -> String {
38    let mut result = path.to_string();
39
40    // Pattern captures both $VAR (group 1) and ${VAR} (group 2) syntax
41    let env_var_pattern = Regex::new(r"\$(\w+)|\$\{(\w+)\}").unwrap();
42
43    // Collect matches first to avoid borrow checker conflicts during replacement
44    let matches: Vec<_> = env_var_pattern.captures_iter(path).collect();
45
46    for cap in matches {
47        // Extract variable name from whichever capture group matched
48        let var_name = cap.get(1).or_else(|| cap.get(2)).unwrap().as_str();
49        let var_placeholder = if cap.get(1).is_some() {
50            format!("${}", var_name)
51        } else {
52            format!("${{{}}}", var_name)
53        };
54
55        // Only replace if the environment variable exists
56        if let Ok(var_value) = std::env::var(var_name) {
57            result = result.replace(&var_placeholder, &var_value);
58        }
59    }
60
61    result
62}
63
64#[instrument(level = "trace")]
65pub fn get_files(file_path: &Path) -> TreeResult<Vec<PathBuf>> {
66    ensure_file_exists(file_path)?;
67    let (_, files, _) = build_env(file_path)?;
68    Ok(files)
69}
70
71#[instrument(level = "trace")]
72pub fn print_files(file_path: &Path) -> TreeResult<()> {
73    let files = get_files(file_path)?;
74    for f in files {
75        println!("{}", f.display());
76    }
77    Ok(())
78}
79
80#[instrument(level = "trace")]
81pub fn build_env_vars(file_path: &Path) -> TreeResult<String> {
82    ensure_file_exists(file_path)?;
83
84    let mut env_vars = String::new();
85    let (variables, _, _) = build_env(file_path)?;
86
87    for (k, v) in variables {
88        env_vars.push_str(&format!("export {}={}\n", k, v));
89    }
90
91    Ok(env_vars)
92}
93
94#[instrument(level = "trace")]
95pub fn is_dag(dir_path: &Path) -> TreeResult<bool> {
96    let re = Regex::new(r"# rsenv:\s*(.+)").map_err(|e| TreeError::InternalError(e.to_string()))?;
97
98    // Walk through each file in the directory
99    for entry in WalkDir::new(dir_path) {
100        let entry = entry.map_err(|e| TreeError::PathResolution {
101            path: dir_path.to_path_buf(),
102            reason: e.to_string(),
103        })?;
104
105        if entry.file_type().is_file() {
106            let file = File::open(entry.path()).map_err(TreeError::FileReadError)?;
107            let reader = BufReader::new(file);
108
109            for line in reader.lines() {
110                let line = line.map_err(TreeError::FileReadError)?;
111                if let Some(caps) = re.captures(&line) {
112                    let parent_references: Vec<&str> = caps[1].split_whitespace().collect();
113                    if parent_references.len() > 1 {
114                        return Ok(true);
115                    }
116                }
117            }
118        }
119    }
120    Ok(false)
121}
122
123/// Builds a complete environment variable map by traversing the hierarchy starting from a file.
124///
125/// Implements a breadth-first traversal algorithm that processes all parent files
126/// referenced by `# rsenv:` comments. Variable precedence follows these rules:
127/// - Child variables override parent variables
128/// - When multiple parents exist, rightmost sibling wins
129/// - First encountered value wins during traversal (enables child override)
130///
131/// Returns a tuple containing:
132/// - Variable map with resolved values
133/// - List of all processed files in traversal order  
134/// - Boolean indicating if the structure contains multiple parents (DAG detection)
135#[instrument(level = "debug")]
136pub fn build_env(file_path: &Path) -> TreeResult<(BTreeMap<String, String>, Vec<PathBuf>, bool)> {
137    warn_if_symlink(file_path)?;
138    let file_path = file_path.to_canonical()?;
139    ensure_file_exists(&file_path)?;
140    debug!("Current file_path: {:?}", file_path);
141
142    let mut variables: BTreeMap<String, String> = BTreeMap::new();
143    let mut files_read: Vec<PathBuf> = Vec::new();
144    let mut is_dag = false;
145
146    let mut to_read_files: Vec<PathBuf> = vec![file_path];
147
148    while let Some(current_file) = to_read_files.pop() {
149        ensure_file_exists(&current_file)?;
150        if files_read.contains(&current_file) {
151            continue;
152        }
153
154        files_read.push(current_file.clone());
155
156        let (vars, parents) = extract_env(&current_file)?;
157        is_dag = is_dag || parents.len() > 1;
158
159        debug!(
160            "vars: {:?}, parents: {:?}, is_dag: {:?}",
161            vars, parents, is_dag
162        );
163
164        for (k, v) in vars {
165            variables.entry(k).or_insert(v); // first entry wins
166        }
167
168        for parent in parents {
169            to_read_files.push(parent);
170        }
171    }
172
173    Ok((variables, files_read, is_dag))
174}
175
176/// Extracts environment variables and the parent path from a specified file.
177///
178/// This function reads the given `file_path` to:
179///
180/// 1. Identify and extract environment variables specified using the `export` keyword.
181/// 2. Identify any parent environment file via the special `# rsenv:` comment.
182///    parent's path can be relative to the child's path.
183///
184/// The current working directory is temporarily changed to the directory of the `file_path`
185/// during the extraction process to construct correct parent paths. It is restored
186/// afterward.
187///
188/// # Arguments
189///
190/// * `file_path` - A string slice representing the path to the .env file. The function
191///                will attempt to canonicalize this path.
192///
193/// # Returns
194///
195/// A `Result` containing:
196///
197/// * A tuple with:
198///     - A `BTreeMap` with the key as the variable name and the value as its corresponding value.
199///     - An `Option` containing a `Utf8PathBuf` pointing to the parent env file, if specified.
200/// * An error if there's any problem reading the file, extracting the variables, or if the
201///   path is invalid.
202///
203/// # Errors
204///
205/// This function will return an error in the following situations:
206///
207/// * The provided `file_path` is invalid.
208/// * There's an issue reading or processing the env file.
209/// * The parent path specified in `# rsenv:` is invalid or not specified properly.
210#[instrument(level = "debug")]
211pub fn extract_env(file_path: &Path) -> TreeResult<(BTreeMap<String, String>, Vec<PathBuf>)> {
212    warn_if_symlink(file_path)?;
213    let file_path = file_path.to_canonical()?;
214    debug!("Current file_path: {:?}", file_path);
215
216    // Save the original current directory, to restore it later
217    let original_dir = env::current_dir()
218        .map_err(|e| TreeError::InternalError(format!("Failed to get current dir: {}", e)))?;
219
220    // Change the current directory in order to construct correct parent path
221    let parent_dir = file_path
222        .parent()
223        .ok_or_else(|| TreeError::InvalidParent(file_path.clone()))?;
224    env::set_current_dir(parent_dir)
225        .map_err(|e| TreeError::InternalError(format!("Failed to change dir: {}", e)))?;
226
227    debug!(
228        "Current directory: {:?}",
229        env::current_dir().unwrap_or_default()
230    );
231
232    let file = File::open(&file_path).map_err(TreeError::FileReadError)?;
233    let reader = BufReader::new(file);
234
235    let mut variables: BTreeMap<String, String> = BTreeMap::new();
236    let mut parent_paths: Vec<PathBuf> = Vec::new();
237
238    for line in reader.lines() {
239        let line = line.map_err(TreeError::FileReadError)?;
240
241        // Check for the rsenv comment
242        if line.starts_with("# rsenv:") {
243            let parents: Vec<&str> = line
244                .trim_start_matches("# rsenv:")
245                .split_whitespace()
246                .collect();
247            for parent in parents {
248                if !parent.is_empty() {
249                    // Expand environment variables in the path
250                    let expanded_path = expand_env_vars(parent);
251                    let parent_path = PathBuf::from(expanded_path)
252                        .to_canonical()
253                        .map_err(|_| TreeError::InvalidParent(PathBuf::from(parent)))?;
254                    parent_paths.push(parent_path);
255                }
256            }
257            debug!("parent_paths: {:?}", parent_paths);
258        }
259        // Check for the export prefix
260        else if line.starts_with("export ") {
261            let parts: Vec<&str> = line.split('=').collect();
262            if parts.len() > 1 {
263                let var_name: Vec<&str> = parts[0].split_whitespace().collect();
264                if var_name.len() > 1 {
265                    variables.insert(var_name[1].to_string(), parts[1].to_string());
266                }
267            }
268        }
269    }
270
271    // After executing your code, restore the original current directory
272    env::set_current_dir(original_dir)
273        .map_err(|e| TreeError::InternalError(format!("Failed to restore dir: {}", e)))?;
274
275    Ok((variables, parent_paths))
276}
277
278#[instrument(level = "trace")]
279fn warn_if_symlink(file_path: &Path) -> TreeResult<()> {
280    let metadata = symlink_metadata(file_path).map_err(TreeError::FileReadError)?;
281    if metadata.file_type().is_symlink() {
282        eprintln!(
283            "Warning: The file {} is a symbolic link.",
284            file_path.display()
285        );
286    }
287    Ok(())
288}
289
290/// Links a parent file to a child file by adding a special comment to the child file.
291/// The comment contains the relative path from the child to the parent.
292/// If the child file already has a parent, the function will replace the existing parent.
293/// If the child file has multiple parents, the function will return an error.
294#[instrument(level = "debug")]
295pub fn link(parent: &Path, child: &Path) -> TreeResult<()> {
296    let parent = parent.to_canonical()?;
297    let child = child.to_canonical()?;
298    debug!("parent: {:?} <- child: {:?}", parent, child);
299
300    let mut child_contents = std::fs::read_to_string(&child).map_err(TreeError::FileReadError)?;
301    let mut lines: Vec<_> = child_contents.lines().map(|s| s.to_string()).collect();
302
303    // Calculate the relative path from child to parent
304    let relative_path =
305        pathdiff::diff_paths(&parent, child.parent().unwrap()).ok_or_else(|| {
306            TreeError::PathResolution {
307                path: parent.clone(),
308                reason: "Failed to compute relative path".to_string(),
309            }
310        })?;
311
312    // Find and count the lines that start with "# rsenv:"
313    let mut rsenv_lines = 0;
314    let mut rsenv_index = None;
315    for (i, line) in lines.iter().enumerate() {
316        if line.starts_with("# rsenv:") {
317            rsenv_lines += 1;
318            rsenv_index = Some(i);
319        }
320    }
321
322    // Based on the count, perform the necessary operations
323    match rsenv_lines {
324        0 => {
325            // No "# rsenv:" line found, so we add it
326            lines.insert(0, format!("# rsenv: {}", relative_path.display()));
327        }
328        1 => {
329            // One "# rsenv:" line found, so we replace it
330            if let Some(index) = rsenv_index {
331                lines[index] = format!("# rsenv: {}", relative_path.display());
332            }
333        }
334        _ => {
335            // More than one "# rsenv:" line found, we throw an error
336            return Err(TreeError::MultipleParents(child));
337        }
338    }
339
340    // Write the modified content back to the child file
341    child_contents = lines.join("\n");
342    std::fs::write(&child, child_contents).map_err(TreeError::FileReadError)?;
343
344    Ok(())
345}
346
347#[instrument(level = "debug")]
348pub fn unlink(child: &Path) -> TreeResult<()> {
349    let child = child.to_canonical()?;
350    debug!("child: {:?}", child);
351
352    let mut child_contents = std::fs::read_to_string(&child).map_err(TreeError::FileReadError)?;
353    let mut lines: Vec<_> = child_contents.lines().map(|s| s.to_string()).collect();
354
355    // Find and count the lines that start with "# rsenv:"
356    let mut rsenv_lines = 0;
357    let mut rsenv_index = None;
358    for (i, line) in lines.iter().enumerate() {
359        if line.starts_with("# rsenv:") {
360            rsenv_lines += 1;
361            rsenv_index = Some(i);
362        }
363    }
364
365    match rsenv_lines {
366        0 => {}
367        1 => {
368            // One "# rsenv:" line found, so we replace it
369            if let Some(index) = rsenv_index {
370                lines[index] = "# rsenv:".to_string();
371            }
372        }
373        _ => {
374            return Err(TreeError::MultipleParents(child));
375        }
376    }
377    // Write the modified content back to the child file
378    child_contents = lines.join("\n");
379    std::fs::write(&child, child_contents).map_err(TreeError::FileReadError)?;
380
381    Ok(())
382}
383
384/// links a list of env files together and build the hierarchical environment variables tree
385#[instrument(level = "debug")]
386pub fn link_all(nodes: &[PathBuf]) {
387    debug!("nodes: {:?}", nodes);
388    let mut parent = None;
389    for node in nodes {
390        if let Some(parent_path) = parent {
391            link(parent_path, node).expect("Failed to link");
392        } else {
393            unlink(node).unwrap();
394        }
395        parent = Some(node);
396    }
397}