From 7a12a595eea1f21c2b7ffa00ae43eb1d8560c357 Mon Sep 17 00:00:00 2001 From: atagen Date: Tue, 2 Jun 2026 11:49:24 +1000 Subject: [PATCH] loaders: allow flake/shell/env/envrc to target other paths --- README.md | 12 ++- src/core.rs | 169 ++++++++++++++++++++++++++----------------- src/envrc.rs | 67 +++++++++++------ src/loaders.rs | 10 +-- src/main.rs | 5 +- src/nix_dev_env.rs | 165 ++++++++++++++++++++++++++++++++++++------ src/path_resolve.rs | 160 ++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 50 +++++++++++++ 8 files changed, 512 insertions(+), 126 deletions(-) create mode 100644 src/path_resolve.rs diff --git a/README.md b/README.md index b5b7c9e..1ac09f8 100644 --- a/README.md +++ b/README.md @@ -219,16 +219,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 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/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/integration.rs b/tests/integration.rs index d59a800..3da7e46 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1816,3 +1816,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}" + ); +}