Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<shell>`. 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 <shell>`. 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`.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
86 changes: 80 additions & 6 deletions nix/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
'';
};

Expand Down Expand Up @@ -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.
Expand Down
103 changes: 96 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Self::Err> {
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<PathBuf>,
pub verbosity: Option<Verbosity>,
pub long_running_warning_ms: Option<u64>,
pub shell_gc_root_ttl_seconds: Option<u64>,
pub direnv: DirenvMode,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
verbosity: Option<String>,
long_running_warning_ms: Option<u64>,
shell_gc_root_ttl_seconds: Option<u64>,
direnv: Option<String>,
}

static CONFIG: OnceLock<Config> = OnceLock::new();
Expand Down Expand Up @@ -48,6 +86,19 @@ pub fn shell_gc_root_ttl_seconds() -> Option<u64> {
.or_else(|| current().shell_gc_root_ttl_seconds)
}

pub fn direnv_mode() -> DirenvMode {
match std::env::var("CADE_DIRENV") {
Ok(raw) => match raw.parse::<DirenvMode>() {
Ok(mode) => mode,
Err(e) => {
eprintln!("cade: ignoring CADE_DIRENV: {e}");
current().direnv
}
},
Err(_) => current().direnv,
}
}

fn home_config_path() -> Option<PathBuf> {
let mut path = PathBuf::from(std::env::var_os("HOME")?);
path.push(".config");
Expand Down Expand Up @@ -125,11 +176,18 @@ impl TryFrom<RawConfig> 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::<DirenvMode>()
.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,
})
}
}
Expand All @@ -144,40 +202,71 @@ 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());
}

#[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());
}

#[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::<DirenvMode>().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());
}
}
Loading
Loading