diff --git a/README.md b/README.md index b5b7c9e..60d998e 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,23 @@ programs.cade = { verbosity = "normal"; # null, quiet, normal, vars, trace longRunningWarningMs = 5000; # null, or a positive integer shellGcRootTtlSeconds = 2592000; # null, or a positive integer - direnvCompat = false; # true installs the direnv shim for tools + direnvCompat = "envrc"; # none, shim, envrc (default), full }; ``` -`direnvCompat` installs a cade-backed `direnv` on `PATH` for editors and other -direnv-aware tools (see [direnv compatibility](#direnv-compatibility)). it is -not the shell integration path; interactive shells should use `cade hook -`. the shim collides with a real direnv in `environment.systemPackages`, -so install only one. +`direnvCompat` selects which direnv compatibility cade enables (see [direnv +compatibility](#direnv-compatibility)): + +- `none`: neither the implicit `.envrc` loader nor the export shim +- `shim`: install the cade-backed `direnv` shim; the implicit `.envrc` loader + stays off +- `envrc` (default): cade implicitly loads a bare `.envrc`; no shim +- `full`: both the implicit `.envrc` loader and the shim + +`shim` and `full` install a cade-backed `direnv` on `PATH` for editors and other +direnv-aware tools. that is not the shell integration path; interactive shells +should use `cade hook `. the shim collides with a real direnv in +`environment.systemPackages`, so install only one. setting `verbosity`, `longRunningWarningMs`, or `shellGcRootTtlSeconds` makes the module generate a TOML config file and pass it to cade with `--config`. @@ -219,16 +227,22 @@ disinherit load load flake load flake devShells.default +# a directed flake elsewhere as `path[#output]`. relative paths resolve against +# this .cade's dir; `..`, absolute paths, and a leading `~/` are all allowed +load flake ../svc +load flake ./sub#dev -# load from shell.nix +# load from shell.nix (path resolves against this .cade's dir; ../, abs, ~/ ok) load shell load shell custom-shell.nix +load shell ../tools/shell.nix -# load from .env file +# load from .env file (path resolves against this .cade's dir) load env load env .env.development +load env ../shared/.env -# load a direnv .envrc (declarative subset only) +# load a direnv .envrc (declarative subset only; path resolves against this dir) load envrc load envrc .envrc.local @@ -278,9 +292,26 @@ the direnv stdlib that maps cleanly onto cade's own loaders: an `.envrc` is picked up two ways: - **automatically**: a directory with an `.envrc` but no `.cade` is treated as - if it contained `load envrc` + if it contained `load envrc`. this implicit loader is opt-in via the `direnv` + setting (see below); it is on by default but a directory with only an `.envrc` + is invisible to cade when it is off - **explicitly**: `load envrc [file]` in a `.cade` composes it as one layer - alongside other directives + alongside other directives. the explicit directive always works, regardless of + the `direnv` setting + +both pieces are governed by one config setting, `direnv`, in cade's +`config.toml`: + +```toml +direnv = "envrc" # none | shim | envrc | full +``` + +- `none`: neither the implicit `.envrc` loader nor the export shim +- `shim`: the export shim is active; the implicit `.envrc` loader is off +- `envrc` (default): the implicit `.envrc` loader is on; the shim is off +- `full`: both + +the `CADE_DIRENV` environment variable overrides the config value the same way. anything cade can't faithfully reproduce (shell expansion, conditionals, `layout`, `source_up`, functions, unknown flags) is skipped with a warning. @@ -304,13 +335,18 @@ keeps working, but they do not activate cade. other direnv commands fail as unsupported. the shim maps JSON export to cade's hidden direnv-compatible JSON endpoint; `json` is an output format, not a shell accepted by `--shell`. +the shim is opt-in via the `direnv` setting: it is active only when `direnv` is +`shim` or `full`. when the shim is off, `cade export json` returns an empty +delta (`{}`), so direnv-aware tools keep working while cade stays inactive for +them. + the JSON exporter carries minimal `DIRENV_DIFF` state containing only previous values for variables cade changed. that state lets repeated exports, directory leave, and re-enter restore the editor environment without growing `PATH` or leaking a full ambient environment snapshot. -enable it in the module with `programs.cade.direnvCompat = true;`, or build it -from the flake: +enable it in the module with `programs.cade.direnvCompat = "shim";` (or `full`), +or build it from the flake: ```sh nix build .#direnv-compat # produces bin/direnv diff --git a/nix/module.nix b/nix/module.nix index 58c97d9..bdf95ae 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -12,10 +12,22 @@ self: let cfg = config.programs.cade; exe = lib.getExe cfg.package; + # direnvCompat accepts the legacy boolean (pre-enum releases) for back-compat: + # `true` mapped to installing the shim, `false` to doing nothing. Normalize to + # the string form everything else consumes, and nudge boolean users onward. + direnvCompatString = + if builtins.isBool cfg.direnvCompat then + (if cfg.direnvCompat then "shim" else "none") + else + cfg.direnvCompat; configValues = lib.filterAttrs (_: v: v != null) { inherit (cfg) verbosity; long_running_warning_ms = cfg.longRunningWarningMs; shell_gc_root_ttl_seconds = cfg.shellGcRootTtlSeconds; + # configFile owns the whole config, so don't inject direnv when it's set; + # otherwise "envrc" is the cade default and needs no explicit write. + direnv = + if cfg.configFile != null || direnvCompatString == "envrc" then null else direnvCompatString; }; tomlFormat = pkgs.formats.toml { }; generatedConfigFile = tomlFormat.generate "cade-config.toml" configValues; @@ -99,14 +111,46 @@ in }; direnvCompat = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; + # the enum is hand-mirrored by the Rust DirenvMode enum (src/config.rs); + # keep the two in sync. `bool` is accepted for back-compat with the + # pre-enum option (legacy `true` -> "shim", `false` -> "none"); it is + # deprecated and warns. Drop the bool arm after a release. + type = + with lib.types; + either bool (enum [ + "none" + "shim" + "envrc" + "full" + ]); + # stay out of the way when real direnv is hooked into the shell, otherwise + # read .envrc ourselves. programs.direnv.enable is the right signal: it + # means direnv is hooked into the shell, the thing that conflicts. we don't + # read systemPackages here because this module conditionally adds its own + # shim to it based on direnvCompat, so the default would recurse on the + # option it's defining (nix infinite-recursion). + default = if (config.programs.direnv.enable or false) then "none" else "envrc"; + example = "full"; description = '' - Install a cade-backed `direnv` executable on PATH for editor and tool - compatibility. This is not the shell integration path; interactive + Which direnv compatibility cade enables, written to its config as + `direnv`. Defaults to `none` when `programs.direnv.enable` is set + (let real direnv own `.envrc`), otherwise `envrc`. + + - `none`: neither the implicit `.envrc` loader nor the export shim. + - `shim`: install the cade-backed `direnv` shim; the implicit `.envrc` + loader stays off. + - `envrc`: cade implicitly loads a bare `.envrc`; no shim. + - `full`: both the implicit `.envrc` loader and the shim. + + The shim is the cade-backed `direnv` executable on PATH for editor and + tool compatibility. This is not the shell integration path; interactive shells should use cade's native hook snippets. The shim collides with a real direnv in environment.systemPackages, so install only one. + + Deprecated: a boolean is still accepted from the pre-enum option + (`true` behaves as `"shim"`, `false` as `"none"`) but warns. Use the + string form. Leaving the option unset keeps the string default above + rather than any boolean. ''; }; @@ -146,9 +190,39 @@ in assertion = cfg.configFile == null || configValues == { }; message = "programs.cade.configFile cannot be combined with generated config options."; } + { + # configFile owns the whole runtime config, so the generated `direnv` + # value is suppressed (see configValues above). But the shim package is + # installed independently from direnvCompat, so "shim"/"full" would + # still drop the shim on PATH while silently not affecting runtime mode + # (split-brain). Reject the ambiguous combination; configFile users + # should set `direnv` inside their TOML and leave direnvCompat unset, or + # set it explicitly to the default-equivalent string for clarity. + assertion = + cfg.configFile == null + || builtins.elem direnvCompatString [ + "none" + "envrc" + ]; + message = '' + programs.cade.direnvCompat = "${direnvCompatString}" is ignored at + runtime when programs.cade.configFile is set (configFile owns the + config), yet it still installs the direnv shim. Put the `direnv` key + in your configFile TOML instead, and leave direnvCompat unset. + ''; + } ]; - environment.systemPackages = [ cfg.package ] ++ lib.optional cfg.direnvCompat direnvShim; + warnings = lib.optional (builtins.isBool cfg.direnvCompat) '' + programs.cade.direnvCompat is set to a boolean (${lib.boolToString cfg.direnvCompat}). + The boolean form is deprecated; it is read as "${direnvCompatString}" for now. + Set it to one of "none", "shim", "envrc", or "full" instead. + ''; + + environment.systemPackages = [ + cfg.package + ] + ++ lib.optional (direnvCompatString == "shim" || direnvCompatString == "full") direnvShim; # bash and zsh evaluate the hook; the shell flag must be enabled by the user # (programs.zsh.enable / shell installed) for the init file to be sourced. diff --git a/src/config.rs b/src/config.rs index fb4164b..1d7ab84 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,20 +6,58 @@ use std::{ sync::OnceLock, }; +// These variants are hand-mirrored in the Nix module's `direnvCompat` enum +// (`nix/module.nix`, the `enum [ "none" "shim" "envrc" "full" ]`). Keep the two +// in sync: adding or renaming a mode here means updating that option too. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DirenvMode { + None, + Shim, + #[default] + Envrc, + Full, +} + +impl DirenvMode { + pub fn loads_envrc(self) -> bool { + matches!(self, DirenvMode::Envrc | DirenvMode::Full) + } + + pub fn runs_shim(self) -> bool { + matches!(self, DirenvMode::Shim | DirenvMode::Full) + } +} + +impl std::str::FromStr for DirenvMode { + type Err = String; + + fn from_str(raw: &str) -> Result { + match raw.trim().to_lowercase().as_str() { + "none" => Ok(DirenvMode::None), + "shim" => Ok(DirenvMode::Shim), + "envrc" => Ok(DirenvMode::Envrc), + "full" => Ok(DirenvMode::Full), + _ => Err(format!("unknown direnv mode: {raw}")), + } + } +} + #[derive(Debug, Clone, Default)] pub struct Config { pub path: Option, pub verbosity: Option, pub long_running_warning_ms: Option, pub shell_gc_root_ttl_seconds: Option, + pub direnv: DirenvMode, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawConfig { verbosity: Option, long_running_warning_ms: Option, shell_gc_root_ttl_seconds: Option, + direnv: Option, } static CONFIG: OnceLock = OnceLock::new(); @@ -48,6 +86,19 @@ pub fn shell_gc_root_ttl_seconds() -> Option { .or_else(|| current().shell_gc_root_ttl_seconds) } +pub fn direnv_mode() -> DirenvMode { + match std::env::var("CADE_DIRENV") { + Ok(raw) => match raw.parse::() { + Ok(mode) => mode, + Err(e) => { + eprintln!("cade: ignoring CADE_DIRENV: {e}"); + current().direnv + } + }, + Err(_) => current().direnv, + } +} + fn home_config_path() -> Option { let mut path = PathBuf::from(std::env::var_os("HOME")?); path.push(".config"); @@ -125,11 +176,18 @@ impl TryFrom for Config { if matches!(raw.shell_gc_root_ttl_seconds, Some(0)) { bail!("shell_gc_root_ttl_seconds must be greater than 0"); } + let direnv = match raw.direnv { + Some(v) => v + .parse::() + .map_err(|e| anyhow::anyhow!("{e}"))?, + None => DirenvMode::default(), + }; Ok(Self { path: None, verbosity, long_running_warning_ms: raw.long_running_warning_ms, shell_gc_root_ttl_seconds: raw.shell_gc_root_ttl_seconds, + direnv, }) } } @@ -144,19 +202,20 @@ mod tests { verbosity: Some("vars".into()), long_running_warning_ms: Some(100), shell_gc_root_ttl_seconds: Some(200), + direnv: Some("full".into()), }; let cfg: Config = raw.try_into().unwrap(); assert_eq!(cfg.verbosity, Some(Verbosity::Vars)); assert_eq!(cfg.long_running_warning_ms, Some(100)); assert_eq!(cfg.shell_gc_root_ttl_seconds, Some(200)); + assert_eq!(cfg.direnv, DirenvMode::Full); } #[test] fn rejects_bad_verbosity() { let raw = RawConfig { verbosity: Some("loud".into()), - long_running_warning_ms: None, - shell_gc_root_ttl_seconds: None, + ..Default::default() }; assert!(Config::try_from(raw).is_err()); } @@ -164,9 +223,8 @@ mod tests { #[test] fn rejects_zero_warning_threshold() { let raw = RawConfig { - verbosity: None, long_running_warning_ms: Some(0), - shell_gc_root_ttl_seconds: None, + ..Default::default() }; assert!(Config::try_from(raw).is_err()); } @@ -174,10 +232,41 @@ mod tests { #[test] fn rejects_zero_shell_gc_root_ttl() { let raw = RawConfig { - verbosity: None, - long_running_warning_ms: None, shell_gc_root_ttl_seconds: Some(0), + ..Default::default() }; assert!(Config::try_from(raw).is_err()); } + + #[test] + fn parses_each_direnv_mode() { + for (text, mode) in [ + ("none", DirenvMode::None), + ("shim", DirenvMode::Shim), + ("envrc", DirenvMode::Envrc), + ("full", DirenvMode::Full), + ] { + assert_eq!(text.parse::().unwrap(), mode); + } + // omitting direnv defaults to envrc + let raw = RawConfig::default(); + assert_eq!(Config::try_from(raw).unwrap().direnv, DirenvMode::Envrc); + } + + #[test] + fn rejects_bad_direnv_mode() { + let raw = RawConfig { + direnv: Some("sometimes".into()), + ..Default::default() + }; + assert!(Config::try_from(raw).is_err()); + } + + #[test] + fn direnv_mode_predicates() { + assert!(!DirenvMode::None.loads_envrc() && !DirenvMode::None.runs_shim()); + assert!(!DirenvMode::Shim.loads_envrc() && DirenvMode::Shim.runs_shim()); + assert!(DirenvMode::Envrc.loads_envrc() && !DirenvMode::Envrc.runs_shim()); + assert!(DirenvMode::Full.loads_envrc() && DirenvMode::Full.runs_shim()); + } } diff --git a/src/core.rs b/src/core.rs index 683539d..eb940de 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1635,6 +1635,91 @@ impl CadeLayer { } } +/// what to actually load, with paths already resolved +enum LoadRun { + Flake(crate::nix_dev_env::FlakeTarget), + Shell(PathBuf), + Env(PathBuf), + Envrc(PathBuf), +} + +/// single source of truth for a `Load` directive: resolve once, then both the +/// load path and the watch path read the same target and file set +struct ResolvedLoad { + run: LoadRun, + /// profile/gc-root key for this resolved target + spec: String, + /// files whose change reloads this layer + watch: Vec, +} + +impl Loadable { + /// effective single-file arg for a non-flake directive, applying the + /// empty-arg default. the one place these defaults live. flake returns + /// `None` (its target is a dir of two files, not a single path) + fn file_arg(&self) -> Option<&str> { + match self { + Loadable::Shell(f) => Some(if f.is_empty() { "./shell.nix" } else { f }), + Loadable::Env(f) => Some(if f.is_empty() { ".env" } else { f }), + Loadable::Envrc(f) => Some(crate::envrc::envrc_arg(f)), + Loadable::Default | Loadable::Flake(_) => None, + } + } + + /// resolve this directive against `layer_dir` into one target that owns both + /// the canonical load path and the watch identity. paths resolve canonically + /// when they exist and lexically when not, so a not-yet-created (possibly + /// directed) target still watches its real files; the loaders surface a + /// genuinely missing target as an error at load time. spec and watch both + /// key off the resolved path, so the same physical file spelled differently + /// (relative, `..`, or a symlink) shares one profile/gc-root identity + fn resolve(&self, layer_dir: &Path) -> ResolvedLoad { + use crate::path_resolve::resolve_for_watch; + match self { + Loadable::Default | Loadable::Flake(_) => { + let arg = match self { + Loadable::Flake(a) => Some(a.as_str()), + _ => None, + }; + // resolve_flake_target resolves the (possibly directed) dir for + // watch, deferring a missing dir to `nix develop` at load time + let target = crate::nix_dev_env::resolve_flake_target(layer_dir, arg); + let watch = vec![target.cwd.join("flake.nix"), target.cwd.join("flake.lock")]; + ResolvedLoad { + spec: target.spec.clone(), + watch, + run: LoadRun::Flake(target), + } + } + Loadable::Shell(_) => { + let file = resolve_for_watch(layer_dir, self.file_arg().unwrap()); + ResolvedLoad { + spec: format!("shell:{}", file.display()), + watch: vec![file.clone()], + run: LoadRun::Shell(file), + } + } + Loadable::Env(_) => { + let file = resolve_for_watch(layer_dir, self.file_arg().unwrap()); + ResolvedLoad { + spec: format!("env:{}", file.display()), + watch: vec![file.clone()], + run: LoadRun::Env(file), + } + } + Loadable::Envrc(_) => { + let path = resolve_for_watch(layer_dir, self.file_arg().unwrap()); + let watch = crate::envrc::envrc_watch_files(&path); + ResolvedLoad { + spec: format!("envrc:{}", path.display()), + watch, + run: LoadRun::Envrc(path), + } + } + } + } +} + fn load_single_layer( layer_count: usize, path: &Path, @@ -1644,7 +1729,6 @@ fn load_single_layer( ) -> Result { use crate::loaders::*; use Keyword::*; - use Loadable::*; let mut layer = CadeLayer::new(layer_count, path); for (action_index, kw) in keywords.iter().enumerate() { @@ -1653,53 +1737,21 @@ fn load_single_layer( Call(argv) => call(path, argv.clone()) .context("calling process") .map(CadeAction::Environ), - Load(loadable) => match loadable { - Default => { - let profile = session.and_then(|session| { - cade.nix_profile_path(session, layer_count, action_index, path, "flake") - }); - load_flake(path, None, profile).context("loading flake") - } - Flake(output) => { - let profile = session.and_then(|session| { - cade.nix_profile_path( - session, - layer_count, - action_index, - path, - &format!("flake:{output}"), - ) - }); - load_flake(path, Some(output.clone()), profile).context("loading flake") - } - Shell(filename) => { - let profile = session.and_then(|session| { - cade.nix_profile_path( - session, - layer_count, - action_index, - path, - &format!("shell:{filename}"), - ) - }); - load_shell(path, filename.clone(), profile).context("loading shell") - } - Env(filename) => load_env(path, filename.clone()).context("loading env file"), - Envrc(filename) => { - let profile_dir = session.and_then(|session| { - cade.nix_profile_path( - session, - layer_count, - action_index, - path, - &format!("envrc:{filename}"), - ) - }); - crate::envrc::load_envrc(path, filename.clone(), profile_dir) - .context("loading .envrc") + Load(loadable) => { + let resolved = loadable.resolve(path); + let profile = session.and_then(|session| { + cade.nix_profile_path(session, layer_count, action_index, path, &resolved.spec) + }); + match resolved.run { + LoadRun::Flake(target) => load_flake(&target, profile).context("loading flake"), + LoadRun::Shell(file) => load_shell(&file, profile).context("loading shell"), + LoadRun::Env(file) => load_env(&file).context("loading env file"), + LoadRun::Envrc(p) => { + crate::envrc::load_envrc(&p, profile).context("loading .envrc") + } } + .map(CadeAction::Environ) } - .map(CadeAction::Environ), Hook(hook) => Ok(CadeAction::Hook(hook.clone())), Clear(vars) => Ok(CadeAction::Clear(vars.clone())), Concat(vars) => Ok(CadeAction::Concat(vars.clone())), @@ -1717,27 +1769,10 @@ fn watched_files_for_keywords(dir: &Path, keywords: &[Keyword]) -> Vec let mut files = vec![dir.join(".cade")]; for kw in keywords { match kw { - Keyword::Load(loadable) => match loadable { - Loadable::Default | Loadable::Flake(_) => { - files.push(dir.join("flake.nix")); - files.push(dir.join("flake.lock")); - } - Loadable::Shell(f) => { - let name = if f.is_empty() { - "shell.nix" - } else { - f.as_str() - }; - files.push(dir.join(name)); - } - Loadable::Env(f) => { - let name = if f.is_empty() { ".env" } else { f.as_str() }; - files.push(dir.join(name)); - } - Loadable::Envrc(f) => { - files.extend(crate::envrc::envrc_watch_files(dir, f.clone())); - } - }, + // same resolver the load path uses, so we never watch a different + // file than we load; the resolver tracks a not-yet-created target's + // real files lexically, so creating it trips the watcher + Keyword::Load(loadable) => files.extend(loadable.resolve(dir).watch), // explicit user-declared dependencies Keyword::Watch(ws) => files.extend(ws.iter().map(|w| dir.join(w))), _ => {} diff --git a/src/core/activation.rs b/src/core/activation.rs index a36a714..2b1e04d 100644 --- a/src/core/activation.rs +++ b/src/core/activation.rs @@ -197,6 +197,13 @@ impl Cade { owner_pid: Option, ) -> Result { let export = self.export_session(); + if !crate::config::direnv_mode().runs_shim() { + // The shim is off, so cade exports no active project env. Still route + // through the unwind path: if a prior shim/full export left a live + // DIRENV_DIFF, restore its preimage instead of stranding the old + // project's vars. With no carried diff this returns an empty no-op. + return Ok(direnv_export::inactive_delta(export.previous)); + } let Some(root) = find_cade_root(&self.cwd) else { // This mirrors direnv's unload behavior for direct callers: leaving // a project must undo the last exported diff if the caller preserved diff --git a/src/core/participants.rs b/src/core/participants.rs index d809150..5e21268 100644 --- a/src/core/participants.rs +++ b/src/core/participants.rs @@ -2,7 +2,7 @@ //! activation at a given cwd, and which one is the root. No db, no env, no //! shell output - just the filesystem layout of `.cade` and `.envrc` markers. -use crate::types::Keyword; +use crate::{config, types::Keyword}; use std::path::{Path, PathBuf}; /// what a dir contributes; a co-located `.envrc` yields to `.cade`, so at most one kind @@ -15,7 +15,9 @@ pub(super) enum DirKind { fn dir_kind(dir: &Path) -> Option { if std::fs::exists(dir.join(".cade")).unwrap_or(false) { Some(DirKind::Cade) - } else if std::fs::exists(dir.join(".envrc")).unwrap_or(false) { + } else if config::direnv_mode().loads_envrc() + && std::fs::exists(dir.join(".envrc")).unwrap_or(false) + { Some(DirKind::Envrc) } else { None diff --git a/src/envrc.rs b/src/envrc.rs index 178544b..d45e96c 100644 --- a/src/envrc.rs +++ b/src/envrc.rs @@ -6,7 +6,8 @@ //! `export`/`PATH_add`) and warns about any line it can't faithfully //! reproduce. This should cover most cases of .envrc. -use crate::loaders::{load_env, load_flake, load_shell}; +use crate::loaders::{load_env, load_shell}; +use crate::nix_dev_env::{FlakeTarget, load_flake}; use crate::{ types::EnvSet, verbosity::{self, Verbosity}, @@ -113,18 +114,20 @@ fn merge(out: &mut EnvSet, other: EnvSet) { out.nix_store_paths.extend(other.nix_store_paths); } -fn envrc_path(dir: &Path, filename: &str) -> PathBuf { - dir.join(if filename.is_empty() { +pub(crate) fn envrc_arg(filename: &str) -> &str { + if filename.is_empty() { ".envrc" } else { filename - }) + } } -/// Compose an .envrc's recognized directives into a single EnvSet -pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option) -> Result { - let path = envrc_path(dir, &filename); - let contents = std::fs::read_to_string(&path) +/// Compose an .envrc's recognized directives into a single EnvSet. `path` is the +/// already-resolved .envrc; its directives resolve against its own dir +pub fn load_envrc(path: &Path, profile_dir: Option) -> Result { + // directives inside resolve against the .envrc's own dir (direnv semantics) + let dir = path.parent().unwrap_or(path); + let contents = std::fs::read_to_string(path) .with_context(|| format!("reading .envrc at {}", path.display()))?; let mut out = EnvSet::new(); @@ -136,27 +139,26 @@ pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option) -> let profile = profile_dir .as_ref() .map(|base| base.join(format!("{idx}-flake"))); - merge( - &mut out, - load_flake(dir, output, profile).context("use flake")?, - ) + // build the bare-output installable directly: a parsed `.#dev` + // must not be re-encoded into a `.#dev` string and fed back + // through the path classifier, which would misread it as a + // directed path and orphan previously-rooted profiles + let target = FlakeTarget::bare_output(dir, output.as_deref()); + merge(&mut out, load_flake(&target, profile).context("use flake")?) } Directive::UseNix(file) => { let profile = profile_dir .as_ref() .map(|base| base.join(format!("{idx}-nix"))); - merge(&mut out, load_shell(dir, file, profile).context("use nix")?) + let shell = dir.join(if file.is_empty() { "shell.nix" } else { &file }); + merge(&mut out, load_shell(&shell, profile).context("use nix")?) } Directive::Dotenv { file, if_exists } => { - let p = if file.is_empty() { - dir.join(".env") - } else { - dir.join(&file) - }; + let p = dir.join(if file.is_empty() { ".env" } else { &file }); if if_exists && !p.exists() { continue; } - merge(&mut out, load_env(dir, file).context("dotenv")?); + merge(&mut out, load_env(&p).context("dotenv")?); } Directive::Export(key, value) => { let parts: Vec = value.split(':').map(str::to_string).collect(); @@ -197,11 +199,13 @@ pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option) -> Ok(out) } -/// Files an .envrc layer depends on -pub fn envrc_watch_files(dir: &Path, filename: String) -> Vec { - let path = envrc_path(dir, &filename); - let mut files = vec![path.clone()]; - let Ok(contents) = std::fs::read_to_string(&path) else { +/// Files an .envrc layer depends on. `path` is the already-resolved .envrc, the +/// same canonical path the load path keys on, so watch and load never diverge +pub fn envrc_watch_files(path: &Path) -> Vec { + // directives inside resolve against the .envrc's own dir (direnv semantics) + let dir = path.parent().unwrap_or(path); + let mut files = vec![path.to_path_buf()]; + let Ok(contents) = std::fs::read_to_string(path) else { return files; }; for directive in parse(&contents) { @@ -256,6 +260,21 @@ mod tests { ); } + #[test] + fn use_flake_named_output_stays_bare_output() { + // regression: a parsed `.#dev` must build a bare-output installable, not + // be re-encoded and reclassified as a directed path (which would change + // the installable to a layer-dir path and orphan rooted profiles) + let Some(Directive::UseFlake(output)) = parse_line("use flake .#dev") else { + panic!("expected UseFlake"); + }; + let target = + crate::nix_dev_env::FlakeTarget::bare_output(Path::new("/layer"), output.as_deref()); + assert_eq!(target.installable, ".#dev"); + assert_eq!(target.spec, "flake:dev"); + assert_eq!(target.cwd, Path::new("/layer")); + } + #[test] fn comments_and_blanks_are_ignored() { assert_eq!(parse_line(""), None); diff --git a/src/loaders.rs b/src/loaders.rs index 8a3d341..6a2a0a8 100644 --- a/src/loaders.rs +++ b/src/loaders.rs @@ -360,14 +360,8 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { Ok(out.stdout) } -pub fn load_env(path: &Path, filename: String) -> Result { - let mut p = path.to_path_buf(); - if filename.is_empty() { - p.push(".env"); - } else { - p.push(filename); - } - let mut file = std::fs::File::open(p) +pub fn load_env(path: &Path) -> Result { + let mut file = std::fs::File::open(path) .with_context(|| format!("opening env file at {}", path.display()))?; let mut buf = String::new(); file.read_to_string(&mut buf).context("reading env file")?; diff --git a/src/main.rs b/src/main.rs index 7dbd9f0..83531a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod envrc; mod envs; mod loaders; mod nix_dev_env; +mod path_resolve; mod progress; mod shells; mod types; @@ -130,7 +131,9 @@ fn try_main() -> Result<()> { fn main() { if let Err(e) = try_main() { - eprintln!("failed to {e}\n{}", e.root_cause()); + // `{e:#}` prints the whole context chain, so which directive/path + // failed to resolve is not lost between top-level action and root cause + eprintln!("failed to {e:#}"); std::process::exit(1); } } diff --git a/src/nix_dev_env.rs b/src/nix_dev_env.rs index 1c563c8..53f5e97 100644 --- a/src/nix_dev_env.rs +++ b/src/nix_dev_env.rs @@ -90,46 +90,110 @@ const IGNORED_ENV_KEYS: &[&str] = &[ "dontAddDisableDepTrack", ]; -pub(crate) fn load_flake( - path: &Path, - output: Option, - profile: Option, -) -> Result { +/// a resolved flake installable +pub(crate) struct FlakeTarget { + /// dir `nix develop` runs in (the flake's own dir) + pub cwd: PathBuf, + /// `nix develop` installable arg, empty for the current-dir default + pub installable: String, + /// stable identifier of the resolved target, used in gc-root keys + pub spec: String, +} + +impl FlakeTarget { + /// the current-dir flake, optionally with a bare output (`.#dev`). this is + /// the historical layer-dir installable; callers that already have a parsed + /// output (e.g. an `.envrc` `use flake .#dev`) build it directly instead of + /// re-encoding a `.#output` string that the path classifier would misread + /// as a directed path + pub(crate) fn bare_output(dir: &Path, output: Option<&str>) -> Self { + match output.filter(|o| !o.is_empty()) { + Some(o) => FlakeTarget { + cwd: dir.to_path_buf(), + installable: format!(".#{o}"), + spec: format!("flake:{o}"), + }, + None => FlakeTarget { + cwd: dir.to_path_buf(), + installable: String::new(), + spec: "flake".to_string(), + }, + } + } +} + +/// a bare output (e.g. `dev`, `devShells.default`) has no `#` and no path +/// marker, so it stays `.#` in the layer dir, preserving historical +/// behavior; anything else is a directed path +fn looks_like_path(arg: &str) -> bool { + arg.contains('#') + || arg.contains('/') + || arg.starts_with('.') + || arg.starts_with('~') + || arg.starts_with('/') +} + +/// resolve a flake `arg` against `layer_dir`. infallible: a directed path is +/// resolved for watch (canonical when present, lexical when not), so a +/// not-yet-created target still keys watch/spec off its real dir; `nix develop` +/// surfaces a genuinely missing dir at load time +pub(crate) fn resolve_flake_target(layer_dir: &Path, arg: Option<&str>) -> FlakeTarget { + let Some(arg) = arg.filter(|a| !a.is_empty()) else { + return FlakeTarget::bare_output(layer_dir, None); + }; + + if !looks_like_path(arg) { + return FlakeTarget::bare_output(layer_dir, Some(arg)); + } + + // path[#output]: left is a directed flake path, optional right names an output within it + let (path_part, output) = match arg.split_once('#') { + Some((p, o)) => (p, Some(o)), + None => (arg, None), + }; + let path_part = if path_part.is_empty() { "." } else { path_part }; + let dir = crate::path_resolve::resolve_for_watch(layer_dir, path_part); + let installable = match output { + Some(o) if !o.is_empty() => format!("{}#{o}", dir.display()), + _ => dir.display().to_string(), + }; + let spec = format!("flake:{installable}"); + FlakeTarget { + cwd: dir, + installable, + spec, + } +} + +pub(crate) fn load_flake(target: &FlakeTarget, profile: Option) -> Result { let mut proc = Command::new("nix"); proc.arg("develop"); - // A named output is a flake installable. - if let Some(flake_output) = output.filter(|o| !o.is_empty()) { - proc.arg(format!(".#{flake_output}")); + if !target.installable.is_empty() { + proc.arg(&target.installable); } add_profile(&mut proc, profile.as_deref()); add_env_command(&mut proc); load_nix_dev_env( proc, - path, - &format!("at {}", path.display()), + &target.cwd, + &format!("at {}", target.cwd.display()), profile.as_deref(), ) } -pub(crate) fn load_shell( - path: &Path, - filename: String, - profile: Option, -) -> Result { - let file = if filename.is_empty() { - "./shell.nix".to_string() - } else { - filename - }; +pub(crate) fn load_shell(file: &Path, profile: Option) -> Result { + // run in the resolved file's own dir so its relative refs resolve + let cwd = file.parent().unwrap_or(file); + let file_str = file.to_string_lossy(); let mut proc = Command::new("nix"); - proc.args(["develop", "-f", &file]); + proc.args(["develop", "-f"]).arg(file); add_profile(&mut proc, profile.as_deref()); add_env_command(&mut proc); load_nix_dev_env( proc, - path, - &format!("-f {file} at {}", path.display()), + cwd, + &format!("-f {file_str} at {}", cwd.display()), profile.as_deref(), ) } @@ -306,6 +370,61 @@ fn clean_captured_path(value: &str, path_suffix: Option<&str>) -> String { mod tests { use super::*; + #[test] + fn bare_output_stays_current_dir_installable() { + // back-compat: `load flake dev` -> `.#dev` in the layer dir + let layer = Path::new("/layer"); + let target = resolve_flake_target(layer, Some("dev")); + assert_eq!(target.installable, ".#dev"); + assert_eq!(target.cwd, layer); + assert_eq!(target.spec, "flake:dev"); + } + + #[test] + fn no_arg_is_current_dir_default() { + let layer = Path::new("/layer"); + let target = resolve_flake_target(layer, None); + assert!(target.installable.is_empty()); + assert_eq!(target.cwd, layer); + assert_eq!(target.spec, "flake"); + } + + #[test] + fn directed_flake_path_resolves_and_runs_in_target_dir() { + let base = std::env::temp_dir().join(format!( + "cade-flake-target-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let sub = base.join("svc"); + std::fs::create_dir_all(&sub).unwrap(); + let canon_sub = std::fs::canonicalize(&sub).unwrap(); + + // `#dev`: installable carries the output + let target = resolve_flake_target(&base, Some("./svc#dev")); + assert_eq!(target.cwd, canon_sub); + assert_eq!(target.installable, format!("{}#dev", canon_sub.display())); + + // no output: installable is just the target dir + let target = resolve_flake_target(&base, Some("./svc")); + assert_eq!(target.cwd, canon_sub); + assert_eq!(target.installable, canon_sub.display().to_string()); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn directed_flake_missing_path_watches_real_target() { + // a directed flake at a not-yet-created path resolves lexically (no + // error here; `nix develop` reports the missing dir at load time) so the + // watch set tracks the real target's `flake.nix`, not the layer dir + let layer = Path::new("/no/such/layer"); + let target = resolve_flake_target(layer, Some("./nope")); + assert_eq!(target.cwd, Path::new("/no/such/layer/nope")); + assert_eq!(target.installable, "/no/such/layer/nope"); + assert_eq!(target.spec, "flake:/no/such/layer/nope"); + } + #[test] fn add_env_command_uses_resolved_env_binary() { let mut proc = Command::new("nix"); diff --git a/src/path_resolve.rs b/src/path_resolve.rs new file mode 100644 index 0000000..7e63d91 --- /dev/null +++ b/src/path_resolve.rs @@ -0,0 +1,160 @@ +//! resolve loader target paths against a layer's own directory +//! +//! relative paths resolve against the layer dir; `.`, `..`, absolute paths, and +//! a leading `~/` (expanded to $HOME) are all accepted. trust is the allowed +//! layer dir: a directed target is still just a nix/dotenv source inside an +//! already-approved layer, never a cade layer of its own + +use std::path::{Path, PathBuf}; + +/// expand a leading `~`/`~/...` to $HOME; other uses of `~` are left untouched, +/// matching the conservative expansion shells apply +fn expand_tilde(arg: &str) -> PathBuf { + expand_tilde_with(arg, home_dir()) +} + +fn expand_tilde_with(arg: &str, home: Option) -> PathBuf { + if arg == "~" { + if let Some(home) = home { + return home; + } + } else if let Some(rest) = arg.strip_prefix("~/") + && let Some(home) = home + { + return home.join(rest); + } + PathBuf::from(arg) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +/// resolve `arg` against the layer dir without requiring the result to exist. +/// absolute and `~`-prefixed args are taken as is, everything else joins onto +/// `layer_dir`. normalised lexically, not canonicalised, so it works for +/// not-yet-created paths +pub fn resolve_against(layer_dir: &Path, arg: &str) -> PathBuf { + let expanded = expand_tilde(arg); + let joined = if expanded.is_absolute() { + expanded + } else { + layer_dir.join(expanded) + }; + normalize_lexical(&joined) +} + +/// watch-set identity: the canonical path when the target exists (matching the +/// gc-root/spec key the load path derives), else the lexical path so creating +/// the file still trips the watcher +pub fn resolve_for_watch(layer_dir: &Path, arg: &str) -> PathBuf { + std::fs::canonicalize(resolve_against(layer_dir, arg)) + .unwrap_or_else(|_| resolve_against(layer_dir, arg)) +} + +/// collapse `.` and `..` lexically, leaving symlinks alone, so a target's path +/// is stable for hashing/watching even before it exists +fn normalize_lexical(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => match out.components().next_back() { + // keep `..` that can't be collapsed + Some(Component::Normal(_)) => { + out.pop(); + } + Some(Component::RootDir) => {} + _ => out.push(".."), + }, + other => out.push(other.as_os_str()), + } + } + if out.as_os_str().is_empty() { + PathBuf::from(".") + } else { + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_joins_layer_dir() { + assert_eq!( + resolve_against(Path::new("/layer"), "sub"), + PathBuf::from("/layer/sub") + ); + assert_eq!( + resolve_against(Path::new("/layer"), "./sub"), + PathBuf::from("/layer/sub") + ); + } + + #[test] + fn parent_and_absolute() { + assert_eq!( + resolve_against(Path::new("/layer/inner"), "../svc"), + PathBuf::from("/layer/svc") + ); + assert_eq!( + resolve_against(Path::new("/layer"), "/abs/path"), + PathBuf::from("/abs/path") + ); + } + + #[test] + fn watch_canonicalises_through_symlink() { + // the watch set and the gc-root/spec key both derive from + // resolve_for_watch, so the same physical file reached via a symlink + // shares one identity and an edit through it trips the watcher + let base = std::env::temp_dir().join(format!( + "cade-symlink-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let real = base.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join(".env"), "X=1\n").unwrap(); + let link = base.join("link"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // both the symlinked dir and the real dir resolve to one canonical file + let via_link = resolve_for_watch(&link, ".env"); + let via_real = resolve_for_watch(&real, ".env"); + assert_eq!(via_link, via_real); + assert_eq!(via_link, std::fs::canonicalize(real.join(".env")).unwrap()); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn watch_falls_back_to_lexical_when_missing() { + // a not-yet-created file still gets a watch entry so creating it reloads + assert_eq!( + resolve_for_watch(Path::new("/no/such/layer"), "shell.nix"), + PathBuf::from("/no/such/layer/shell.nix") + ); + } + + #[test] + fn tilde_expands_to_home() { + let home = Some(PathBuf::from("/home/tester")); + assert_eq!( + expand_tilde_with("~/proj", home.clone()), + PathBuf::from("/home/tester/proj") + ); + assert_eq!(expand_tilde_with("~", home), PathBuf::from("/home/tester")); + // bare `~user` (no slash) left untouched, like shells + assert_eq!( + expand_tilde_with("~other/x", Some(PathBuf::from("/home/tester"))), + PathBuf::from("~other/x") + ); + } +} diff --git a/tests/export_json.rs b/tests/export_json.rs index 589821c..9d96476 100644 --- a/tests/export_json.rs +++ b/tests/export_json.rs @@ -4,7 +4,11 @@ use common::{Sandbox, stderr, stdout}; use std::{path::Path, process::Output}; fn run_export_json(sb: &Sandbox, cwd: &Path, extra_env: &[(&str, &str)]) -> Output { - sb.run(cwd, &["export", "json"], extra_env) + // The shim endpoint is opt-in; these tests exercise its payload, so enable + // it explicitly. A test may still override CADE_DIRENV via extra_env. + let mut env = vec![("CADE_DIRENV", "full")]; + env.extend_from_slice(extra_env); + sb.run(cwd, &["export", "json"], &env) } fn parse_json(out: &Output) -> serde_json::Value { diff --git a/tests/integration.rs b/tests/integration.rs index d59a800..452c6cb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -726,7 +726,7 @@ fn direnv_export_with_owner_pid_writes_session_holder() { let out = sb.run( &sb.root, &["--owner-pid", owner.as_str(), "export", "json"], - &[], + &[("CADE_DIRENV", "full")], ); assert!(out.status.success(), "{:?}", out); @@ -1554,6 +1554,133 @@ fn envrc_is_autodetected_when_no_cade() { ); } +#[test] +fn direnv_none_ignores_bare_envrc() { + let sb = Sandbox::new(); + sb.write_config("direnv = \"none\"\n"); + // a bare .envrc, no .cade + sb.write(".envrc", "dotenv\n"); + sb.write(".env", "FROM_ENVRC=1\n"); + + // with the implicit loader off, the dir is invisible to cade: there is no + // config root, so nothing activates and no env is emitted. + let out = sb.enter(&sb.root, &[]); + let s = stdout(&out); + assert!( + !s.contains("FROM_ENVRC"), + "bare .envrc must not activate when direnv = none: {s}" + ); + assert!( + !s.contains("export __CADE_LAYERS="), + "no layers should compose for a bare .envrc when direnv = none: {s}" + ); +} + +#[test] +fn direnv_shim_skips_implicit_envrc_but_export_json_works() { + let sb = Sandbox::new(); + sb.write_config("direnv = \"shim\"\n"); + sb.write(".envrc", "dotenv\n"); + sb.write(".env", "FROM_ENVRC=1\n"); + + // implicit .envrc loader is off in shim mode: the bare .envrc dir is + // invisible, so entering it does not load anything. + let entered = sb.enter(&sb.root, &[]); + assert!( + !stdout(&entered).contains("FROM_ENVRC"), + "shim mode must not implicitly load .envrc: {}", + stdout(&entered) + ); + + // but the export shim endpoint is live + let exported = sb.run(&sb.root, &["export", "json"], &[]); + assert!(exported.status.success(), "{exported:?}"); + let json: serde_json::Value = serde_json::from_str(stdout(&exported).trim()).unwrap(); + assert!(json.is_object(), "export json must be an object: {json}"); +} + +#[test] +fn direnv_none_export_json_is_empty_noop() { + let sb = Sandbox::new(); + sb.write_config("direnv = \"none\"\n"); + sb.write(".cade", "A=1\n"); + sb.allow(&sb.root); + + // shim off: the endpoint stays a harmless no-op that does not emit cade env + let out = sb.run(&sb.root, &["export", "json"], &[]); + assert!(out.status.success(), "{out:?}"); + let json: serde_json::Value = serde_json::from_str(stdout(&out).trim()).unwrap(); + assert_eq!(json, serde_json::json!({}), "expected empty delta: {json}"); +} + +#[test] +fn direnv_none_export_json_unwinds_carried_diff() { + let sb = Sandbox::new(); + sb.write(".cade", "PROJ_VAR=hello\n"); + sb.allow(&sb.root); + + // First export under an active mode to obtain a real DIRENV_DIFF that + // carries the project's preimage, exactly as a prior shim/full export would + // have handed an editor like Zed. + let active = sb.run(&sb.root, &["export", "json"], &[("CADE_DIRENV", "full")]); + assert!(active.status.success(), "{active:?}"); + let active_json: serde_json::Value = serde_json::from_str(stdout(&active).trim()).unwrap(); + assert_eq!( + active_json["PROJ_VAR"], "hello", + "active export should set the project var: {active_json}" + ); + let diff = active_json["DIRENV_DIFF"] + .as_str() + .expect("active export must carry a DIRENV_DIFF") + .to_string(); + + // Now the mode flips to `none` while the editor still carries that + // DIRENV_DIFF. The shim is off, but the export must still unwind the carried + // project env rather than returning `{}` and stranding PROJ_VAR. + let out = sb.run( + &sb.root, + &["export", "json"], + &[ + ("CADE_DIRENV", "none"), + ("DIRENV_DIFF", diff.as_str()), + ("PROJ_VAR", "hello"), + ], + ); + assert!(out.status.success(), "{out:?}"); + let json: serde_json::Value = serde_json::from_str(stdout(&out).trim()).unwrap(); + assert_ne!( + json, + serde_json::json!({}), + "off-mode export must unwind a carried diff, not return an empty no-op: {json}" + ); + let obj = json.as_object().expect("delta is an object"); + assert!( + obj.contains_key("PROJ_VAR") && json["PROJ_VAR"].is_null(), + "PROJ_VAR had no preimage, so the unwind must clear it (null): {json}" + ); + assert!( + obj.contains_key("DIRENV_DIFF") && json["DIRENV_DIFF"].is_null(), + "the unwind must clear DIRENV_DIFF: {json}" + ); +} + +#[test] +fn default_mode_reads_bare_envrc() { + let sb = Sandbox::new(); + // no config written: the default mode (envrc) loads a bare .envrc + sb.write(".envrc", "dotenv\n"); + sb.write(".env", "FROM_ENVRC=1\n"); + sb.allow(&sb.root); + + let out = sb.enter(&sb.root, &[]); + assert!(out.status.success(), "{out:?}"); + assert!( + stdout(&out).contains("export FROM_ENVRC='1';"), + "default mode should load a bare .envrc: {}", + stdout(&out) + ); +} + #[test] fn explicit_load_envrc_directive() { let sb = Sandbox::new(); @@ -1816,3 +1943,53 @@ fn allow_gap_fill_respects_disinherit_root() { "disinherit caps the chain, so the parent must never compose: {s}" ); } + +#[test] +fn directed_load_env_reads_from_subdir() { + let sb = Sandbox::new(); + sb.write(".cade", "load env ./conf/app.env\n"); + sb.write("conf/app.env", "FROM_SUBDIR=1\n"); + sb.allow(&sb.root); + + let out = sb.enter(&sb.root, &[]); + assert!(out.status.success(), "enter failed: {:?}", out); + assert!( + stdout(&out).contains("export FROM_SUBDIR='1';"), + "directed env not loaded: {}", + stdout(&out) + ); +} + +#[test] +fn directed_load_env_reads_from_sibling_with_parent_ref() { + let sb = Sandbox::new(); + sb.write("shared/.env", "FROM_SIBLING=1\n"); + let proj = sb.dir("proj"); + sb.write("proj/.cade", "load env ../shared/.env\n"); + sb.allow(&proj); + + let out = sb.enter(&proj, &[]); + assert!(out.status.success(), "enter failed: {:?}", out); + assert!( + stdout(&out).contains("export FROM_SIBLING='1';"), + "directed sibling env not loaded: {}", + stdout(&out) + ); +} + +#[test] +fn directed_load_missing_path_errors_clearly() { + let sb = Sandbox::new(); + sb.write(".cade", "load env ./conf/missing.env\n"); + sb.allow(&sb.root); + + let out = sb.enter(&sb.root, &[]); + assert!(!out.status.success(), "missing directed env should fail"); + let err = stderr(&out); + // a missing directed target is reported by the loader at load time and must + // still name the offending file clearly + assert!( + err.contains("env file") && err.contains("missing.env"), + "error should name the loader and path: {err}" + ); +}