use chrono::{Local, NaiveDate, SecondsFormat, TimeZone};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};
use std::time::{SystemTime, UNIX_EPOCH};
use fusevm::Value;
pub const CONTENT_CAP: usize = 8192;
pub const MAX_OPS: usize = 256;
pub const MAX_AUTO_NAMES: usize = 4096;
const VOLATILE: &[&str] = &[
"_",
"?",
"!",
"$",
"#",
"COLUMNS",
"EPOCHREALTIME",
"EPOCHSECONDS",
"HISTCMD",
"LINENO",
"LINES",
"RANDOM",
"SECONDS",
"pipestatus",
"status",
];
const SUMMARY_MAX: usize = 64;
static PROV_ACTIVE: AtomicBool = AtomicBool::new(false);
static TRACK_ALL: AtomicBool = AtomicBool::new(false);
static CURRENT_LINE: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Site {
pub line: usize,
pub file: Option<String>,
pub func: Option<String>,
pub time_ms: i64,
}
impl Site {
fn now() -> Self {
let frame = current_function();
let (func, file, line) = match frame {
Some((name, file, flineno)) => (
Some(name),
file,
current_line() + flineno.max(0) as usize,
),
None => (
None,
crate::ported::utils::scriptfilename_get(),
current_line(),
),
};
Self {
line,
file: file.filter(|f| f != "zsh"),
func,
time_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
}
}
fn same_position(&self, other: &Self) -> bool {
self.line == other.line && self.file == other.file && self.func == other.func
}
pub fn location(&self) -> String {
let base = match &self.file {
Some(f) => format!("{}:{}", f, self.line),
None => format!("line {}", self.line),
};
match &self.func {
Some(fun) => format!("{} ({})", base, fun),
None => base,
}
}
pub fn clock(&self, with_date: bool) -> String {
let fmt = if with_date {
"%Y-%m-%d %H:%M:%S%.3f"
} else {
"%H:%M:%S%.3f"
};
match Local.timestamp_millis_opt(self.time_ms).single() {
Some(dt) => dt.format(fmt).to_string(),
None => String::new(),
}
}
pub fn rfc3339(&self) -> String {
match Local.timestamp_millis_opt(self.time_ms).single() {
Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, false),
None => String::new(),
}
}
fn day(&self) -> Option<NaiveDate> {
Local
.timestamp_millis_opt(self.time_ms)
.single()
.map(|dt| dt.date_naive())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvOp {
pub op: String,
pub args: Vec<String>,
pub site: Site,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvNode {
pub origin: String,
pub origin_site: Site,
pub ops: Vec<ProvOp>,
pub owner: Option<String>,
pub dropped_ops: usize,
}
impl ProvNode {
fn origin(origin: impl Into<String>, site: Site) -> Self {
Self {
origin: origin.into(),
origin_site: site,
ops: Vec::new(),
owner: None,
dropped_ops: 0,
}
}
fn push_op(&mut self, op: ProvOp) {
if self
.ops
.last()
.is_some_and(|l| l.op == op.op && l.args == op.args && l.site.same_position(&op.site))
{
return;
}
if self.ops.len() >= MAX_OPS {
self.dropped_ops += 1;
return;
}
self.ops.push(op);
}
}
enum ValueWeak {
Str(Weak<String>),
Array(Weak<Vec<Value>>),
}
impl ValueWeak {
fn still_at(&self, ptr: usize) -> bool {
match self {
ValueWeak::Str(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
ValueWeak::Array(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
}
}
}
struct PtrEntry {
weak: ValueWeak,
node: ProvNode,
}
#[derive(Default)]
struct Ledger {
ptr: HashMap<usize, PtrEntry>,
name: HashMap<String, ProvNode>,
tracked: HashSet<String>,
func: HashMap<String, ProvNode>,
tracked_funcs: HashSet<String>,
auto_dropped: usize,
content: HashMap<u64, ProvNode>,
content_order: VecDeque<u64>,
}
fn ledger() -> &'static Mutex<Ledger> {
static LEDGER: OnceLock<Mutex<Ledger>> = OnceLock::new();
LEDGER.get_or_init(|| Mutex::new(Ledger::default()))
}
fn lock() -> MutexGuard<'static, Ledger> {
match ledger().lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
pub fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
if std::env::var("ZSHRS_PROVENANCE").is_ok_and(|v| v == "0") {
return false;
}
crate::config::current().provenance.enabled
})
}
fn track_all_configured() -> bool {
static CONFIGURED: OnceLock<bool> = OnceLock::new();
*CONFIGURED.get_or_init(|| match std::env::var("ZSHRS_PROVENANCE_ALL") {
Ok(v) if v == "1" => true,
Ok(v) if v == "0" => false,
_ => crate::config::current().provenance.track_all,
})
}
pub fn init_from_config() {
if enabled() && track_all_configured() {
set_track_all(true);
}
}
#[inline]
pub fn track_all() -> bool {
TRACK_ALL.load(Ordering::Relaxed)
}
pub fn set_track_all(on: bool) -> bool {
if on && !enabled() {
return false;
}
TRACK_ALL.store(on, Ordering::Relaxed);
if on {
PROV_ACTIVE.store(true, Ordering::Relaxed);
} else {
let l = lock();
let empty = l.tracked.is_empty()
&& l.name.is_empty()
&& l.tracked_funcs.is_empty()
&& l.func.is_empty();
drop(l);
if empty {
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
}
true
}
fn auto_armable(name: &str) -> bool {
!name.is_empty()
&& !VOLATILE.contains(&name)
&& !name.chars().all(|c| c.is_ascii_digit())
}
#[inline]
pub fn active() -> bool {
PROV_ACTIVE.load(Ordering::Relaxed)
}
#[inline]
pub fn note_line(line: usize) {
CURRENT_LINE.store(line, Ordering::Relaxed);
}
pub fn current_line() -> usize {
CURRENT_LINE.load(Ordering::Relaxed)
}
fn current_function() -> Option<(String, Option<String>, i64)> {
let stack = crate::ported::modules::parameter::FUNCSTACK.try_lock().ok()?;
let frame = stack.last()?;
(frame.tp == crate::ported::zsh_h::FS_FUNC)
.then(|| (frame.name.clone(), frame.filename.clone(), frame.flineno))
}
pub fn summarize_str(s: &str) -> String {
let mut out = String::with_capacity(SUMMARY_MAX + 12);
out.push('"');
for ch in s.chars().take(SUMMARY_MAX) {
match ch {
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c => out.push(c),
}
}
if s.chars().count() > SUMMARY_MAX {
out.push('…');
}
out.push('"');
out
}
pub fn summarize_value(v: &Value) -> String {
match v {
Value::Str(s) => summarize_str(s),
Value::Array(a) => format!("ARRAY len={}", a.len()),
Value::Hash(h) => format!("ASSOC entries={}", h.len()),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Status(c) => format!("STATUS {}", c),
Value::Undef => "unset".to_string(),
other => format!("{:?}", other),
}
}
fn content_key(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
fn value_ptr(v: &Value) -> Option<usize> {
match v {
Value::Str(s) => Some(Arc::as_ptr(s) as usize),
Value::Array(a) => Some(Arc::as_ptr(a) as usize),
_ => None,
}
}
fn value_weak(v: &Value) -> Option<ValueWeak> {
match v {
Value::Str(s) => Some(ValueWeak::Str(Arc::downgrade(s))),
Value::Array(a) => Some(ValueWeak::Array(Arc::downgrade(a))),
_ => None,
}
}
impl Ledger {
fn ptr_node(&mut self, ptr: usize) -> Option<ProvNode> {
let live = match self.ptr.get(&ptr) {
Some(e) => e.weak.still_at(ptr),
None => return None,
};
if !live {
self.ptr.remove(&ptr);
return None;
}
self.ptr.get(&ptr).map(|e| e.node.clone())
}
fn put_ptr(&mut self, v: &Value, node: ProvNode) {
let (Some(ptr), Some(weak)) = (value_ptr(v), value_weak(v)) else {
return;
};
self.ptr.insert(ptr, PtrEntry { weak, node });
}
fn put_content(&mut self, s: &str, node: ProvNode) {
if s.is_empty() {
return;
}
let key = content_key(s);
if self.content.insert(key, node).is_none() {
self.content_order.push_back(key);
while self.content_order.len() > CONTENT_CAP {
if let Some(old) = self.content_order.pop_front() {
self.content.remove(&old);
}
}
}
}
fn content_node(&self, s: &str) -> Option<ProvNode> {
if s.is_empty() {
return None;
}
self.content.get(&content_key(s)).cloned()
}
fn extend_owner(&mut self, node: &ProvNode, op: &ProvOp) {
let Some(owner) = node.owner.as_deref() else {
return;
};
if let Some(target) = self.name.get_mut(owner) {
target.push_op(op.clone());
}
}
}
pub fn track_name(name: &str, current_value: Option<&str>) -> bool {
if !enabled() {
return false;
}
let site = Site::now();
let mut l = lock();
l.tracked.insert(name.to_string());
if let Some(v) = current_value {
let mut node = ProvNode::origin(format!("param {} = {}", name, summarize_str(v)), site);
node.owner = Some(name.to_string());
l.name.insert(name.to_string(), node);
}
drop(l);
PROV_ACTIVE.store(true, Ordering::Relaxed);
true
}
pub fn untrack_name(name: &str) -> bool {
let mut l = lock();
let had = l.tracked.remove(name) | l.name.remove(name).is_some();
let empty = l.tracked.is_empty()
&& l.name.is_empty()
&& l.tracked_funcs.is_empty()
&& l.func.is_empty();
drop(l);
if empty && !track_all() {
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
had
}
pub fn clear() {
let mut l = lock();
*l = Ledger::default();
drop(l);
TRACK_ALL.store(false, Ordering::Relaxed);
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
pub fn tracked_names() -> Vec<String> {
let l = lock();
let mut v: Vec<String> = l.tracked.iter().cloned().collect();
v.sort();
v
}
pub fn lookup_name(name: &str) -> Option<ProvNode> {
lock().name.get(name).cloned()
}
pub fn lookup_value(v: &Value) -> Option<ProvNode> {
let ptr = value_ptr(v)?;
lock().ptr_node(ptr)
}
pub fn lookup_content(s: &str) -> Option<ProvNode> {
lock().content_node(s)
}
pub fn on_cmd_subst(source: &str, out: &str) {
let site = Site::now();
let origin = if source.is_empty() {
"cmdsubst".to_string()
} else {
format!("cmdsubst {}", summarize_str(source))
};
lock().put_content(out, ProvNode::origin(origin, site));
}
pub fn on_process_subst(source: &str, out: &str) {
let site = Site::now();
let origin = if source.is_empty() {
"procsubst".to_string()
} else {
format!("procsubst {}", summarize_str(source))
};
lock().put_content(out, ProvNode::origin(origin, site));
}
pub fn on_glob(pattern: &str, results: &[String]) {
if results.is_empty() || results.len() > 32 {
return;
}
let site = Site::now();
let mut l = lock();
for r in results {
l.put_content(
r,
ProvNode::origin(format!("glob {}", summarize_str(pattern)), site.clone()),
);
}
}
pub fn on_heredoc(kind: &str, body: &str) {
let site = Site::now();
lock().put_content(body, ProvNode::origin(kind.to_string(), site));
}
pub fn on_param_read(name: &str, value: &Value) {
let site = Site::now();
let mut l = lock();
let Some(mut node) = l.name.get(name).cloned() else {
return;
};
let op = ProvOp {
op: "expand".to_string(),
args: vec![format!("${}", name), summarize_value(value)],
site,
};
if let Some(target) = l.name.get_mut(name) {
target.push_op(op.clone());
}
node.push_op(op);
node.owner = Some(name.to_string());
l.put_ptr(value, node.clone());
if let Value::Str(s) = value {
l.put_content(s, node);
}
}
pub fn on_concat(lhs: &Value, rhs: &Value, result: &Value) {
let site = Site::now();
let mut l = lock();
let mut chosen: Option<ProvNode> = None;
for operand in [lhs, rhs] {
let found = value_ptr(operand)
.and_then(|p| l.ptr_node(p))
.or_else(|| match operand {
Value::Str(s) => l.content_node(s),
_ => None,
});
if let Some(node) = found {
if chosen.as_ref().is_none_or(|c| node.ops.len() > c.ops.len()) {
chosen = Some(node);
}
}
}
let Some(mut node) = chosen else {
return;
};
let is_noop = matches!(lhs, Value::Str(s) if s.is_empty())
|| matches!(rhs, Value::Str(s) if s.is_empty());
if !is_noop {
let op = ProvOp {
op: "concat".to_string(),
args: vec![summarize_value(lhs), summarize_value(rhs)],
site,
};
l.extend_owner(&node, &op);
node.push_op(op);
}
l.put_ptr(result, node.clone());
if let Value::Str(s) = result {
l.put_content(s, node);
}
}
pub fn on_param_write(name: &str, kind: &str, value: &str) {
let site = Site::now();
let mut l = lock();
if !l.tracked.contains(name) {
if !track_all() || !auto_armable(name) {
return;
}
if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
l.auto_dropped += 1;
return;
}
l.tracked.insert(name.to_string());
}
let inherited = l
.content_node(value)
.filter(|n| n.owner.as_deref() != Some(name));
let mut node = match l.name.remove(name) {
Some(mut existing) => {
if let Some(inh) = inherited {
existing.push_op(ProvOp {
op: "origin".to_string(),
args: vec![inh.origin],
site: inh.origin_site,
});
for op in inh.ops {
existing.push_op(op);
}
}
existing
}
None => inherited.unwrap_or_else(|| {
ProvNode::origin(format!("{} {}", kind, summarize_str(value)), site.clone())
}),
};
node.owner = Some(name.to_string());
node.push_op(ProvOp {
op: kind.to_string(),
args: vec![name.to_string(), summarize_str(value)],
site,
});
l.name.insert(name.to_string(), node.clone());
l.put_content(value, node);
}
pub fn on_param_unset(name: &str) {
let site = Site::now();
let mut l = lock();
if let Some(node) = l.name.get_mut(name) {
node.push_op(ProvOp {
op: "unset".to_string(),
args: vec![name.to_string()],
site,
});
}
}
pub fn on_exec(kind: &str, args: &[String]) {
if args.is_empty() {
return;
}
let site = Site::now();
let cmd = args[0].clone();
let mut l = lock();
for (i, a) in args.iter().enumerate().skip(1) {
let Some(mut node) = l.content_node(a) else {
continue;
};
let op = ProvOp {
op: kind.to_string(),
args: vec![cmd.clone(), format!("argv[{}]", i)],
site: site.clone(),
};
l.extend_owner(&node, &op);
node.push_op(op);
l.put_content(a, node);
}
}
fn def_site(file: Option<&str>, line: i64) -> Site {
let mut site = Site::now();
if file.is_some() || line > 0 {
site.file = file.map(str::to_string).filter(|f| f != "zsh");
site.line = line.max(0) as usize;
site.func = None;
}
site
}
pub fn track_func(name: &str, file: Option<&str>, line: i64) -> bool {
if !enabled() {
return false;
}
let mut l = lock();
l.tracked_funcs.insert(name.to_string());
if file.is_some() || line > 0 {
let site = def_site(file, line);
l.func
.entry(name.to_string())
.or_insert_with(|| ProvNode::origin(format!("function {}", name), site));
}
drop(l);
PROV_ACTIVE.store(true, Ordering::Relaxed);
true
}
pub fn untrack_func(name: &str) -> bool {
let mut l = lock();
let had = l.tracked_funcs.remove(name) | l.func.remove(name).is_some();
let empty = l.tracked.is_empty()
&& l.name.is_empty()
&& l.tracked_funcs.is_empty()
&& l.func.is_empty();
drop(l);
if empty && !track_all() {
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
had
}
pub fn lookup_func(name: &str) -> Option<ProvNode> {
lock().func.get(name).cloned()
}
pub fn tracked_func_names() -> Vec<String> {
let l = lock();
let mut v: Vec<String> = l.tracked_funcs.iter().cloned().collect();
v.sort();
v
}
pub fn auto_dropped() -> usize {
lock().auto_dropped
}
fn auto_arm_func(l: &mut Ledger, name: &str) -> bool {
if l.tracked_funcs.contains(name) {
return true;
}
if !track_all() || !auto_armable(name) {
return false;
}
if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
l.auto_dropped += 1;
return false;
}
l.tracked_funcs.insert(name.to_string());
true
}
pub fn on_func_define(name: &str, file: Option<&str>, line: i64) {
let site = def_site(file, line);
let mut l = lock();
if !auto_arm_func(&mut l, name) {
return;
}
match l.func.get_mut(name) {
Some(node) => node.push_op(ProvOp {
op: "redefine".to_string(),
args: vec![name.to_string()],
site,
}),
None => {
l.func.insert(
name.to_string(),
ProvNode::origin(format!("function {}", name), site),
);
}
}
}
pub fn on_func_call(name: &str, file: Option<&str>, line: i64) {
let site = Site::now();
let mut l = lock();
if !auto_arm_func(&mut l, name) {
return;
}
let node = l
.func
.entry(name.to_string())
.or_insert_with(|| ProvNode::origin(format!("function {}", name), def_site(file, line)));
node.push_op(ProvOp {
op: "call".to_string(),
args: vec![format!("{}()", name)],
site,
});
}
pub fn on_func_unset(name: &str) {
let site = Site::now();
let mut l = lock();
if let Some(node) = l.func.get_mut(name) {
node.push_op(ProvOp {
op: "unfunction".to_string(),
args: vec![name.to_string()],
site,
});
}
}
pub fn render(label: &str, node: &ProvNode) -> String {
let mut out = format!("{}\n", label);
out.push_str(&format!(
" origin: {} ({}, {})\n",
node.origin,
node.origin_site.location(),
node.origin_site.clock(true)
));
if node.ops.is_empty() {
out.push_str(" ops: (none)\n");
return out;
}
let origin_day = node.origin_site.day();
out.push_str(" ops:\n");
for (i, op) in node.ops.iter().enumerate() {
out.push_str(&format!(
" {:>2}. {:<10} {:<40} {:<24} {}\n",
i + 1,
op.op,
op.args.join(" "),
op.site.location(),
op.site.clock(op.site.day() != origin_day)
));
}
if node.dropped_ops > 0 {
out.push_str(&format!(
" … {} more ops (chain capped at {})\n",
node.dropped_ops, MAX_OPS
));
}
out
}
fn json_str_or_null(s: Option<&str>) -> String {
match s {
Some(v) => format!("{:?}", v),
None => "null".to_string(),
}
}
pub fn render_json(label: &str, node: &ProvNode) -> String {
let mut out = String::new();
out.push_str(&format!(
"{{\"name\":{:?},\"origin\":{:?},\"origin_line\":{},\"origin_file\":{},\"origin_function\":{},\"origin_time\":{:?},\"ops\":[",
label,
node.origin,
node.origin_site.line,
json_str_or_null(node.origin_site.file.as_deref()),
json_str_or_null(node.origin_site.func.as_deref()),
node.origin_site.rfc3339(),
));
for (i, op) in node.ops.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("{{\"op\":{:?},\"args\":[", op.op));
for (j, a) in op.args.iter().enumerate() {
if j > 0 {
out.push(',');
}
out.push_str(&format!("{:?}", a));
}
out.push_str(&format!(
"],\"line\":{},\"file\":{},\"function\":{},\"time\":{:?}}}",
op.site.line,
json_str_or_null(op.site.file.as_deref()),
json_str_or_null(op.site.func.as_deref()),
op.site.rfc3339(),
));
}
out.push_str(&format!("],\"dropped_ops\":{}}}", node.dropped_ops));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> std::sync::MutexGuard<'static, ()> {
let g = crate::test_util::global_state_lock();
clear();
PROV_ACTIVE.store(true, Ordering::Relaxed);
g
}
#[test]
fn tracked_name_seeds_an_origin_from_the_current_value() {
let _g = setup();
note_line(7);
assert!(track_name("FOO", Some("bar")));
let node = lookup_name("FOO").expect("tracked name has a node");
assert_eq!(node.origin_site.line, 7);
assert!(node.origin.contains("param FOO"), "origin = {}", node.origin);
assert!(node.ops.is_empty(), "no ops at the origin");
assert_eq!(tracked_names(), vec!["FOO".to_string()]);
}
#[test]
fn assignment_inherits_the_command_substitutions_lineage() {
let _g = setup();
note_line(3);
track_name("OUT", None);
on_cmd_subst("date +%s", "1750000000");
on_param_write("OUT", "assign", "1750000000");
let node = lookup_name("OUT").expect("assignment created a node");
assert!(
node.origin.starts_with("cmdsubst"),
"origin must come from the substitution, got {}",
node.origin
);
assert_eq!(node.ops.len(), 1);
assert_eq!(node.ops[0].op, "assign");
}
#[test]
fn expansion_then_exec_extends_the_owning_parameters_chain() {
let _g = setup();
note_line(1);
track_name("F", Some("report.txt"));
on_param_write("F", "assign", "report.txt");
let v = Value::str("report.txt");
on_param_read("F", &v);
on_exec("exec", &["wc".into(), "-l".into(), "report.txt".into()]);
let node = lookup_name("F").expect("F is tracked");
let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
assert!(
ops.contains(&"exec"),
"consumption must land on the owner's chain, got {:?}",
ops
);
let exec_op = node.ops.iter().find(|o| o.op == "exec").unwrap();
assert_eq!(exec_op.args[0], "wc");
assert_eq!(exec_op.args[1], "argv[2]");
}
#[test]
fn concat_carries_the_lineage_of_whichever_operand_has_one() {
let _g = setup();
note_line(4);
track_name("F", Some("alpha"));
on_param_write("F", "assign", "alpha");
let read = Value::str("alpha");
on_param_read("F", &read);
let joined = Value::str("alpha.bak");
on_concat(&read, &Value::str(".bak"), &joined);
let node = lookup_value(&joined).expect("result inherited the chain");
assert_eq!(node.owner.as_deref(), Some("F"));
assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("concat"));
let owner = lookup_name("F").unwrap();
assert!(owner.ops.iter().any(|o| o.op == "concat"), "{owner:?}");
}
#[test]
fn an_empty_segment_concat_propagates_without_recording_an_op() {
let _g = setup();
track_name("F", Some("alpha"));
on_param_write("F", "assign", "alpha");
let read = Value::str("alpha");
on_param_read("F", &read);
let before = lookup_name("F").unwrap().ops.len();
let same = Value::str("alpha");
on_concat(&Value::str(""), &read, &same);
assert_eq!(
lookup_name("F").unwrap().ops.len(),
before,
"a concat that adds no bytes must not appear in the chain"
);
assert!(
lookup_value(&same).is_some(),
"the lineage still has to reach the produced value"
);
}
#[test]
fn concat_of_two_untracked_values_records_nothing() {
let _g = setup();
let out = Value::str("ab");
on_concat(&Value::str("a"), &Value::str("b"), &out);
assert!(lookup_value(&out).is_none());
}
#[test]
fn untracked_names_never_gain_a_lineage() {
let _g = setup();
on_cmd_subst("ls", "a\nb");
on_param_write("UNTRACKED", "assign", "a\nb");
assert!(
lookup_name("UNTRACKED").is_none(),
"writes to untracked names must not create rows"
);
}
#[test]
fn value_lineage_survives_arc_clones_and_dies_with_the_value() {
let _g = setup();
track_name("V", Some("x"));
let v = Value::str("payload");
on_param_read("V", &v);
let clone = v.clone();
assert!(
lookup_value(&clone).is_some(),
"an Arc clone is the same value"
);
let ptr = value_ptr(&v).unwrap();
drop(v);
drop(clone);
let mut l = lock();
assert!(l.ptr_node(ptr).is_none(), "dropped value must reap its row");
assert!(!l.ptr.contains_key(&ptr), "reaped row must be removed");
}
#[test]
fn content_entries_are_bounded_by_the_fifo_cap() {
let _g = setup();
for i in 0..(CONTENT_CAP + 100) {
on_cmd_subst("gen", &format!("value-{}", i));
}
let l = lock();
assert_eq!(l.content.len(), CONTENT_CAP);
assert_eq!(l.content_order.len(), CONTENT_CAP);
drop(l);
assert!(
lookup_content("value-0").is_none(),
"oldest speculative entry must have been evicted"
);
assert!(
lookup_content(&format!("value-{}", CONTENT_CAP + 99)).is_some(),
"newest speculative entry must survive"
);
}
#[test]
fn high_fanout_globs_are_not_recorded() {
let _g = setup();
let many: Vec<String> = (0..64).map(|i| format!("f{}", i)).collect();
on_glob("*", &many);
assert!(
lookup_content("f0").is_none(),
"a 64-match glob must not evict the ledger"
);
on_glob("*.rs", &["lib.rs".to_string()]);
assert!(lookup_content("lib.rs").is_some(), "small globs record");
}
#[test]
fn untrack_and_clear_disarm_the_engine() {
let _g = setup();
track_name("A", Some("1"));
track_name("B", Some("2"));
assert!(untrack_name("A"));
assert!(active(), "still armed while B is tracked");
assert!(untrack_name("B"));
assert!(!active(), "last untrack disarms the hot gate");
assert!(!untrack_name("B"), "second untrack is a no-op");
track_name("C", None);
clear();
assert!(!active());
assert!(tracked_names().is_empty());
}
#[test]
fn reassignment_extends_the_chain_instead_of_replacing_it() {
let _g = setup();
note_line(1);
track_name("X", None);
note_line(2);
on_param_write("X", "assign", "23");
note_line(3);
on_param_write("X", "assign", "1");
note_line(4);
on_param_write("X", "assign", "55");
let node = lookup_name("X").expect("X has a chain");
assert_eq!(node.origin_site.line, 2, "origin is the first write");
let seen: Vec<(&str, &str, usize)> = node
.ops
.iter()
.map(|o| (o.op.as_str(), o.args[1].as_str(), o.site.line))
.collect();
assert_eq!(
seen,
vec![
("assign", "\"23\"", 2),
("assign", "\"1\"", 3),
("assign", "\"55\"", 4),
],
"every assignment stays on the chain: {:?}",
node.ops
);
}
#[test]
fn a_later_substitution_is_spliced_in_not_swapped_for_the_chain() {
let _g = setup();
note_line(1);
track_name("X", Some("seed"));
note_line(2);
on_param_write("X", "assign", "23");
note_line(3);
on_cmd_subst("date +%s", "1750000000");
on_param_write("X", "assign", "1750000000");
let node = lookup_name("X").expect("X has a chain");
assert!(
node.origin.contains("param X = "),
"the armed value stays the origin, got {}",
node.origin
);
let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
assert_eq!(ops, vec!["assign", "origin", "assign"], "{:?}", node.ops);
assert!(
node.ops[1].args[0].starts_with("cmdsubst"),
"the substitution is recorded where it happened: {:?}",
node.ops[1]
);
}
#[test]
fn rewriting_a_parameter_with_its_own_value_does_not_duplicate_the_chain() {
let _g = setup();
track_name("X", None);
on_param_write("X", "assign", "same");
on_param_write("X", "assign", "same");
let node = lookup_name("X").unwrap();
assert_eq!(node.ops.len(), 1, "immediate repeat collapses: {:?}", node.ops);
note_line(9);
on_param_write("X", "assign", "same");
let node = lookup_name("X").unwrap();
assert_eq!(
node.ops.iter().filter(|o| o.op == "origin").count(),
0,
"a parameter never inherits from itself: {:?}",
node.ops
);
}
#[test]
fn a_repeat_at_the_same_position_collapses_despite_a_later_clock() {
let _g = setup();
note_line(4);
track_name("Q", None);
on_param_write("Q", "assign", "v");
let first = lookup_name("Q").unwrap().ops[0].site.time_ms;
std::thread::sleep(std::time::Duration::from_millis(5));
on_param_write("Q", "assign", "v");
let node = lookup_name("Q").unwrap();
assert_eq!(node.ops.len(), 1, "the clock must not defeat the collapse");
assert_eq!(node.ops[0].site.time_ms, first, "the first stamp stands");
}
#[test]
fn a_site_carries_the_file_the_line_belongs_to() {
let _g = setup();
let saved = crate::ported::utils::scriptfilename_get();
crate::ported::utils::set_scriptfilename(Some("/tmp/lineage.zsh".to_string()));
note_line(11);
track_name("S", None);
on_param_write("S", "assign", "v");
crate::ported::utils::set_scriptfilename(saved);
let node = lookup_name("S").unwrap();
assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lineage.zsh"));
assert_eq!(node.origin_site.line, 11);
assert_eq!(node.origin_site.func, None, "no function frame is active");
assert!(node.origin_site.time_ms > 0, "the origin is stamped");
assert_eq!(node.origin_site.location(), "/tmp/lineage.zsh:11");
assert!(
node.origin_site.clock(true).starts_with("20"),
"clock = {}",
node.origin_site.clock(true)
);
assert!(
render("S", &node).contains("/tmp/lineage.zsh:11"),
"the report names the file"
);
}
#[test]
fn track_all_arms_a_parameter_on_its_first_write() {
let _g = setup();
assert!(set_track_all(true));
note_line(3);
on_param_write("NEVER_ARMED", "assign", "v");
let node = lookup_name("NEVER_ARMED").expect("track_all armed it");
assert_eq!(node.ops.len(), 1);
assert_eq!(tracked_names(), vec!["NEVER_ARMED".to_string()]);
set_track_all(false);
on_param_write("STILL_UNARMED", "assign", "v");
assert!(
lookup_name("STILL_UNARMED").is_none(),
"turning it off stops arming new names"
);
}
#[test]
fn track_all_skips_the_parameters_the_shell_rewrites_itself() {
let _g = setup();
assert!(set_track_all(true));
for volatile in ["LINENO", "RANDOM", "status", "_", "3"] {
on_param_write(volatile, "assign", "v");
assert!(
lookup_name(volatile).is_none(),
"{volatile} must not arm itself"
);
}
on_param_write("REPLY", "assign", "v");
assert!(lookup_name("REPLY").is_some(), "ordinary names still arm");
}
#[test]
fn track_all_stops_arming_at_the_ceiling_and_counts_the_rest() {
let _g = setup();
assert!(set_track_all(true));
for i in 0..MAX_AUTO_NAMES + 8 {
on_param_write(&format!("P{}", i), "assign", "v");
}
assert_eq!(tracked_names().len(), MAX_AUTO_NAMES);
assert_eq!(auto_dropped(), 8, "the overflow is counted, not stored");
}
#[test]
fn a_function_records_its_definition_calls_and_removal() {
let _g = setup();
assert!(set_track_all(true));
on_func_define("build", Some("/tmp/lib.zsh"), 12);
note_line(40);
on_func_call("build", Some("/tmp/lib.zsh"), 12);
on_func_define("build", Some("/tmp/lib.zsh"), 80);
on_func_unset("build");
let node = lookup_func("build").expect("the function has a chain");
assert_eq!(node.origin, "function build");
assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
assert_eq!(node.origin_site.line, 12, "origin is the first definition");
let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
assert_eq!(ops, vec!["call", "redefine", "unfunction"], "{:?}", node.ops);
assert_eq!(node.ops[0].site.line, 40, "the call op is the caller's site");
assert_eq!(node.ops[1].site.line, 80, "the redefine op is the new body's");
assert_eq!(tracked_func_names(), vec!["build".to_string()]);
assert!(lookup_name("build").is_none(), "the parameter namespace is separate");
}
#[test]
fn arming_a_function_before_it_exists_leaves_the_origin_to_its_definition() {
let _g = setup();
assert!(track_func("later", None, 0));
assert!(
lookup_func("later").is_none(),
"no definition has happened yet, so there is nothing to attribute"
);
assert_eq!(
tracked_func_names(),
vec!["later".to_string()],
"but it IS armed"
);
on_func_define("later", Some("/tmp/lib.zsh"), 7);
let node = lookup_func("later").expect("the definition creates the chain");
assert_eq!(node.origin, "function later");
assert_eq!(
node.origin_site.line, 7,
"the origin is where it was defined"
);
assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
assert!(
node.ops.is_empty(),
"the first definition is the origin, not a redefine: {:?}",
node.ops
);
on_func_define("later", Some("/tmp/lib.zsh"), 20);
let node = lookup_func("later").expect("still tracked");
assert_eq!(node.origin_site.line, 7, "the origin does not move");
let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
assert_eq!(ops, vec!["redefine"], "{:?}", node.ops);
assert_eq!(node.ops[0].site.line, 20);
}
#[test]
fn arming_an_existing_function_seeds_the_origin_from_its_definition_site() {
let _g = setup();
assert!(track_func("known", Some("/tmp/lib.zsh"), 3));
let node = lookup_func("known").expect("armed with a known definition site");
assert_eq!(node.origin_site.line, 3);
assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
assert!(node.ops.is_empty());
assert!(track_func("known", Some("/tmp/lib.zsh"), 3));
let node = lookup_func("known").expect("still there");
assert_eq!(node.origin_site.line, 3);
assert_eq!(tracked_func_names(), vec!["known".to_string()]);
}
#[test]
fn an_unarmed_function_records_nothing_without_track_all() {
let _g = setup();
on_func_define("quiet", Some("/tmp/lib.zsh"), 1);
on_func_call("quiet", Some("/tmp/lib.zsh"), 1);
assert!(lookup_func("quiet").is_none());
assert!(track_func("quiet", Some("/tmp/lib.zsh"), 1));
on_func_call("quiet", Some("/tmp/lib.zsh"), 1);
let node = lookup_func("quiet").expect("armed by name");
assert_eq!(node.ops.len(), 1, "only the call after arming: {:?}", node.ops);
assert!(untrack_func("quiet"));
assert!(!active(), "the last untrack disarms the hot gate");
}
#[test]
fn unset_is_recorded_as_the_final_op() {
let _g = setup();
track_name("Z", Some("v"));
on_param_unset("Z");
let node = lookup_name("Z").unwrap();
assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("unset"));
}
#[test]
fn render_and_json_carry_the_whole_chain() {
let _g = setup();
note_line(12);
track_name("R", None);
on_cmd_subst("echo hi", "hi");
on_param_write("R", "assign", "hi");
let node = lookup_name("R").unwrap();
let text = render("R", &node);
assert!(text.contains("origin: cmdsubst"), "text = {}", text);
assert!(text.contains("line 12"), "text = {}", text);
let json = render_json("R", &node);
assert!(json.starts_with("{\"name\":\"R\""), "json = {}", json);
assert!(json.contains("\"op\":\"assign\""), "json = {}", json);
assert!(json.ends_with("\"dropped_ops\":0}"), "json = {}", json);
}
#[test]
fn concurrent_hooks_keep_the_ledger_coherent() {
let _g = setup();
note_line(1);
track_name("SHARED", Some("seed"));
let handles: Vec<_> = (0..8)
.map(|i| {
std::thread::spawn(move || {
for n in 0..50 {
let out = format!("t{}-{}", i, n);
on_cmd_subst("worker", &out);
let v = Value::str(out.clone());
on_param_read("SHARED", &v);
on_exec("exec", &["cmd".into(), out]);
assert!(lookup_name("SHARED").is_some());
}
})
})
.collect();
for h in handles {
h.join().expect("thread");
}
let node = lookup_name("SHARED").expect("origin still live");
assert_eq!(node.origin_site.line, 1);
assert_eq!(node.owner.as_deref(), Some("SHARED"));
}
}