Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
287 lines
8.5 KiB
Rust
287 lines
8.5 KiB
Rust
//! Parse .envrc files and extract environment variables.
|
|
//!
|
|
//! This module provides a way to load environment variables from `.envrc` files.
|
|
//! It uses a two-tier approach:
|
|
//!
|
|
//! 1. **Try `direnv export json`** - If direnv is installed, use it for full compatibility
|
|
//! 2. **Fallback to bash** - Run .envrc in a bash subshell with direnv stubs
|
|
//!
|
|
//! This approach handles:
|
|
//! - Variable expansion ($HOME, ${VAR:-default})
|
|
//! - Command substitution ($(git rev-parse ...))
|
|
//! - Conditional logic (if/then/else)
|
|
//! - direnv helper functions (source_up_if_exists, PATH_add, etc.)
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
/// Stub implementations of common direnv helper functions.
|
|
/// These are prepended to the .envrc before execution when direnv is not available.
|
|
const DIRENV_STUBS: &str = r#"
|
|
# Stub direnv helper functions
|
|
source_up_if_exists() { :; }
|
|
source_up() { :; }
|
|
source_env_if_exists() {
|
|
if [ -f "$1" ]; then
|
|
. "$1"
|
|
fi
|
|
}
|
|
source_env() {
|
|
if [ -f "$1" ]; then
|
|
. "$1"
|
|
fi
|
|
}
|
|
PATH_add() {
|
|
export PATH="$PWD/$1:$PATH"
|
|
}
|
|
path_add() {
|
|
PATH_add "$@"
|
|
}
|
|
layout() { :; }
|
|
use() { :; }
|
|
watch_file() { :; }
|
|
"#;
|
|
|
|
/// Load environment variables from .envrc file in the given directory.
|
|
///
|
|
/// Returns a HashMap of environment variables that were set/modified by the .envrc.
|
|
/// Returns None if no .envrc exists or if parsing fails.
|
|
pub fn load_envrc(dir: &Path) -> Option<HashMap<String, String>> {
|
|
let envrc_path = dir.join(".envrc");
|
|
if !envrc_path.exists() {
|
|
tracing::debug!(?dir, ".envrc not found");
|
|
return None;
|
|
}
|
|
|
|
// Try direnv first (most reliable if installed)
|
|
if let Some(env) = try_direnv_export(dir) {
|
|
return Some(env);
|
|
}
|
|
|
|
// Fall back to bash subshell approach
|
|
load_envrc_via_bash(dir)
|
|
}
|
|
|
|
/// Try to use `direnv export json` to load environment variables.
|
|
/// Returns None if direnv is not installed or fails.
|
|
fn try_direnv_export(dir: &Path) -> Option<HashMap<String, String>> {
|
|
let mut cmd = Command::new("direnv");
|
|
cmd.args(["export", "json"])
|
|
.current_dir(dir)
|
|
.stdin(std::process::Stdio::null());
|
|
kigi_tools::util::detach_std_command(&mut cmd);
|
|
let output = cmd.output().ok()?;
|
|
|
|
if !output.status.success() {
|
|
// direnv not allowed, or other error
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if !stderr.contains("not allowed") {
|
|
tracing::debug!(?dir, %stderr, "direnv export failed");
|
|
}
|
|
return None;
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
if stdout.trim().is_empty() {
|
|
// No changes from direnv
|
|
return None;
|
|
}
|
|
|
|
// Parse JSON output: {"VAR": "value", ...}
|
|
// Note: direnv also outputs null for vars to unset, but we ignore those
|
|
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&stdout) {
|
|
Ok(json) => {
|
|
let env: HashMap<String, String> = json
|
|
.into_iter()
|
|
.filter_map(|(k, v)| {
|
|
if let serde_json::Value::String(s) = v {
|
|
Some((k, s))
|
|
} else {
|
|
// Skip null values (unset)
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if env.is_empty() {
|
|
None
|
|
} else {
|
|
tracing::info!(?dir, count = env.len(), "Loaded environment via direnv");
|
|
Some(env)
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(?dir, ?e, "Failed to parse direnv JSON output");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load environment by running .envrc in a bash subshell.
|
|
/// This is the fallback when direnv is not installed.
|
|
fn load_envrc_via_bash(dir: &Path) -> Option<HashMap<String, String>> {
|
|
let envrc_path = dir.join(".envrc");
|
|
|
|
// Build a script that:
|
|
// 1. Includes direnv stubs
|
|
// 2. Sources the .envrc
|
|
// 3. Outputs all env vars as KEY=VALUE pairs (null-separated for safety)
|
|
let script = format!(
|
|
r#"
|
|
set -e
|
|
cd "{dir}"
|
|
{stubs}
|
|
. "{envrc}"
|
|
# Output all environment variables, null-separated
|
|
env -0
|
|
"#,
|
|
dir = dir.display(),
|
|
stubs = DIRENV_STUBS,
|
|
envrc = envrc_path.display(),
|
|
);
|
|
|
|
// Capture baseline environment (before running .envrc)
|
|
let baseline: HashMap<String, String> = std::env::vars().collect();
|
|
|
|
// Run the script and capture output
|
|
let mut bash_cmd = Command::new("/bin/bash");
|
|
bash_cmd
|
|
.arg("-c")
|
|
.arg(&script)
|
|
.current_dir(dir)
|
|
.stdin(std::process::Stdio::null());
|
|
kigi_tools::util::detach_std_command(&mut bash_cmd);
|
|
let output = bash_cmd.output();
|
|
|
|
let output = match output {
|
|
Ok(o) if o.status.success() => o,
|
|
Ok(_) => {
|
|
tracing::warn!(?envrc_path, "Failed to execute .envrc via bash");
|
|
return None;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(?envrc_path, ?e, "Failed to run bash for .envrc");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Parse the null-separated KEY=VALUE pairs
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let mut result: HashMap<String, String> = HashMap::new();
|
|
|
|
for entry in stdout.split('\0') {
|
|
if entry.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = entry.split_once('=') {
|
|
// Skip internal/noise variables
|
|
let ignored_keys = ["_", "SHLVL", "PWD", "OLDPWD"];
|
|
if ignored_keys.contains(&key) {
|
|
continue;
|
|
}
|
|
// Only include vars that are new or changed from baseline
|
|
match baseline.get(key) {
|
|
Some(baseline_value) if baseline_value == value => {
|
|
// Unchanged, skip
|
|
}
|
|
_ => {
|
|
// New or changed
|
|
result.insert(key.to_string(), value.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.is_empty() {
|
|
tracing::debug!(?envrc_path, "No environment changes from .envrc");
|
|
None
|
|
} else {
|
|
tracing::info!(
|
|
?envrc_path,
|
|
count = result.len(),
|
|
"Loaded environment from .envrc via bash"
|
|
);
|
|
Some(result)
|
|
}
|
|
}
|
|
|
|
/// Load .envrc and return the environment, or empty HashMap on failure.
|
|
pub fn load_envrc_or_empty(dir: &Path) -> HashMap<String, String> {
|
|
load_envrc(dir).unwrap_or_default()
|
|
}
|
|
|
|
/// [`load_envrc_or_empty`] gated on folder-trust: loads the repo-local `.envrc`
|
|
/// (executed in a bash subshell) only when `trusted`, else returns an empty map.
|
|
/// The shell call sites pass the `project_scope_allowed` verdict so the "run a
|
|
/// cloned repo's `.envrc` only when the folder is trusted" rule lives in ONE
|
|
/// place (mirrors `permission::claude_settings::load_claude_env_with_project`).
|
|
pub fn load_envrc_or_empty_when_trusted(dir: &Path, trusted: bool) -> HashMap<String, String> {
|
|
if trusted {
|
|
load_envrc_or_empty(dir)
|
|
} else {
|
|
HashMap::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_simple_export() {
|
|
let dir = TempDir::new().unwrap();
|
|
fs::write(dir.path().join(".envrc"), "export FOO=bar\n").unwrap();
|
|
|
|
let env = load_envrc(dir.path()).unwrap();
|
|
assert_eq!(env.get("FOO"), Some(&"bar".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_variable_expansion() {
|
|
let dir = TempDir::new().unwrap();
|
|
fs::write(dir.path().join(".envrc"), "export MY_DIR=$PWD/subdir\n").unwrap();
|
|
|
|
let env = load_envrc(dir.path()).unwrap();
|
|
let expected = format!("{}/subdir", dir.path().display());
|
|
assert_eq!(env.get("MY_DIR"), Some(&expected));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_envrc() {
|
|
let dir = TempDir::new().unwrap();
|
|
assert!(load_envrc(dir.path()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_add() {
|
|
let dir = TempDir::new().unwrap();
|
|
fs::write(dir.path().join(".envrc"), "PATH_add bin\n").unwrap();
|
|
|
|
let env = load_envrc(dir.path()).unwrap();
|
|
let path = env.get("PATH").unwrap();
|
|
assert!(path.contains(&format!("{}/bin", dir.path().display())));
|
|
}
|
|
|
|
#[test]
|
|
fn test_conditional() {
|
|
let dir = TempDir::new().unwrap();
|
|
fs::write(
|
|
dir.path().join(".envrc"),
|
|
r#"
|
|
if [ -d "$PWD" ]; then
|
|
export EXISTS=yes
|
|
else
|
|
export EXISTS=no
|
|
fi
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let env = load_envrc(dir.path()).unwrap();
|
|
assert_eq!(env.get("EXISTS"), Some(&"yes".to_string()));
|
|
}
|
|
}
|