diff --git a/README.md b/README.md index 8a8207c..b5b7c9e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ one directive per line, `#` are comments # keeping variables inherited from parent .cade layers pure +# stop the cascade here; no parent .cade layers load above this dir +disinherit + # load from flake (default shell or named installable) load load flake diff --git a/src/cli/parse.rs b/src/cli/parse.rs index 1005322..c090ac0 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -62,6 +62,7 @@ impl FromStr for Keyword { let res = match keyword.as_str() { "pure" => Pure, + "disinherit" => Disinherit, "call" => { // split respecting shell quoting let target = shlex::split(rest_raw).ok_or(ParseError::InvalidQuoting)?; @@ -181,6 +182,14 @@ mod tests { )); } + #[test] + fn bare_disinherit_parses() { + assert!(matches!( + "disinherit".parse::(), + Ok(Keyword::Disinherit) + )); + } + #[test] fn keyword_with_equals_in_args_stays_a_keyword() { // the `=` belongs to the hook command, not a bare assignment diff --git a/src/core.rs b/src/core.rs index bb063d2..25912c4 100644 --- a/src/core.rs +++ b/src/core.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result, anyhow, bail}; use rusqlite::named_params; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeSet, HashMap, HashSet}, path::{Path, PathBuf}, process::Command, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -106,8 +106,7 @@ where } } -/// Compact ` (n)` stack-depth badge for load/unload notices: the tip is the -/// nth layer applied. Empty for a lone layer. +/// ` (n)` stack-depth badge; empty for a lone layer fn layer_count_suffix(total: usize) -> String { if total > 1 { format!(" ({total})") @@ -116,6 +115,27 @@ fn layer_count_suffix(total: usize) -> String { } } +/// yellow `[←]` unloaded notice; `total` sizes the badge +fn announce_unloaded(dir: &str, total: usize) { + verbosity::log( + Verbosity::Normal, + format_args!( + "{}cade: unloaded {}{}.", + crate::progress::eviction_marker(), + dir, + layer_count_suffix(total) + ), + ); +} + +/// green `[→]` loaded notice for an in-place single-layer change +fn announce_loaded(dir: &str) { + verbosity::log( + Verbosity::Normal, + format_args!("{}cade: loaded {}.", crate::progress::load_marker(), dir), + ); +} + pub struct RollupResult { pub env: HashMap>, // vars that concatenate ambient values rather than clobbering them @@ -242,9 +262,21 @@ struct WatchState { files: Vec, } -fn has_config(dir: &Path) -> bool { - std::fs::exists(dir.join(".cade")).unwrap_or(false) - || std::fs::exists(dir.join(".envrc")).unwrap_or(false) +/// what a dir contributes; a co-located `.envrc` yields to `.cade`, so at most one kind +#[derive(Clone, Copy, PartialEq, Eq)] +enum DirKind { + Cade, + Envrc, +} + +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) { + Some(DirKind::Envrc) + } else { + None + } } // Falls back to an implicit `load envrc` when a dir has no .cade. @@ -256,16 +288,62 @@ fn config_keywords(dir: &Path) -> Result> { } } -fn find_cade_root(start: &Path) -> Option { - let mut dir = start.to_path_buf(); - loop { - if has_config(&dir) { - return Some(dir); - } - if !dir.pop() { - return None; +/// the active layer set, tip-first: every `.cade` ancestor (the cascade stacks +/// across gaps; an empty intermediate dir does not sever it) unioned with +/// direnv's single nearest `.envrc`. `disinherit` halts the cascade; otherwise +/// only the permission layer caps it +// +// note: chain-build parses each `.cade` here and activation parses it again via +// `config_keywords`; a single-parse pass shared across both is a cross-cutting +// refactor (touches the composition-branch callers) and is deferred. for now we +// keep the error policy aligned: a malformed `.cade` caps the cascade at that +// dir so its parse error surfaces at load instead of being silently climbed past +fn participant_dirs(start: &Path) -> Vec { + let mut cade_chain: Vec = Vec::new(); + let mut nearest_envrc: Option = None; + + let mut dir = Some(start.to_path_buf()); + while let Some(d) = dir { + match dir_kind(&d) { + Some(DirKind::Cade) => { + cade_chain.push(d.clone()); + // include this dir then stop on either disinherit or a parse + // error: both cap the cascade, and a malformed `.cade` left in + // the chain surfaces consistently when activation re-parses it + match read_cade(&d.join(".cade")) { + Ok(kws) if kws.iter().any(|kw| matches!(kw, Keyword::Disinherit)) => break, + Err(_) => break, + Ok(_) => {} + } + } + Some(DirKind::Envrc) => { + // only the nearest .envrc + nearest_envrc.get_or_insert_with(|| d.clone()); + } + // a gap does not break the cascade + None => {} } + dir = d.parent().map(Path::to_path_buf); + } + + merge_participants(cade_chain, nearest_envrc) +} + +fn merge_participants(cade_chain: Vec, nearest_envrc: Option) -> Vec { + let mut dirs = cade_chain; + if let Some(envrc) = nearest_envrc + && !dirs.contains(&envrc) + { + dirs.push(envrc); } + // deepest first + dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count())); + dirs +} + +/// activation root: the deepest participant (may be an `.envrc` below the nearest `.cade`) +fn find_cade_root(start: &Path) -> Option { + participant_dirs(start).into_iter().next() } // Reject session ids that could escape the snapshots dir when used as a path. @@ -912,12 +990,12 @@ impl Cade { if !permission { return self.set_permission(&root, false); } - let chain = collect_cade_paths(&root); // tip-first contiguous config dirs + // participants may be non-contiguous, so gap-fill walks them, not raw parents + let chain = collect_cade_paths(&root); if chain.is_empty() { - // if there's no .cade, ignore the request return Ok(()); } - // the base is the nearest already-approved ancestor; fill from tip to it + // fill from the tip up to the nearest already-approved participant let mut base = None; for (i, dir) in chain.iter().enumerate() { if self.get_permission(Path::new(dir))? { @@ -1059,7 +1137,7 @@ impl Cade { pub fn do_activation( &mut self, shell: &dyn ShellOutput, - announce: Announce, + announce: Option, client_id: Option<&str>, owner_pid: Option, ) -> Result<()> { @@ -1130,16 +1208,19 @@ impl Cade { let hooks_json = serde_json::to_string(&rollup.hooks).unwrap_or_default(); print!("{}", shell.set_env("__CADE_HOOKS", &hooks_json)); - let watch_state = build_watch_state(&plan.root, &plan.all_watch_files); + let watch_state = build_watch_state(&plan.root, layer_paths.clone(), &plan.all_watch_files); let watches_json = serde_json::to_string(&watch_state).unwrap_or_default(); print!("{}", shell.set_env("__CADE_WATCHES", &watches_json)); - spinner.success(&format!( - "cade: {} {}{}.", - announce.verb(), - plan.root.display(), - layer_count_suffix(layer_paths.len()) - )); + match announce { + Some(announce) => spinner.success(&format!( + "cade: {} {}{}.", + announce.verb(), + plan.root.display(), + layer_count_suffix(layer_paths.len()) + )), + None => spinner.done(), + } log_key_list("set", set_keys); log_key_list("cleared", &rollup.unset); @@ -1182,21 +1263,10 @@ impl Cade { .and_then(|h| serde_json::from_str(&h).ok()) .unwrap_or_default(); - if announce - && verbosity::enabled(Verbosity::Normal) - && let Some(layers) = &layers - { + if announce && let Some(layers) = &layers { let paths: Vec<&str> = layers.split('\x1F').filter(|s| !s.is_empty()).collect(); if let Some(tip) = paths.last() { - verbosity::log( - Verbosity::Normal, - format_args!( - "{}cade: unloaded {}{}.", - crate::progress::eviction_marker(), - tip, - layer_count_suffix(paths.len()) - ), - ); + announce_unloaded(tip, paths.len()); } } @@ -1287,110 +1357,137 @@ impl Cade { client_id: Option<&str>, owner_pid: Option, ) -> Result<()> { - let root = find_cade_root(&self.cwd); + let cwd = self.cwd.clone(); + let (active, disallowed_tip) = self.resolve_active(&cwd)?; + let new_root = active.first().cloned(); + let new_set: BTreeSet = active + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); let is_active = std::env::var("__CADE_LAYERS").is_ok(); - if is_active { - if let Ok(session) = std::env::var("__CADE_SESSION") - && is_valid_session(&session) - { - self.refresh_session_holders(&session, client_id, owner_pid); + // not active yet + if !is_active { + if new_root.is_some() { + self.do_activation(shell, Some(Announce::Loaded), client_id, owner_pid)?; + } else { + self.sync_disallowed_prompt(disallowed_tip.as_deref(), shell); } - // reload/restore only when the active state is stale - let watch_state = std::env::var("__CADE_WATCHES") - .ok() - .and_then(|w| serde_json::from_str::(&w).ok()); - let stale = watch_state - .as_ref() - .map(|state| watches_are_stale(state, root.as_deref())) - .unwrap_or(true); - - // Check whether any layer in the active chain lost permission - // (e.g. disallow a parent dir while in child dir). - let permission_revoked = watch_state - .as_ref() - .map(|state| { - state - .cade_paths - .iter() - .any(|dir| !self.get_permission(Path::new(dir)).unwrap_or(false)) - }) - .unwrap_or(false); + return Ok(()); + } - let root_permitted = root - .as_ref() - .and_then(|r| self.get_permission(r).ok()) - .unwrap_or(false); + if let Ok(session) = std::env::var("__CADE_SESSION") + && is_valid_session(&session) + { + self.refresh_session_holders(&session, client_id, owner_pid); + } - if stale || permission_revoked { - let reactivating = !permission_revoked && root_permitted; - let same_tree = match (&watch_state, &root) { - (Some(state), Some(r)) if reactivating => roots_in_same_cade_tree(state, r), - _ => false, - }; - self.do_restore( - shell, - !reactivating, - !reactivating || !same_tree, - client_id, - owner_pid, - )?; - if reactivating { - self.do_activation( - shell, - if same_tree { - Announce::Reloaded - } else { - Announce::Loaded - }, - client_id, - owner_pid, - )?; - } else if let Some(root) = &root { - mark_disallowed_root(root, shell); + let state = std::env::var("__CADE_WATCHES") + .ok() + .and_then(|w| serde_json::from_str::(&w).ok()); + let old_set: BTreeSet = state + .as_ref() + .map(|s| s.cade_paths.iter().cloned().collect()) + .unwrap_or_default(); + let old_root = state.as_ref().map(|s| s.root.clone()); + let files_stale = state.as_ref().map(files_changed).unwrap_or(true); + + // unchanged; only keep the disallowed-child prompt in sync + if new_set == old_set && !files_stale { + self.sync_disallowed_prompt(disallowed_tip.as_deref(), shell); + return Ok(()); + } + + match &new_root { + // left every approved layer + None => { + self.do_restore(shell, true, true, client_id, owner_pid)?; + } + Some(new_root) => { + let new_root_s = new_root.to_string_lossy().to_string(); + let same = old_root.as_deref() == Some(new_root_s.as_str()); + let deeper = old_root + .as_ref() + .is_some_and(|o| new_root.starts_with(o) && new_root_s != *o); + let shallower = old_root + .as_ref() + .is_some_and(|o| Path::new(o).starts_with(new_root) && new_root_s != *o); + + if same { + // same root: announce the parents that joined/left, then one reload + self.do_restore(shell, false, false, client_id, owner_pid)?; + for dir in old_set.difference(&new_set) { + announce_unloaded(dir, 1); + } + for dir in new_set.difference(&old_set) { + announce_loaded(dir); + } + self.do_activation(shell, Some(Announce::Reloaded), client_id, owner_pid)?; + } else if deeper { + // descended: load the new tip (badge shows nesting) + self.do_restore(shell, false, false, client_id, owner_pid)?; + self.do_activation(shell, Some(Announce::Loaded), client_id, owner_pid)?; + } else if shallower { + // ascended: unload the dropped tip, recompose the base silently + self.do_restore(shell, false, true, client_id, owner_pid)?; + self.do_activation(shell, None, client_id, owner_pid)?; } else { - clear_disallowed_root_marker(shell); + // sibling: unload old, load new + self.do_restore(shell, false, true, client_id, owner_pid)?; + self.do_activation(shell, Some(Announce::Loaded), client_id, owner_pid)?; } } - } else { - self.activate_if_permitted(&root, shell, client_id, owner_pid)?; } - + self.sync_disallowed_prompt(disallowed_tip.as_deref(), shell); Ok(()) } - /// Layers to compose, returned parent-first + /// prompt to allow a disallowed tip, or clear a stale prompt; leaves the parent env alone + fn sync_disallowed_prompt(&self, disallowed_tip: Option<&Path>, shell: &dyn ShellOutput) { + match disallowed_tip { + Some(tip) => mark_disallowed_root(tip, shell), + None => clear_disallowed_root_marker(shell), + } + } + + /// layers to compose, parent-first; anchors on the deepest approved participant + /// (a disallowed tip is skipped, not refused) and caps at the first unapproved above it fn approved_chain(&mut self, root: &Path) -> Result)>> { let mut chain = Vec::new(); + let mut anchored = false; for dir in collect_cade_paths(root) { let path = PathBuf::from(&dir); - if !self.get_permission(&path)? { + if self.get_permission(&path)? { + anchored = true; + let keywords = config_keywords(&path)?; + chain.push((path, keywords)); + } else if anchored { break; } - let keywords = config_keywords(&path)?; - chain.push((path, keywords)); } chain.reverse(); // parent-first for rollup Ok(chain) } - fn activate_if_permitted( - &mut self, - root: &Option, - shell: &dyn ShellOutput, - client_id: Option<&str>, - owner_pid: Option, - ) -> Result<()> { - if let Some(root) = root { - if self.get_permission(root)? { - self.do_activation(shell, Announce::Loaded, client_id, owner_pid)?; - } else { - mark_disallowed_root(root, shell); + /// participants that will compose at `cwd`, tip-first (anchored on the deepest + /// approved), plus the deepest participant if it is disallowed (to prompt for it) + fn resolve_active(&mut self, cwd: &Path) -> Result<(Vec, Option)> { + let participants = participant_dirs(cwd); + let mut active = Vec::new(); + let mut anchored = false; + for p in &participants { + if self.get_permission(p)? { + anchored = true; + active.push(p.clone()); + } else if anchored { + break; } - } else { - clear_disallowed_root_marker(shell); } - Ok(()) + let disallowed_tip = match participants.first() { + Some(tip) if active.first() != Some(tip) => Some(tip.clone()), + _ => None, + }; + Ok((active, disallowed_tip)) } pub fn do_status(&mut self) -> Result<()> { @@ -1656,7 +1753,8 @@ fn load_single_layer( Clear(vars) => Ok(CadeAction::Clear(vars.clone())), Concat(vars) => Ok(CadeAction::Concat(vars.clone())), Set(env) => Ok(CadeAction::Environ(env.clone())), - Watch(_) => continue, + // affects only chain construction, not the loaded environment + Watch(_) | Disinherit => continue, }?; layer.push_action(act); } @@ -1712,8 +1810,11 @@ fn compute_layer_key(watched_files: &[PathBuf]) -> String { parts.join("\n") } -fn build_watch_state(root: &Path, watched_files: &[PathBuf]) -> WatchState { - let cade_paths = collect_cade_paths(root); +fn build_watch_state( + root: &Path, + cade_paths: Vec, + watched_files: &[PathBuf], +) -> WatchState { let files = watched_files .iter() .filter_map(|f| { @@ -1733,22 +1834,8 @@ fn build_watch_state(root: &Path, watched_files: &[PathBuf]) -> WatchState { } } -/// check if watched files are stale, current_root is the innermost .cade -fn watches_are_stale(state: &WatchState, current_root: Option<&Path>) -> bool { - let current_root = match current_root { - Some(r) => r, - None => return true, // left the cade tree - }; - if current_root.to_string_lossy() != state.root { - return true; - } - - // a .cade added/removed in the ancestry changes the layer set - if collect_cade_paths(current_root) != state.cade_paths { - return true; - } - - // check file mtimes/sizes +/// any watched file changed (mtime, size, or gone); the layer-set change is checked separately +fn files_changed(state: &WatchState) -> bool { for entry in &state.files { match std::fs::metadata(&entry.path) { Ok(meta) => { @@ -1759,31 +1846,16 @@ fn watches_are_stale(state: &WatchState, current_root: Option<&Path>) -> bool { Err(_) => return true, // file disappeared } } - false } -fn roots_in_same_cade_tree(state: &WatchState, current_root: &Path) -> bool { - let current = current_root.to_string_lossy(); - state.root == current - || state.cade_paths.iter().any(|p| p == current.as_ref()) - || collect_cade_paths(current_root) - .iter() - .any(|p| p == &state.root) -} - -/// chain of .cade or .envrcs from root upward (tip-first) +/// participants for an activation root, tip-first; `root` is the deepest +/// participant, so this reproduces the cwd's set (see `participant_dirs`) fn collect_cade_paths(root: &Path) -> Vec { - let mut paths = Vec::new(); - let mut dir = Some(root.to_path_buf()); - while let Some(d) = dir { - if !has_config(&d) { - break; - } - paths.push(d.to_string_lossy().to_string()); - dir = d.parent().map(Path::to_path_buf); - } - paths + participant_dirs(root) + .iter() + .map(|d| d.to_string_lossy().to_string()) + .collect() } fn mtime_nanos(meta: &std::fs::Metadata) -> u128 { @@ -1920,6 +1992,181 @@ mod tests { std::fs::remove_dir_all(&base).ok(); } + fn parts(dirs: &[PathBuf], base: &Path) -> Vec { + dirs.iter() + .map(|d| { + d.strip_prefix(base) + .unwrap_or(d) + .to_string_lossy() + .to_string() + }) + .collect() + } + + /// build a temp tree per spec, then assert the tip-first participant list + fn assert_participants(spec: &[(&str, &str)], cwd_rel: &str, expect_tip_first: &[&str]) { + use std::sync::atomic::{AtomicU32, Ordering}; + static SALT: AtomicU32 = AtomicU32::new(0); + let base = std::env::temp_dir().join(format!( + "cade-parts-{}-{}", + std::process::id(), + SALT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::remove_dir_all(&base).ok(); + for (rel, file) in spec { + let dir = base.join(rel); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(file), b"").unwrap(); + } + let cwd = base.join(cwd_rel); + std::fs::create_dir_all(&cwd).unwrap(); + let got = parts(&participant_dirs(&cwd), &base); + let want: Vec = expect_tip_first.iter().map(|s| s.to_string()).collect(); + assert_eq!(got, want, "spec {spec:?} cwd {cwd_rel}"); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn participants_cade_cascade() { + // S1: contiguous .cade cascade, tip-first + assert_participants(&[("a", ".cade"), ("a/b", ".cade")], "a/b", &["a/b", "a"]); + } + + #[test] + fn participants_nearest_envrc_only_no_cade() { + // S2: stacked .envrc, no .cade -> only the nearest one + assert_participants(&[("a", ".envrc"), ("a/b", ".envrc")], "a/b", &["a/b"]); + } + + #[test] + fn participants_cade_union_nearest_envrc_below() { + // S3: cade {a} union nearest envrc {a/b} + assert_participants(&[("a", ".cade"), ("a/b", ".envrc")], "a/b", &["a/b", "a"]); + } + + #[test] + fn participants_cade_union_nearest_envrc_above() { + // S4: cade {a/b} union nearest envrc {a} + assert_participants(&[("a", ".envrc"), ("a/b", ".cade")], "a/b", &["a/b", "a"]); + } + + #[test] + fn participants_only_nearest_envrc_enters_with_a_gap() { + // S5: cade {a} union nearest envrc {a/b/c}; a/b is dropped (a hole) + assert_participants( + &[("a", ".cade"), ("a/b", ".envrc"), ("a/b/c", ".envrc")], + "a/b/c", + &["a/b/c", "a"], + ); + } + + #[test] + fn participants_cade_cascade_spans_a_gap() { + // an empty intermediate dir does not sever the cascade + assert_participants( + &[("a", ".cade"), ("a/b/c", ".cade")], + "a/b/c", + &["a/b/c", "a"], + ); + } + + #[test] + fn participants_upper_envrc_survives_a_cade_cascade_gap() { + // the gap at b excludes an upper .cade, but the nearest .envrc above it still joins + assert_participants( + &[("a", ".envrc"), ("a/b/c", ".cade")], + "a/b/c", + &["a/b/c", "a"], + ); + } + + #[test] + fn participants_colocated_envrc_is_ignored() { + // S6: a dir with both is a .cade layer; its .envrc never participates + let base = std::env::temp_dir().join(format!("cade-parts-both-{}", std::process::id())); + std::fs::remove_dir_all(&base).ok(); + let a = base.join("a"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::write(a.join(".cade"), b"").unwrap(); + std::fs::write(a.join(".envrc"), b"").unwrap(); + assert_eq!(parts(&participant_dirs(&a), &base), vec!["a".to_string()]); + std::fs::remove_dir_all(&base).ok(); + } + + /// Build a temp tree from (rel-dir, filename, contents) entries. + fn build_tree(spec: &[(&str, &str, &str)], tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static SALT: AtomicU32 = AtomicU32::new(0); + let base = std::env::temp_dir().join(format!( + "cade-{tag}-{}-{}", + std::process::id(), + SALT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::remove_dir_all(&base).ok(); + for (rel, file, contents) in spec { + let dir = base.join(rel); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(file), contents.as_bytes()).unwrap(); + } + base + } + + #[test] + fn disinherit_truncates_the_cade_cascade() { + // child .cade disinherits, so its .cade parent never joins the chain. + let base = build_tree( + &[("a", ".cade", ""), ("a/b", ".cade", "disinherit\n")], + "disinherit", + ); + let cwd = base.join("a/b"); + assert_eq!( + parts(&participant_dirs(&cwd), &base), + vec!["a/b".to_string()] + ); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn disinherit_still_unions_the_nearest_envrc() { + // disinherit drops the parent .cade, but a nearer .envrc still composes. + let base = build_tree( + &[ + ("a", ".cade", ""), + ("a/b", ".cade", "disinherit\n"), + ("a/b/c", ".envrc", "export X=1\n"), + ], + "disinherit-envrc", + ); + let cwd = base.join("a/b/c"); + assert_eq!( + parts(&participant_dirs(&cwd), &base), + vec!["a/b/c".to_string(), "a/b".to_string()] + ); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn malformed_cade_caps_the_cascade_instead_of_being_skipped() { + // a child `.cade` with an unparseable directive must cap the chain at + // that dir, not be silently climbed past to its valid parent. this keeps + // the chain-shape decision aligned with activation, which surfaces the + // parse error when it re-reads the same file. + let base = build_tree( + &[ + ("a", ".cade", "A_CADE=1\n"), + ("a/b", ".cade", "not a keyword\n"), + ], + "disinherit-malformed", + ); + let cwd = base.join("a/b"); + assert_eq!( + parts(&participant_dirs(&cwd), &base), + vec!["a/b".to_string()], + "malformed .cade must cap the cascade, not skip up to the parent" + ); + std::fs::remove_dir_all(&base).ok(); + } + #[test] fn read_cade_errors_on_invalid_utf8_instead_of_truncating() { let path = std::env::temp_dir().join(format!("cade-badutf8-{}", std::process::id())); diff --git a/src/core/activation.rs b/src/core/activation.rs index 0801aa7..52258f3 100644 --- a/src/core/activation.rs +++ b/src/core/activation.rs @@ -65,6 +65,8 @@ impl Cade { if cade_files.is_empty() { return Ok(None); } + // effective root = deepest approved participant, so messages and watches track what composed + let root = cade_files.last().map(|(p, _)| p.clone()).unwrap_or(root); let mut cade_layers = Vec::new(); let mut all_watch_files: Vec = Vec::new(); diff --git a/src/main.rs b/src/main.rs index d1d4dc2..7dbd9f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,7 @@ fn try_main() -> Result<()> { let output = shell_name.get_output(); cade.do_activation( output.as_ref(), - Announce::Loaded, + Some(Announce::Loaded), args.client_id.as_deref(), args.owner_pid, ) diff --git a/src/progress.rs b/src/progress.rs index 71554e9..199cb05 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -91,6 +91,15 @@ pub fn eviction_marker() -> String { } } +/// green `[→]` marker for layer notices emitted outside the spinner; empty off-terminal +pub fn load_marker() -> String { + if std::io::stderr().is_terminal() { + format!("[{GREEN}{LOADED}{RESET}] ") + } else { + String::new() + } +} + /// Replace the spinner's recent-output tail (shown once long-running). pub fn set_recent(lines: Vec) { if !is_active() { @@ -191,6 +200,28 @@ impl Spinner { } } + /// resolve with no message; for a silent recompose where another notice carries the news + pub fn done(mut self) { + self.resolved = true; + if !self.active { + return; + } + ACTIVE.store(false, Ordering::Release); + if let Some(thread) = self.thread.take() { + thread.thread().unpark(); + let _ = thread.join(); + } + let visible = STATE + .lock() + .unwrap() + .take() + .map(|state| state.visible_lines) + .unwrap_or(0); + let mut err = std::io::stderr().lock(); + rewind(&mut err, visible); + let _ = err.flush(); + } + fn finish(&mut self, colour: &str, symbol: char, message: String) { ACTIVE.store(false, Ordering::Release); if let Some(thread) = self.thread.take() { diff --git a/src/types.rs b/src/types.rs index 04fbe2c..a6a47bb 100644 --- a/src/types.rs +++ b/src/types.rs @@ -26,6 +26,8 @@ pub struct CadeLayer { #[derive(Debug)] pub enum Keyword { Pure, + /// stop the `.cade` cascade at this dir; nothing above it loads + Disinherit, Call(Vec), Load(Loadable), Hook(InnerHook), diff --git a/tests/integration.rs b/tests/integration.rs index 9ddaa39..d59a800 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1251,7 +1251,10 @@ fn reload_between_unrelated_roots_announces_unload_then_load() { } #[test] -fn reload_within_same_cade_tree_stays_reload() { +fn reload_descending_into_child_loads_the_new_layer() { + // active at the parent root, then sitting in an approved child: descending + // composes the new tip, announced as a load (the badge shows it is nested), + // with the parent left silent (no spurious unload). let sb = Sandbox::new(); sb.write(".cade", "A=1\n"); let sub = sb.dir("sub"); @@ -1287,11 +1290,149 @@ fn reload_within_same_cade_tree_stays_reload() { let err = stderr(&out); assert!(!err.contains("cade: unloaded"), "{err}"); assert!( - err.contains(&format!("cade: reloaded {sub_str} (2).")), + err.contains(&format!("cade: loaded {sub_str} (2).")), "{err}" ); } +#[test] +fn reload_ascending_out_of_child_unloads_it() { + // active in the child (composed [root, sub]); moving up to the parent drops + // the child layer (announced unloaded) while the base survives silently. + let sb = Sandbox::new(); + sb.write(".cade", "A=1\n"); + let sub = sb.dir("sub"); + sb.write("sub/.cade", "B=2\n"); + sb.allow(&sb.root); + sb.allow(&sub); + sb.write_snapshot("s5", "PATH=/orig"); + + let root_str = sb.root.to_string_lossy().to_string(); + let sub_str = sub.to_string_lossy().to_string(); + let layers = format!("{root_str}\u{1f}{sub_str}"); + let watches = serde_json::json!({ + "root": sub_str, + "cade_paths": [sub_str, root_str], + "files": [] + }) + .to_string(); + + let out = sb.run( + &sb.root, + &["reload", "--shell", "bash"], + &[ + ("__CADE_SESSION", "s5"), + ("__CADE_SET", "A\u{1f}B"), + ("__CADE_UNSET", ""), + ("__CADE_PURE", "0"), + ("__CADE_HOOKS", "[]"), + ("__CADE_LAYERS", layers.as_str()), + ("__CADE_WATCHES", watches.as_str()), + ("A", "1"), + ("B", "2"), + ], + ); + assert!(out.status.success(), "{:?}", out); + let err = stderr(&out); + assert!(err.contains(&format!("cade: unloaded {sub_str}")), "{err}"); + assert!(!err.contains("cade: loaded"), "{err}"); + assert!(!err.contains("cade: reloaded"), "{err}"); + // the surviving base is recomposed to just the root layer + assert!( + stdout(&out).contains(&format!("__CADE_LAYERS='{root_str}'")), + "{}", + stdout(&out) + ); +} + +#[test] +fn reload_into_disallowed_child_keeps_the_approved_parent() { + // sitting in a disallowed child while the parent is active must NOT unload + // the parent; it only prompts to allow the child. + let sb = Sandbox::new(); + sb.write(".cade", "A=1\n"); + let sub = sb.dir("sub"); + sb.write("sub/.cade", "B=2\n"); + sb.allow(&sb.root); // child intentionally NOT allowed + sb.write_snapshot("s5", "PATH=/orig"); + + let root_str = sb.root.to_string_lossy().to_string(); + let watches = serde_json::json!({ + "root": root_str, + "cade_paths": [root_str], + "files": [] + }) + .to_string(); + + let out = sb.run( + &sub, + &["reload", "--shell", "bash"], + &[ + ("__CADE_SESSION", "s5"), + ("__CADE_SET", "A"), + ("__CADE_UNSET", ""), + ("__CADE_PURE", "0"), + ("__CADE_HOOKS", "[]"), + ("__CADE_LAYERS", root_str.as_str()), + ("__CADE_WATCHES", watches.as_str()), + ("A", "1"), + ], + ); + assert!(out.status.success(), "{:?}", out); + let err = stderr(&out); + assert!(!err.contains("cade: unloaded"), "{err}"); + assert!(err.contains("disallowed"), "{err}"); + // no reactivation: the parent's layer set is left untouched + assert!(!stdout(&out).contains("__CADE_LAYERS"), "{}", stdout(&out)); +} + +#[test] +fn reload_when_parent_revoked_unloads_parent_and_reloads_tip() { + // composed [root, sub]; with the parent no longer approved, the tip stays + // active but recomposes: unload the dropped parent, reload the tip. + let sb = Sandbox::new(); + sb.write(".cade", "A=1\n"); + let sub = sb.dir("sub"); + sb.write("sub/.cade", "B=2\n"); + sb.allow(&sub); // parent intentionally NOT allowed (simulating a revoke) + sb.write_snapshot("s5", "PATH=/orig"); + + let root_str = sb.root.to_string_lossy().to_string(); + let sub_str = sub.to_string_lossy().to_string(); + let layers = format!("{root_str}\u{1f}{sub_str}"); + let watches = serde_json::json!({ + "root": sub_str, + "cade_paths": [sub_str, root_str], + "files": [] + }) + .to_string(); + + let out = sb.run( + &sub, + &["reload", "--shell", "bash"], + &[ + ("__CADE_SESSION", "s5"), + ("__CADE_SET", "A\u{1f}B"), + ("__CADE_UNSET", ""), + ("__CADE_PURE", "0"), + ("__CADE_HOOKS", "[]"), + ("__CADE_LAYERS", layers.as_str()), + ("__CADE_WATCHES", watches.as_str()), + ("A", "1"), + ("B", "2"), + ], + ); + assert!(out.status.success(), "{:?}", out); + let err = stderr(&out); + assert!(err.contains(&format!("cade: unloaded {root_str}")), "{err}"); + assert!(err.contains(&format!("cade: reloaded {sub_str}")), "{err}"); + assert!( + stdout(&out).contains(&format!("__CADE_LAYERS='{sub_str}'")), + "{}", + stdout(&out) + ); +} + #[test] fn watch_directive_invalidates_a_call_layer() { let sb = Sandbox::new(); @@ -1493,3 +1634,185 @@ fn inline_assignment_hard_replace_drops_ambient() { stdout(&out) ); } + +// The S-table: the active layer set is cade's contiguous `.cade` cascade unioned +// with direnv's single nearest `.envrc`. Each layer sets a uniquely-named var so +// we can read off exactly which participants composed. + +#[test] +fn s1_cade_cascade_composes_both() { + // /a/.cade + /a/b/.cade, cwd=a/b -> [a, a/b] + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "B_CADE=1\n"); + sb.allow(&sb.dir("a")); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export A_CADE='1';"), "{s}"); + assert!(s.contains("export B_CADE='1';"), "{s}"); +} + +#[test] +fn s2_nearest_envrc_only_no_cade() { + // /a/.envrc + /a/b/.envrc, cwd=a/b -> [a/b] (the nearest only) + let sb = Sandbox::new(); + sb.write("a/.envrc", "export A_ENVRC=up\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.envrc", "export B_ENVRC=near\n"); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export B_ENVRC='near';"), "{s}"); + assert!( + !s.contains("A_ENVRC"), + "the upper .envrc is not the nearest, so it must not load: {s}" + ); +} + +#[test] +fn s3_cade_unions_nearest_envrc_below() { + // /a/.cade + /a/b/.envrc, cwd=a/b -> [a, a/b] + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.envrc", "export B_ENVRC=ok\n"); + sb.allow(&sb.dir("a")); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export A_CADE='1';"), "{s}"); + assert!(s.contains("export B_ENVRC='ok';"), "{s}"); +} + +#[test] +fn s4_cade_unions_nearest_envrc_above() { + // /a/.envrc + /a/b/.cade, cwd=a/b -> [a, a/b] + let sb = Sandbox::new(); + sb.write("a/.envrc", "export A_ENVRC=ok\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "B_CADE=1\n"); + sb.allow(&sb.dir("a")); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export A_ENVRC='ok';"), "{s}"); + assert!(s.contains("export B_CADE='1';"), "{s}"); +} + +#[test] +fn s5_only_nearest_envrc_enters_leaving_a_gap() { + // /a/.cade + /a/b/.envrc + /a/b/c/.envrc, cwd=a/b/c -> [a, a/b/c]; a/b dropped + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + sb.write("a/b/.envrc", "export B_ENVRC=mid\n"); + let c = sb.dir("a/b/c"); + sb.write("a/b/c/.envrc", "export C_ENVRC=near\n"); + sb.allow(&sb.dir("a")); + sb.allow(&c); + + let out = sb.enter(&c, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export A_CADE='1';"), "{s}"); + assert!(s.contains("export C_ENVRC='near';"), "{s}"); + assert!( + !s.contains("B_ENVRC"), + "a/b is not the nearest .envrc, so the gap dir must not load: {s}" + ); +} + +#[test] +fn s6_colocated_envrc_is_ignored() { + // /a has BOTH .cade and .envrc, cwd=a -> [a] via .cade; .envrc ignored + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + sb.write("a/.envrc", "export A_ENVRC=shouldnt\n"); + let a = sb.dir("a"); + sb.allow(&a); + + let out = sb.enter(&a, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export A_CADE='1';"), "{s}"); + assert!( + !s.contains("A_ENVRC"), + "a co-located .envrc must be ignored when .cade is present: {s}" + ); +} + +#[test] +fn disinherit_stops_the_parent_cascade() { + // child .cade disinherits -> its parent .cade never composes + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + sb.allow(&sb.dir("a")); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit must drop the parent .cade layer: {s}" + ); +} + +#[test] +fn disinherit_composes_with_nearest_envrc() { + // disinherit truncates the .cade cascade, but a nearer .envrc still joins + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + let c = sb.dir("a/b/c"); + sb.write("a/b/c/.envrc", "export C_ENVRC=ok\n"); + sb.allow(&sb.dir("a/b")); + sb.allow(&c); + + let out = sb.enter(&c, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!(s.contains("export C_ENVRC='ok';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit must drop the .cade above it: {s}" + ); +} + +#[test] +fn allow_gap_fill_respects_disinherit_root() { + // the disinherit dir is the chain root; gap-fill never reaches above it + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + let tip = sb.dir("a/b/tip"); + sb.write("a/b/tip/.cade", "TIP_CADE=1\n"); + + // approve the disinherit root as the base, then the tip + sb.allow(&b); + sb.allow(&tip); + + let out = sb.enter(&tip, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export TIP_CADE='1';"), "{s}"); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit caps the chain, so the parent must never compose: {s}" + ); +}