Skip to main content

kaish_kernel/interpreter/
scope.rs

1//! Variable scope management for kaish.
2//!
3//! Scopes provide variable bindings with:
4//! - Nested scope frames (push/pop for loops, tool calls)
5//! - The special `$?` variable holding the last command's exit code
6//! - Path resolution for nested access (`${VAR.field[0]}`)
7
8use std::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use kaish_types::{json_to_value_no_envelope, value_to_json};
13
14use crate::ast::{Value, VarPath, VarSegment};
15
16use super::eval::value_to_string;
17use super::result::ExecResult;
18
19/// Why a variable path failed to resolve.
20///
21/// The three-way split is load-bearing for `${path:-default}` (decision A): the
22/// default fires on **absence** but never on a **shape** error — a wrong-typed
23/// access is a bug, not a missing value. All three are loud for a bare access;
24/// they diverge only when `:-` is present (see the default handling), where
25/// `UndefinedRoot`/`Absence` yield the default and `Shape` still shouts.
26///
27/// - `UndefinedRoot` — the root variable (or an unset dynamic `$k` subscript) is
28///   not in scope. Soft in string interpolation (expands to empty, matching
29///   bash), loud in expression position.
30/// - `Absence` — the path is well-shaped but the target isn't there: a missing
31///   record key or an out-of-bounds list index.
32/// - `Shape` — the access is wrong for the value: a string key on a list, an
33///   integer index on a record, subscripting a scalar, a dotted (non-bracket)
34///   segment, or slicing a record. Never suppressed by `:-`.
35///
36/// A loud error is surfaced everywhere, including inside strings; it is NEVER
37/// silently swallowed to an empty expansion.
38#[derive(Debug, Clone, PartialEq)]
39pub enum PathError {
40    /// The root variable is not in scope (or an unset dynamic `$k` subscript).
41    UndefinedRoot(String),
42    /// A missing key or out-of-bounds index — absence, not misuse.
43    Absence(String),
44    /// A wrong-for-the-shape access. Message ready to display.
45    Shape(String),
46}
47
48/// A human-readable type name for a value, for path error messages.
49fn type_name(value: &Value) -> &'static str {
50    match value {
51        Value::Null => "null",
52        Value::Bool(_) => "a boolean",
53        Value::Int(_) => "an integer",
54        Value::Float(_) => "a float",
55        Value::String(_) => "a string",
56        Value::Json(serde_json::Value::Array(_)) => "a list",
57        Value::Json(serde_json::Value::Object(_)) => "a record",
58        Value::Json(_) => "a scalar",
59        Value::Bytes(_) => "binary data",
60    }
61}
62
63/// A dynamic subscript value usable as a list index: an integer, or a string
64/// that parses as one (`k=1; ${xs[$k]}`).
65fn value_as_index(value: &Value) -> Option<i64> {
66    match value {
67        Value::Int(i) => Some(*i),
68        Value::String(s) => s.parse::<i64>().ok(),
69        _ => None,
70    }
71}
72
73/// A concrete, container-resolved subscript. [`resolve_step`] produces one per
74/// hop *after* seeing the container — negative indices normalized, bounds
75/// checked, dynamic keys looked up — so read traversal and (next phase) lvalue
76/// writes share one classification and can never drift. Record-key *presence*
77/// is deliberately NOT decided here: that is per-hop walk policy (a read errors
78/// on a missing key; a write leaf inserts it).
79#[derive(Debug, Clone, PartialEq)]
80enum Step {
81    /// A validated, in-bounds list index.
82    Index(usize),
83    /// A record key (existence unchecked — see the type doc).
84    Key(String),
85    /// A normalized, end-exclusive slice range (`s <= e <= len`).
86    Slice(usize, usize),
87}
88
89/// Classify a list index against an array. Negative indices count from the end;
90/// out of bounds is a loud error, and an integer subscript on a record is an
91/// error (keys are strings — the design's "integers index lists" rule). The
92/// container is a collection by [`resolve_step`]'s guard, so the scalar arm is
93/// unreachable.
94fn classify_index(json: &serde_json::Value, i: i64, path: &str) -> Result<Step, PathError> {
95    let arr = match json {
96        serde_json::Value::Array(a) => a,
97        serde_json::Value::Object(_) => {
98            return Err(PathError::Shape(format!(
99                "${{{path}[{i}]}}: integer index on a record — record keys are strings, use ${{{path}[\"{i}\"]}}"
100            )))
101        }
102        _ => unreachable!("resolve_step guards non-collection containers"),
103    };
104    let len = arr.len() as i64;
105    let idx = if i < 0 { len + i } else { i };
106    if idx < 0 || idx >= len {
107        return Err(PathError::Absence(format!(
108            "${{{path}[{i}]}}: index out of bounds (list length {len})"
109        )));
110    }
111    Ok(Step::Index(idx as usize))
112}
113
114/// Classify a record key against an object. A bareword/string key on a list is
115/// an error; key *presence* is checked when the step is applied ([`descend`]),
116/// not here — the read/write split lives in that leaf policy.
117fn classify_key(json: &serde_json::Value, key: &str, path: &str) -> Result<Step, PathError> {
118    match json {
119        serde_json::Value::Object(_) => Ok(Step::Key(key.to_string())),
120        serde_json::Value::Array(_) => Err(PathError::Shape(format!(
121            "${{{path}[{key}]}}: string key on a list — use an integer index"
122        ))),
123        _ => unreachable!("resolve_step guards non-collection containers"),
124    }
125}
126
127/// Classify a slice against an array, end-exclusive. Bounds clamp; negatives
128/// count from the end; an inverted or empty range yields an empty range.
129/// Slicing a record is an error.
130fn classify_slice(
131    json: &serde_json::Value,
132    start: Option<i64>,
133    end: Option<i64>,
134    path: &str,
135) -> Result<Step, PathError> {
136    let arr = match json {
137        serde_json::Value::Array(a) => a,
138        serde_json::Value::Object(_) => {
139            return Err(PathError::Shape(format!(
140                "${{{path}[..]}}: cannot slice a record"
141            )))
142        }
143        _ => unreachable!("resolve_step guards non-collection containers"),
144    };
145    let len = arr.len() as i64;
146    let norm = |b: i64| -> i64 {
147        let b = if b < 0 { len + b } else { b };
148        b.clamp(0, len)
149    };
150    let s = start.map(norm).unwrap_or(0);
151    let e = end.map(norm).unwrap_or(len);
152    let (s, e) = if s >= e {
153        (s as usize, s as usize)
154    } else {
155        (s as usize, e as usize)
156    };
157    Ok(Step::Slice(s, e))
158}
159
160/// A dotted `.field` access. Brackets-only: always a loud error, with the
161/// bracket fix in the message. Shared by the root pre-check and `resolve_step`
162/// so a dotted segment reports identically at any hop.
163fn dotted_access_error(path: &str, field: &str) -> PathError {
164    PathError::Shape(format!(
165        "${{{path}…}}: kaish uses bracket access, not dots — write the key as a subscript: [{field}]"
166    ))
167}
168
169/// Render a subscript as the user wrote it, for building the path prefix that
170/// error messages carry (`a` → `a[b]` → `a[b][0]`) so a nested failure names the
171/// real path, not just the root.
172fn render_segment(seg: &VarSegment) -> String {
173    match seg {
174        VarSegment::Index(i) => format!("[{i}]"),
175        VarSegment::Key(k) => format!("[{k}]"),
176        VarSegment::Dynamic(v) => format!("[${v}]"),
177        VarSegment::Slice(a, b) => format!(
178            "[{}:{}]",
179            a.map(|n| n.to_string()).unwrap_or_default(),
180            b.map(|n| n.to_string()).unwrap_or_default()
181        ),
182        VarSegment::Field(f) => format!(".{f}"),
183    }
184}
185
186/// Classify one subscript against its container — the shared per-hop unit that
187/// keeps read traversal and (next phase) lvalue writes from diverging. Only the
188/// dynamic-key arm needs the scope (to look up `$k`); everything else is a pure
189/// function of the container and segment. A non-collection container is caught
190/// here once, so the `classify_*` helpers never see a scalar.
191fn resolve_step(
192    container: &serde_json::Value,
193    seg: &VarSegment,
194    scope: &Scope,
195    path: &str,
196) -> Result<Step, PathError> {
197    // A non-root `Field` is a dotted `.field` access — checked before the
198    // container guard so a dotted segment always wins over a "not a collection"
199    // message (the per-hop precedence the old walker had).
200    if let VarSegment::Field(name) = seg {
201        return Err(dotted_access_error(path, name));
202    }
203
204    // Every remaining subscript needs a collection container.
205    if !matches!(
206        container,
207        serde_json::Value::Array(_) | serde_json::Value::Object(_)
208    ) {
209        return Err(PathError::Shape(format!(
210            "${{{path}…}}: cannot subscript {} — it is not a collection",
211            type_name(&json_to_value_no_envelope(container.clone()))
212        )));
213    }
214
215    match seg {
216        VarSegment::Index(i) => classify_index(container, *i, path),
217        VarSegment::Key(k) => classify_key(container, k, path),
218        VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
219        VarSegment::Dynamic(var) => {
220            // The variable's value is the subscript; the container type decides
221            // whether it's an index or a key. An unset `$k` is UndefinedRoot,
222            // not Absence — the *variable* is missing, so `${r[$k]:-d}` defaults.
223            let key_val = scope.get(var).ok_or_else(|| {
224                PathError::UndefinedRoot(format!("${{{path}[${var}]}}: ${var} is not set"))
225            })?;
226            match container {
227                serde_json::Value::Array(_) => {
228                    let idx = value_as_index(key_val).ok_or_else(|| {
229                        PathError::Shape(format!(
230                            "${{{path}[${var}]}}: a list index must be an integer, got \"{}\"",
231                            value_to_string(key_val)
232                        ))
233                    })?;
234                    classify_index(container, idx, path)
235                }
236                serde_json::Value::Object(_) => Ok(Step::Key(value_to_string(key_val))),
237                _ => unreachable!("non-collection container guarded above"),
238            }
239        }
240        VarSegment::Field(_) => unreachable!("dotted segment handled above"),
241    }
242}
243
244/// Apply one classified step, descending the borrowed JSON tree. Borrowed input
245/// stays borrowed for index/key (no clone); a slice always allocates a new list,
246/// and once owned (post-slice) descent clones the selected child. A missing
247/// record key is a loud read error here — the write-leaf insert is the next
248/// phase, and lives in the walk, not in [`resolve_step`].
249fn descend<'a>(
250    current: Cow<'a, serde_json::Value>,
251    step: Step,
252    path: &str,
253) -> Result<Cow<'a, serde_json::Value>, PathError> {
254    match step {
255        Step::Slice(s, e) => {
256            let Some(arr) = current.as_array() else {
257                unreachable!("slice classified against an array")
258            };
259            Ok(Cow::Owned(serde_json::Value::Array(arr[s..e].to_vec())))
260        }
261        Step::Index(i) => match current {
262            Cow::Borrowed(j) => {
263                let Some(arr) = j.as_array() else {
264                    unreachable!("index classified against an array")
265                };
266                Ok(Cow::Borrowed(&arr[i]))
267            }
268            Cow::Owned(j) => {
269                let Some(arr) = j.as_array() else {
270                    unreachable!("index classified against an array")
271                };
272                Ok(Cow::Owned(arr[i].clone()))
273            }
274        },
275        Step::Key(k) => match current {
276            Cow::Borrowed(j) => match j.as_object().and_then(|m| m.get(&k)) {
277                Some(child) => Ok(Cow::Borrowed(child)),
278                None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
279            },
280            Cow::Owned(j) => match j.as_object().and_then(|m| m.get(&k)) {
281                Some(child) => Ok(Cow::Owned(child.clone())),
282                None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
283            },
284        },
285    }
286}
287
288/// Apply one classified step during a **write** walk's intermediate hops:
289/// descend mutably, requiring the child to already exist — no
290/// autovivification. Bounds/shape were already checked by `resolve_step`;
291/// this only adds the "must already exist" policy that only a write walk
292/// needs (a read's `descend` also requires existence for `Key`, but a write's
293/// *final* hop diverges — see `apply_leaf_write`). A `Slice` step can never
294/// be part of a valid lvalue path (mutating through a detached slice copy
295/// wouldn't write back), so it is always a loud `Shape` error here,
296/// intermediate or not.
297fn descend_mut<'a>(
298    current: &'a mut serde_json::Value,
299    step: Step,
300    path: &str,
301) -> Result<&'a mut serde_json::Value, PathError> {
302    match step {
303        Step::Slice(..) => Err(PathError::Shape(format!(
304            "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
305        ))),
306        Step::Index(i) => {
307            let Some(arr) = current.as_array_mut() else {
308                unreachable!("index classified against an array")
309            };
310            Ok(&mut arr[i])
311        }
312        Step::Key(k) => {
313            let Some(map) = current.as_object_mut() else {
314                unreachable!("key classified against an object")
315            };
316            match map.get_mut(&k) {
317                Some(child) => Ok(child),
318                None => Err(PathError::Absence(format!(
319                    "${{{path}[{k}]}}: no such key — no autovivification, create it first (e.g. `{path}[{k}]={{}}`)"
320                ))),
321            }
322        }
323    }
324}
325
326/// Apply the classified **final** step of a write walk: a record key inserts
327/// or updates (the one thing a path-set may create), a list index updates
328/// in-bounds (already validated by `resolve_step`'s `classify_index` — an
329/// out-of-bounds index is an `Absence` error before this is ever reached),
330/// and a slice is a loud `Shape` error (no slice lvalues — `push` grows
331/// lists).
332fn apply_leaf_write(
333    current: &mut serde_json::Value,
334    step: Step,
335    value: serde_json::Value,
336    path: &str,
337) -> Result<(), PathError> {
338    match step {
339        Step::Slice(..) => Err(PathError::Shape(format!(
340            "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
341        ))),
342        Step::Index(i) => {
343            let Some(arr) = current.as_array_mut() else {
344                unreachable!("index classified against an array")
345            };
346            arr[i] = value;
347            Ok(())
348        }
349        Step::Key(k) => {
350            let Some(map) = current.as_object_mut() else {
351                unreachable!("key classified against an object")
352            };
353            map.insert(k, value);
354            Ok(())
355        }
356    }
357}
358
359/// Render a mid-walk `PathError` for `push`'s error text. `Absence`/`Shape`
360/// already carry a ready-to-display `${…}` message from `resolve_step`/
361/// `descend_mut` (shared with `walk_write`); only `UndefinedRoot` needs
362/// `push`-flavored wording here — it fires for an unset `$k` dynamic
363/// subscript mid-path, not the root itself (`walk_append` checks the root
364/// exists before ever starting the walk).
365fn push_path_error_message(err: PathError, root_name: &str) -> String {
366    match err {
367        PathError::UndefinedRoot(msg) if msg.is_empty() => {
368            format!("push: {root_name} is not defined")
369        }
370        PathError::UndefinedRoot(msg) => format!("push: {msg}"),
371        PathError::Absence(msg) | PathError::Shape(msg) => msg,
372    }
373}
374
375/// Variable scope with nested frames and last-result tracking.
376///
377/// Variables are looked up from innermost to outermost frame.
378/// The `?` variable always refers to the last command result.
379///
380/// The `frames` field is wrapped in `Arc` for copy-on-write (COW) semantics.
381/// Cloning a Scope is O(1) — just bumps the Arc refcount. Mutations use
382/// `Arc::make_mut` to clone the inner data only when shared. This matters
383/// because `execute_pipeline` snapshots the scope into ExecContext (clone)
384/// and syncs it back (clone) on every command.
385#[derive(Debug, Clone)]
386pub struct Scope {
387    /// Stack of variable frames. Last element is the innermost scope.
388    /// Wrapped in Arc for copy-on-write: clone is O(1), mutation clones on demand.
389    frames: Arc<Vec<HashMap<String, Value>>>,
390    /// Variables marked for export to child processes.
391    exported: HashSet<String>,
392    /// The result of the last command execution.
393    ///
394    /// Boxed: `Scope` is cloned/held by value at every recursion level (the
395    /// dispatch snapshot, the command-subst save/restore), and an inline
396    /// `ExecResult` made `Scope` ~half again as large in each of those copies
397    /// (GH #48, item 5). The box is reused in place by `set_last_result`, so the
398    /// steady state is one allocation per `Scope`, not one per update.
399    last_result: Box<ExecResult>,
400    /// Script or tool name ($0).
401    script_name: String,
402    /// Positional arguments ($1-$9, $@, $#).
403    positional: Vec<String>,
404    /// Error exit mode (set -e): exit on any command failure.
405    error_exit: bool,
406    /// Counter for temporarily suppressing errexit (e.g. inside && / || left side).
407    /// When > 0, error_exit_enabled() returns false even if error_exit is true.
408    errexit_suppressed: usize,
409    /// AST display mode (kaish-ast -on/-off): show AST instead of executing.
410    show_ast: bool,
411    /// Latch mode (set -o latch): gate dangerous operations behind nonce confirmation.
412    latch_enabled: bool,
413    /// Trash mode (set -o trash): move deleted files to freedesktop.org Trash.
414    trash_enabled: bool,
415    /// Maximum file size (bytes) for trash. Files larger than this bypass trash.
416    /// Default: 10 MB.
417    trash_max_size: u64,
418    /// Glob expansion mode (set -o glob): expand bare glob patterns in arguments.
419    glob_enabled: bool,
420    /// Kaish session identifier ($$). A monotonic counter assigned at Kernel
421    /// construction (see `KERNEL_COUNTER` in kernel.rs) — *not* the OS PID.
422    /// Subshells / forks inherit the parent's value (Scope clone copies it).
423    /// 0 is a sentinel meaning "this scope was constructed outside a Kernel"
424    /// (e.g. arithmetic unit tests, kaish-clear before its setter runs).
425    pid: u64,
426}
427
428impl Scope {
429    /// Create a new scope with one empty frame.
430    ///
431    /// `pid` defaults to 0 (sentinel). The owning Kernel calls `set_pid()`
432    /// during construction to assign the real session identifier.
433    pub fn new() -> Self {
434        Self {
435            frames: Arc::new(vec![HashMap::new()]),
436            exported: HashSet::new(),
437            last_result: Box::new(ExecResult::default()),
438            script_name: String::new(),
439            positional: Vec::new(),
440            error_exit: false,
441            errexit_suppressed: 0,
442            show_ast: false,
443            latch_enabled: false,
444            trash_enabled: false,
445            trash_max_size: 10 * 1024 * 1024, // 10 MB
446            glob_enabled: true,
447            pid: 0,
448        }
449    }
450
451    /// Get the kaish session identifier ($$).
452    pub fn pid(&self) -> u64 {
453        self.pid
454    }
455
456    /// Set the kaish session identifier ($$). Called by the Kernel during
457    /// construction to thread the assigned counter value into the scope.
458    /// Also used by `kaish-clear` to preserve $$ across a session reset.
459    pub fn set_pid(&mut self, pid: u64) {
460        self.pid = pid;
461    }
462
463    /// Push a new scope frame (for entering a loop, tool call, etc.)
464    pub fn push_frame(&mut self) {
465        Arc::make_mut(&mut self.frames).push(HashMap::new());
466    }
467
468    /// Pop the innermost scope frame.
469    ///
470    /// Panics if attempting to pop the last frame.
471    pub fn pop_frame(&mut self) {
472        if self.frames.len() > 1 {
473            Arc::make_mut(&mut self.frames).pop();
474        } else {
475            panic!("cannot pop the root scope frame");
476        }
477    }
478
479    /// Set a variable in the current (innermost) frame.
480    ///
481    /// Use this for `local` variable declarations.
482    pub fn set(&mut self, name: impl Into<String>, value: Value) {
483        if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
484            frame.insert(name.into(), value);
485        }
486    }
487
488    /// Set a variable with global semantics (shell default).
489    ///
490    /// If the variable exists in any frame, update it there.
491    /// Otherwise, create it in the outermost (root) frame.
492    /// Use this for non-local variable assignments.
493    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
494        let name = name.into();
495
496        // Search from innermost to outermost to find existing variable
497        let frames = Arc::make_mut(&mut self.frames);
498        for frame in frames.iter_mut().rev() {
499            if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
500                e.insert(value);
501                return;
502            }
503        }
504
505        // Variable doesn't exist - create in root frame (index 0)
506        if let Some(frame) = frames.first_mut() {
507            frame.insert(name, value);
508        }
509    }
510
511    /// Get a variable by name, searching from innermost to outermost frame.
512    pub fn get(&self, name: &str) -> Option<&Value> {
513        for frame in self.frames.iter().rev() {
514            if let Some(value) = frame.get(name) {
515                return Some(value);
516            }
517        }
518        None
519    }
520
521    /// Remove a variable, searching from innermost to outermost frame.
522    ///
523    /// Returns the removed value if found, None otherwise.
524    pub fn remove(&mut self, name: &str) -> Option<Value> {
525        for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
526            if let Some(value) = frame.remove(name) {
527                return Some(value);
528            }
529        }
530        None
531    }
532
533    /// Set the last command result (accessible via `$?`).
534    pub fn set_last_result(&mut self, result: ExecResult) {
535        // Write through the existing box rather than reallocating one.
536        *self.last_result = result;
537    }
538
539    /// Get the last command result.
540    pub fn last_result(&self) -> &ExecResult {
541        &self.last_result
542    }
543
544    /// Set the positional parameters ($0, $1-$9, $@, $#).
545    ///
546    /// The script_name becomes $0, and args become $1, $2, etc.
547    pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
548        self.script_name = script_name.into();
549        self.positional = args;
550    }
551
552    /// Save current positional parameters for later restoration.
553    ///
554    /// Returns (script_name, args) tuple that can be passed to set_positional.
555    pub fn save_positional(&self) -> (String, Vec<String>) {
556        (self.script_name.clone(), self.positional.clone())
557    }
558
559    /// Get a positional parameter by index ($0-$9).
560    ///
561    /// $0 returns the script name, $1-$9 return arguments.
562    pub fn get_positional(&self, n: usize) -> Option<&str> {
563        if n == 0 {
564            if self.script_name.is_empty() {
565                None
566            } else {
567                Some(&self.script_name)
568            }
569        } else {
570            self.positional.get(n - 1).map(|s| s.as_str())
571        }
572    }
573
574    /// Get all positional arguments as a slice ($@).
575    pub fn all_args(&self) -> &[String] {
576        &self.positional
577    }
578
579    /// Get the count of positional arguments ($#).
580    pub fn arg_count(&self) -> usize {
581        self.positional.len()
582    }
583
584    /// Check if error-exit mode is active (set -e and not suppressed).
585    ///
586    /// Returns false when inside the left side of `&&` or `||` chains,
587    /// matching bash behavior where those operators handle failure themselves.
588    pub fn error_exit_enabled(&self) -> bool {
589        self.error_exit && self.errexit_suppressed == 0
590    }
591
592    /// Set error-exit mode (set -e / set +e).
593    pub fn set_error_exit(&mut self, enabled: bool) {
594        self.error_exit = enabled;
595    }
596
597    /// Suppress errexit temporarily (for `&&`/`||` left side).
598    pub fn suppress_errexit(&mut self) {
599        self.errexit_suppressed += 1;
600    }
601
602    /// Unsuppress errexit (after `&&`/`||` left side completes).
603    pub fn unsuppress_errexit(&mut self) {
604        self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
605    }
606
607    /// Check if AST display mode is enabled (kaish-ast -on).
608    pub fn show_ast(&self) -> bool {
609        self.show_ast
610    }
611
612    /// Set AST display mode (kaish-ast -on / kaish-ast -off).
613    pub fn set_show_ast(&mut self, enabled: bool) {
614        self.show_ast = enabled;
615    }
616
617    /// Check if latch mode is enabled (set -o latch).
618    pub fn latch_enabled(&self) -> bool {
619        self.latch_enabled
620    }
621
622    /// Set latch mode (set -o latch / set +o latch).
623    pub fn set_latch_enabled(&mut self, enabled: bool) {
624        self.latch_enabled = enabled;
625    }
626
627    /// Check if trash mode is enabled (set -o trash).
628    pub fn trash_enabled(&self) -> bool {
629        self.trash_enabled
630    }
631
632    /// Set trash mode (set -o trash / set +o trash).
633    pub fn set_trash_enabled(&mut self, enabled: bool) {
634        self.trash_enabled = enabled;
635    }
636
637    /// Get the maximum file size for trash (bytes).
638    pub fn trash_max_size(&self) -> u64 {
639        self.trash_max_size
640    }
641
642    /// Set the maximum file size for trash (bytes).
643    pub fn set_trash_max_size(&mut self, size: u64) {
644        self.trash_max_size = size;
645    }
646
647    /// Check if glob expansion is enabled (set -o glob, default true).
648    pub fn glob_enabled(&self) -> bool {
649        self.glob_enabled
650    }
651
652    /// Set glob expansion mode (set -o glob / set +o glob).
653    pub fn set_glob_enabled(&mut self, enabled: bool) {
654        self.glob_enabled = enabled;
655    }
656
657    /// Mark a variable as exported (visible to child processes).
658    ///
659    /// The variable doesn't need to exist yet; it will be exported when set.
660    pub fn export(&mut self, name: impl Into<String>) {
661        self.exported.insert(name.into());
662    }
663
664    /// Check if a variable is marked for export.
665    pub fn is_exported(&self, name: &str) -> bool {
666        self.exported.contains(name)
667    }
668
669    /// Set a variable in the **innermost** frame and mark it as exported.
670    ///
671    /// Used for frame-scoped overlays (`execute_with_vars`, `FOO=bar cmd`) and
672    /// for seeding root-frame exports at construction. For the `export`
673    /// builtin's assignment form use [`set_exported_global`](Self::set_exported_global)
674    /// so the value survives a function return (shared-scope semantics).
675    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
676        let name = name.into();
677        self.set(&name, value);
678        self.export(name);
679    }
680
681    /// Set a variable with **global** (shared-scope) semantics and mark it as
682    /// exported. This is `export NAME=VALUE`: like a plain assignment, the value
683    /// updates an existing variable wherever it lives or lands in the root frame,
684    /// so it persists past a function return rather than dying with the
685    /// function's frame.
686    pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
687        let name = name.into();
688        self.set_global(&name, value);
689        self.export(name);
690    }
691
692    /// Unmark a variable from export.
693    pub fn unexport(&mut self, name: &str) {
694        self.exported.remove(name);
695    }
696
697    /// Get all exported variables with their values.
698    ///
699    /// Only returns variables that exist and are marked for export.
700    pub fn exported_vars(&self) -> Vec<(String, Value)> {
701        let mut result = Vec::new();
702        for name in &self.exported {
703            if let Some(value) = self.get(name) {
704                result.push((name.clone(), value.clone()));
705            }
706        }
707        result.sort_by(|(a, _), (b, _)| a.cmp(b));
708        result
709    }
710
711    /// Get all exported variable names.
712    pub fn exported_names(&self) -> Vec<&str> {
713        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
714        names.sort();
715        names
716    }
717
718    /// Resolve a variable path: `${VAR}`, `${xs[0]}`, `${r[key]}`, `${a[b][c]}`.
719    ///
720    /// The first segment is the root name; the rest are bracket subscripts,
721    /// walked left to right into the root's `Value::Json`. A subscript landing
722    /// on a JSON scalar unwraps to a native `Value` (envelope-free); a subscript
723    /// landing on a collection stays `Value::Json`. `$?` resolves to the
724    /// previous command's exit code (bare only).
725    ///
726    /// Traversal borrows into the root's JSON tree and clones only the selected
727    /// leaf (a slice builds a new list); the whole-root clone is never taken, so
728    /// repeated `${u[$k]}` in a loop stays O(depth), not O(root size). The
729    /// per-hop classification lives in [`resolve_step`], shared with the future
730    /// lvalue-write walk so read and write can never diverge.
731    ///
732    /// Errors distinguish an undefined root (soft) from a loud path error (see
733    /// [`PathError`]).
734    pub fn resolve_path(&self, path: &VarPath) -> Result<Value, PathError> {
735        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
736            // Empty path, or a first segment the parser never emits as root.
737            return Err(PathError::UndefinedRoot(String::new()));
738        };
739
740        // Special case: $? (last result) — bare only.
741        if root_name == "?" {
742            if path.segments.len() == 1 {
743                return Ok(Value::Int(self.last_result.code));
744            }
745            return Err(PathError::Shape(
746                "$? is the POSIX exit code, not a collection — use `kaish-last` for structured data"
747                    .to_string(),
748            ));
749        }
750
751        let root = self
752            .get(root_name)
753            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
754
755        // Bare `${VAR}`: return the stored value unchanged — no subscript, no
756        // envelope unwrap.
757        let subscripts = &path.segments[1..];
758        if subscripts.is_empty() {
759            return Ok(root.clone());
760        }
761
762        // A leading dotted segment is brackets-only regardless of the root's
763        // type (matches the per-hop Field-before-container precedence in
764        // `resolve_step`, which the root-collection check below would otherwise
765        // preempt on a scalar root).
766        if let Some(VarSegment::Field(name)) = subscripts.first() {
767            return Err(dotted_access_error(root_name, name));
768        }
769
770        // Subscripted: the root must be a collection to descend into. A scalar
771        // root reports the same "not a collection" message a mid-path scalar
772        // would (via `resolve_step`).
773        let root_json = match root {
774            Value::Json(j) => j,
775            other => {
776                return Err(PathError::Shape(format!(
777                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
778                    type_name(other)
779                )))
780            }
781        };
782
783        // Walk the subscripts, borrowing into the tree; only a slice (which
784        // builds a new list) and the terminal unwrap allocate. `prefix`
785        // accumulates the path walked so far so a nested failure names the real
786        // path (`${a[b][9]}`, not `${a[9]}`).
787        let mut current = Cow::Borrowed(root_json);
788        let mut prefix = root_name.clone();
789        for seg in subscripts {
790            let step = resolve_step(&current, seg, self, &prefix)?;
791            current = descend(current, step, &prefix)?;
792            prefix.push_str(&render_segment(seg));
793        }
794        Ok(json_to_value_no_envelope(current.into_owned()))
795    }
796
797    /// Write a value into a collection lvalue path: `xs[0]=9`,
798    /// `user[email]=amy@example.com`, `services[web][port]=9090`.
799    ///
800    /// Shares `resolve_step` with [`resolve_path`](Self::resolve_path) so
801    /// classification (bounds, shape) never drifts between read and write.
802    /// The walk itself diverges at the leaf: every intermediate hop requires
803    /// the child to already exist (`descend_mut` — **no autovivification**),
804    /// while the final hop may insert a new record key (`apply_leaf_write`) —
805    /// the ONLY thing a path-set may create. A list index write is in-bounds
806    /// update only (`resolve_step`'s `classify_index` already turns an
807    /// out-of-bounds index into a loud `Absence`); `push` is how lists grow.
808    /// A slice lvalue (`xs[0:2]=…`) is always a `Shape` error.
809    ///
810    /// The root must already be defined (`UndefinedRoot`) and be a collection
811    /// (`Shape` for a scalar root) — same rule as a read. On success the
812    /// mutated root replaces the old value via `set_global` (bracket-path
813    /// writes always update wherever the variable lives, regardless of
814    /// `local` — see `docs/arrays-and-hashes.md`, "Assignment lvalues").
815    pub fn walk_write(&mut self, path: &VarPath, value: Value) -> Result<(), PathError> {
816        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
817            return Err(PathError::UndefinedRoot(String::new()));
818        };
819
820        let root = self
821            .get(root_name)
822            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
823
824        let mut root_json = match root {
825            Value::Json(j) => j.clone(),
826            other => {
827                return Err(PathError::Shape(format!(
828                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
829                    type_name(other)
830                )))
831            }
832        };
833
834        let subscripts = &path.segments[1..];
835        let Some((last, intermediates)) = subscripts.split_last() else {
836            // A bare name never reaches walk_write — the kernel routes a
837            // one-segment path through set/set_global. Guard defensively
838            // rather than silently no-op.
839            return Err(PathError::Shape(format!(
840                "{root_name}: assignment target has no subscript"
841            )));
842        };
843
844        let mut current = &mut root_json;
845        let mut prefix = root_name.clone();
846        for seg in intermediates {
847            let step = resolve_step(current, seg, self, &prefix)?;
848            current = descend_mut(current, step, &prefix)?;
849            prefix.push_str(&render_segment(seg));
850        }
851
852        let step = resolve_step(current, last, self, &prefix)?;
853        apply_leaf_write(current, step, value_to_json(&value), &prefix)?;
854
855        self.set_global(root_name.clone(), Value::Json(root_json));
856        Ok(())
857    }
858
859    /// Append value(s) to a list variable, in place: a top-level bareword
860    /// target (`push xs val`) or a bracket-path target
861    /// (`push services[web][tags] item`).
862    ///
863    /// The target must already exist and be a list — an undefined root, a
864    /// non-list leaf, or a missing intermediate hop is a loud error, never a
865    /// silent create or autoviv (see `docs/arrays-and-hashes.md`, "Append —
866    /// push"). Intermediate hops share `walk_write`'s `resolve_step`/
867    /// `descend_mut` (no autoviv, same bounds/shape classification); the
868    /// final hop diverges — it appends instead of replacing.
869    pub fn walk_append(&mut self, path: &VarPath, values: Vec<Value>) -> Result<(), String> {
870        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
871            return Err("push: target has no root".to_string());
872        };
873        let root_name = root_name.clone();
874        let current = self
875            .get(&root_name)
876            .ok_or_else(|| format!("push: {root_name} is not defined"))?
877            .clone();
878
879        let subscripts = &path.segments[1..];
880        if subscripts.is_empty() {
881            if !matches!(current, Value::Json(serde_json::Value::Array(_))) {
882                return Err(format!("push: {root_name} is not a list ({})", type_name(&current)));
883            }
884            let Value::Json(serde_json::Value::Array(mut arr)) = current else {
885                unreachable!("checked above")
886            };
887            arr.extend(values.iter().map(value_to_json));
888            self.set_global(root_name, Value::Json(serde_json::Value::Array(arr)));
889            return Ok(());
890        }
891
892        let mut root_json = match current {
893            Value::Json(j) => j,
894            other => {
895                return Err(format!(
896                    "push: {root_name}…: cannot subscript {} — it is not a collection",
897                    type_name(&other)
898                ))
899            }
900        };
901
902        // Walk every subscript — including the last — with the shared
903        // no-autoviv intermediate walker: the container the values append
904        // into must already exist, matching `walk_write`'s policy.
905        let mut cur = &mut root_json;
906        let mut prefix = root_name.clone();
907        for seg in subscripts {
908            let step = resolve_step(cur, seg, self, &prefix)
909                .map_err(|e| push_path_error_message(e, &root_name))?;
910            cur = descend_mut(cur, step, &prefix)
911                .map_err(|e| push_path_error_message(e, &root_name))?;
912            prefix.push_str(&render_segment(seg));
913        }
914
915        let serde_json::Value::Array(arr) = cur else {
916            return Err(format!(
917                "push: {prefix} is not a list ({})",
918                type_name(&json_to_value_no_envelope(cur.clone()))
919            ));
920        };
921        arr.extend(values.iter().map(value_to_json));
922        self.set_global(root_name, Value::Json(root_json));
923        Ok(())
924    }
925
926    /// Check if a variable exists in any frame.
927    pub fn contains(&self, name: &str) -> bool {
928        self.get(name).is_some()
929    }
930
931    /// Get all variable names in scope (for debugging/introspection).
932    pub fn all_names(&self) -> Vec<&str> {
933        let mut names: Vec<&str> = self
934            .frames
935            .iter()
936            .flat_map(|f| f.keys().map(|s| s.as_str()))
937            .collect();
938        names.sort();
939        names.dedup();
940        names
941    }
942
943    /// Get all variables as (name, value) pairs.
944    ///
945    /// Variables are deduplicated, with inner frames shadowing outer ones.
946    pub fn all(&self) -> Vec<(String, Value)> {
947        let mut result = std::collections::HashMap::new();
948        // Iterate outer to inner so inner frames override
949        for frame in self.frames.iter() {
950            for (name, value) in frame {
951                result.insert(name.clone(), value.clone());
952            }
953        }
954        let mut pairs: Vec<_> = result.into_iter().collect();
955        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
956        pairs
957    }
958}
959
960impl Default for Scope {
961    fn default() -> Self {
962        Self::new()
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn new_scope_has_one_frame() {
972        let scope = Scope::new();
973        assert_eq!(scope.frames.len(), 1);
974    }
975
976    #[test]
977    fn set_and_get_variable() {
978        let mut scope = Scope::new();
979        scope.set("X", Value::Int(42));
980        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
981    }
982
983    #[test]
984    fn get_nonexistent_returns_none() {
985        let scope = Scope::new();
986        assert_eq!(scope.get("MISSING"), None);
987    }
988
989    #[test]
990    fn inner_frame_shadows_outer() {
991        let mut scope = Scope::new();
992        scope.set("X", Value::Int(1));
993        scope.push_frame();
994        scope.set("X", Value::Int(2));
995        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
996        scope.pop_frame();
997        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
998    }
999
1000    #[test]
1001    fn inner_frame_can_see_outer_vars() {
1002        let mut scope = Scope::new();
1003        scope.set("OUTER", Value::String("visible".into()));
1004        scope.push_frame();
1005        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
1006    }
1007
1008    #[test]
1009    fn resolve_simple_path() {
1010        let mut scope = Scope::new();
1011        scope.set("NAME", Value::String("Alice".into()));
1012
1013        let path = VarPath::simple("NAME");
1014        assert_eq!(
1015            scope.resolve_path(&path),
1016            Ok(Value::String("Alice".into()))
1017        );
1018    }
1019
1020    #[test]
1021    fn resolve_bare_last_result_returns_exit_code() {
1022        let mut scope = Scope::new();
1023        scope.set_last_result(ExecResult::failure(127, "not found"));
1024
1025        let path = VarPath {
1026            segments: vec![VarSegment::Field("?".into())],
1027        };
1028        assert_eq!(scope.resolve_path(&path), Ok(Value::Int(127)));
1029    }
1030
1031    #[test]
1032    fn resolve_last_result_field_access_is_rejected() {
1033        // Field access on $? was removed — use `kaish-last` for structured data.
1034        // The resolver now returns a loud error; the validator also catches it
1035        // earlier with a specific error code for actionable diagnostics.
1036        let mut scope = Scope::new();
1037        scope.set_last_result(ExecResult::success_with_data(
1038            "1",
1039            Value::Json(serde_json::json!({"count": 5})),
1040        ));
1041
1042        let path = VarPath {
1043            segments: vec![
1044                VarSegment::Field("?".into()),
1045                VarSegment::Field("data".into()),
1046            ],
1047        };
1048        assert!(matches!(
1049            scope.resolve_path(&path),
1050            Err(PathError::Shape(_))
1051        ));
1052    }
1053
1054    #[test]
1055    fn resolve_dotted_access_on_scalar_is_a_loud_error() {
1056        let mut scope = Scope::new();
1057        scope.set("X", Value::Int(42));
1058
1059        // Dotted access `${X.invalid}` — brackets-only, so it's a loud error.
1060        let path = VarPath {
1061            segments: vec![
1062                VarSegment::Field("X".into()),
1063                VarSegment::Field("invalid".into()),
1064            ],
1065        };
1066        assert!(matches!(
1067            scope.resolve_path(&path),
1068            Err(PathError::Shape(_))
1069        ));
1070    }
1071
1072    #[test]
1073    fn resolve_undefined_root_is_soft() {
1074        let scope = Scope::new();
1075        let path = VarPath::simple("NOPE");
1076        assert!(matches!(
1077            scope.resolve_path(&path),
1078            Err(PathError::UndefinedRoot(_))
1079        ));
1080    }
1081
1082    // ── PathError classification (Absence vs Shape) ─────────────────────────
1083    // Pins the three-way split: `${path:-default}` (a later commit) leans on
1084    // Absence-vs-Shape, so a misclassification here is a real semantic bug, not
1085    // cosmetics. All three stay loud for a bare access.
1086
1087    /// Build `${root[seg]}` with one bracket subscript.
1088    fn subscripted(scope: &mut Scope, root: &str, value: serde_json::Value, seg: VarSegment) -> Result<Value, PathError> {
1089        scope.set(root, Value::Json(value));
1090        let path = VarPath {
1091            segments: vec![VarSegment::Field(root.into()), seg],
1092        };
1093        scope.resolve_path(&path)
1094    }
1095
1096    #[test]
1097    fn out_of_bounds_index_is_absence() {
1098        let mut scope = Scope::new();
1099        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Index(9));
1100        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1101    }
1102
1103    #[test]
1104    fn missing_record_key_is_absence() {
1105        let mut scope = Scope::new();
1106        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Key("nope".into()));
1107        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1108    }
1109
1110    #[test]
1111    fn string_key_on_a_list_is_shape() {
1112        let mut scope = Scope::new();
1113        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Key("web".into()));
1114        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1115    }
1116
1117    #[test]
1118    fn integer_index_on_a_record_is_shape() {
1119        let mut scope = Scope::new();
1120        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Index(0));
1121        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1122    }
1123
1124    #[test]
1125    fn subscripting_a_scalar_is_shape() {
1126        let mut scope = Scope::new();
1127        scope.set("s", Value::String("hello".into()));
1128        let path = VarPath {
1129            segments: vec![VarSegment::Field("s".into()), VarSegment::Index(0)],
1130        };
1131        assert!(matches!(scope.resolve_path(&path), Err(PathError::Shape(_))));
1132    }
1133
1134    #[test]
1135    fn unset_dynamic_key_is_undefined_root_not_absence() {
1136        // `${r[$k]}` with `$k` unset: the *variable* is missing, so it's
1137        // UndefinedRoot-class (which `:-` treats as absence), not a Shape error.
1138        let mut scope = Scope::new();
1139        let r = subscripted(
1140            &mut scope,
1141            "r",
1142            serde_json::json!({"name": "amy"}),
1143            VarSegment::Dynamic("k".into()),
1144        );
1145        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1146    }
1147
1148    #[test]
1149    fn contains_finds_variable() {
1150        let mut scope = Scope::new();
1151        scope.set("EXISTS", Value::Bool(true));
1152        assert!(scope.contains("EXISTS"));
1153        assert!(!scope.contains("MISSING"));
1154    }
1155
1156    #[test]
1157    fn all_names_lists_variables() {
1158        let mut scope = Scope::new();
1159        scope.set("A", Value::Int(1));
1160        scope.set("B", Value::Int(2));
1161        scope.push_frame();
1162        scope.set("C", Value::Int(3));
1163
1164        let names = scope.all_names();
1165        assert!(names.contains(&"A"));
1166        assert!(names.contains(&"B"));
1167        assert!(names.contains(&"C"));
1168    }
1169
1170    #[test]
1171    #[should_panic(expected = "cannot pop the root scope frame")]
1172    fn pop_root_frame_panics() {
1173        let mut scope = Scope::new();
1174        scope.pop_frame();
1175    }
1176
1177    #[test]
1178    fn positional_params_basic() {
1179        let mut scope = Scope::new();
1180        scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
1181
1182        // $0 is the script/tool name
1183        assert_eq!(scope.get_positional(0), Some("my_tool"));
1184        // $1, $2, $3 are the arguments
1185        assert_eq!(scope.get_positional(1), Some("arg1"));
1186        assert_eq!(scope.get_positional(2), Some("arg2"));
1187        assert_eq!(scope.get_positional(3), Some("arg3"));
1188        // $4 doesn't exist
1189        assert_eq!(scope.get_positional(4), None);
1190    }
1191
1192    #[test]
1193    fn positional_params_empty() {
1194        let scope = Scope::new();
1195        // No positional params set
1196        assert_eq!(scope.get_positional(0), None);
1197        assert_eq!(scope.get_positional(1), None);
1198        assert_eq!(scope.arg_count(), 0);
1199        assert!(scope.all_args().is_empty());
1200    }
1201
1202    #[test]
1203    fn all_args_returns_slice() {
1204        let mut scope = Scope::new();
1205        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1206
1207        let args = scope.all_args();
1208        assert_eq!(args, &["a", "b", "c"]);
1209    }
1210
1211    #[test]
1212    fn arg_count_returns_count() {
1213        let mut scope = Scope::new();
1214        scope.set_positional("test", vec!["one".into(), "two".into()]);
1215
1216        assert_eq!(scope.arg_count(), 2);
1217    }
1218
1219    #[test]
1220    fn export_marks_variable() {
1221        let mut scope = Scope::new();
1222        scope.set("X", Value::Int(42));
1223
1224        assert!(!scope.is_exported("X"));
1225        scope.export("X");
1226        assert!(scope.is_exported("X"));
1227    }
1228
1229    #[test]
1230    fn set_exported_sets_and_exports() {
1231        let mut scope = Scope::new();
1232        scope.set_exported("PATH", Value::String("/usr/bin".into()));
1233
1234        assert!(scope.is_exported("PATH"));
1235        assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
1236    }
1237
1238    #[test]
1239    fn unexport_removes_export_marker() {
1240        let mut scope = Scope::new();
1241        scope.set_exported("VAR", Value::Int(1));
1242        assert!(scope.is_exported("VAR"));
1243
1244        scope.unexport("VAR");
1245        assert!(!scope.is_exported("VAR"));
1246        // Variable still exists, just not exported
1247        assert!(scope.get("VAR").is_some());
1248    }
1249
1250    #[test]
1251    fn exported_vars_returns_only_exported_with_values() {
1252        let mut scope = Scope::new();
1253        scope.set_exported("A", Value::Int(1));
1254        scope.set_exported("B", Value::Int(2));
1255        scope.set("C", Value::Int(3)); // Not exported
1256        scope.export("D"); // Exported but no value
1257
1258        let exported = scope.exported_vars();
1259        assert_eq!(exported.len(), 2);
1260        assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
1261        assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
1262    }
1263
1264    #[test]
1265    fn exported_names_returns_sorted_names() {
1266        let mut scope = Scope::new();
1267        scope.export("Z");
1268        scope.export("A");
1269        scope.export("M");
1270
1271        let names = scope.exported_names();
1272        assert_eq!(names, vec!["A", "M", "Z"]);
1273    }
1274
1275    // ── walk_write (lvalue assignment) ──────────────────────────────────────
1276
1277    /// Build `xs[seg]=value` and apply it.
1278    fn write_at(
1279        scope: &mut Scope,
1280        root: &str,
1281        segs: Vec<VarSegment>,
1282    ) -> Result<(), PathError> {
1283        let mut segments = vec![VarSegment::Field(root.into())];
1284        segments.extend(segs);
1285        scope.walk_write(&VarPath { segments }, Value::Int(0))
1286    }
1287
1288    #[test]
1289    fn walk_write_list_index_update() {
1290        let mut scope = Scope::new();
1291        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1292        let path = VarPath {
1293            segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(0)],
1294        };
1295        scope.walk_write(&path, Value::Int(9)).expect("write should succeed");
1296        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([9, 2, 3]))));
1297    }
1298
1299    #[test]
1300    fn walk_write_negative_index() {
1301        let mut scope = Scope::new();
1302        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1303        let path = VarPath {
1304            segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)],
1305        };
1306        scope.walk_write(&path, Value::Int(7)).expect("write should succeed");
1307        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([1, 2, 7]))));
1308    }
1309
1310    #[test]
1311    fn walk_write_inserts_a_new_record_key() {
1312        let mut scope = Scope::new();
1313        scope.set("u", Value::Json(serde_json::json!({"port": 8080})));
1314        let path = VarPath {
1315            segments: vec![VarSegment::Field("u".into()), VarSegment::Key("host".into())],
1316        };
1317        scope
1318            .walk_write(&path, Value::String("localhost".into()))
1319            .expect("write should succeed");
1320        assert_eq!(
1321            scope.get("u"),
1322            Some(&Value::Json(serde_json::json!({"port": 8080, "host": "localhost"})))
1323        );
1324    }
1325
1326    #[test]
1327    fn walk_write_deep_path_updates_nested_key() {
1328        let mut scope = Scope::new();
1329        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1330        let path = VarPath {
1331            segments: vec![
1332                VarSegment::Field("s".into()),
1333                VarSegment::Key("web".into()),
1334                VarSegment::Key("port".into()),
1335            ],
1336        };
1337        scope.walk_write(&path, Value::Int(9000)).expect("write should succeed");
1338        assert_eq!(
1339            scope.get("s"),
1340            Some(&Value::Json(serde_json::json!({"web": {"port": 9000}})))
1341        );
1342    }
1343
1344    #[test]
1345    fn walk_write_out_of_bounds_index_is_absence() {
1346        let mut scope = Scope::new();
1347        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1348        let r = write_at(&mut scope, "xs", vec![VarSegment::Index(9)]);
1349        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1350    }
1351
1352    #[test]
1353    fn walk_write_missing_intermediate_is_absence_no_autoviv() {
1354        let mut scope = Scope::new();
1355        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1356        let r = write_at(
1357            &mut scope,
1358            "s",
1359            vec![VarSegment::Key("api".into()), VarSegment::Key("port".into())],
1360        );
1361        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1362        // The root is untouched — no partial autovivification.
1363        assert_eq!(
1364            scope.get("s"),
1365            Some(&Value::Json(serde_json::json!({"web": {"port": 8080}})))
1366        );
1367    }
1368
1369    #[test]
1370    fn walk_write_scalar_root_is_shape() {
1371        let mut scope = Scope::new();
1372        scope.set("y", Value::String("hi".into()));
1373        let r = write_at(&mut scope, "y", vec![VarSegment::Index(0)]);
1374        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1375    }
1376
1377    #[test]
1378    fn walk_write_undefined_root_is_undefined_root() {
1379        let mut scope = Scope::new();
1380        let r = write_at(&mut scope, "z", vec![VarSegment::Index(0)]);
1381        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1382    }
1383
1384    #[test]
1385    fn walk_write_slice_lvalue_is_shape() {
1386        let mut scope = Scope::new();
1387        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1388        let r = write_at(&mut scope, "xs", vec![VarSegment::Slice(Some(0), Some(2))]);
1389        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1390    }
1391
1392    // ── walk_append (push) ──────────────────────────────────────────────────
1393
1394    #[test]
1395    fn walk_append_extends_a_list_in_place() {
1396        let mut scope = Scope::new();
1397        scope.set("xs", Value::Json(serde_json::json!(["a", "b"])));
1398        scope
1399            .walk_append(&VarPath::simple("xs"), vec![Value::String("c".into())])
1400            .expect("push should succeed");
1401        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!(["a", "b", "c"]))));
1402    }
1403
1404    #[test]
1405    fn walk_append_undefined_target_is_a_loud_error() {
1406        let mut scope = Scope::new();
1407        let r = scope.walk_append(&VarPath::simple("nope"), vec![Value::Int(1)]);
1408        assert!(r.is_err(), "expected a loud error for an undefined target");
1409    }
1410
1411    #[test]
1412    fn walk_append_non_list_target_is_a_loud_error() {
1413        let mut scope = Scope::new();
1414        scope.set("y", Value::String("hi".into()));
1415        let r = scope.walk_append(&VarPath::simple("y"), vec![Value::Int(1)]);
1416        assert!(r.is_err(), "expected a loud error for a non-list target");
1417    }
1418
1419    #[test]
1420    fn walk_append_bracket_path_extends_a_nested_list_in_place() {
1421        let mut scope = Scope::new();
1422        scope.set(
1423            "services",
1424            Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
1425        );
1426        let path = VarPath {
1427            segments: vec![
1428                VarSegment::Field("services".into()),
1429                VarSegment::Key("web".into()),
1430                VarSegment::Key("tags".into()),
1431            ],
1432        };
1433        scope
1434            .walk_append(&path, vec![Value::String("b".into())])
1435            .expect("bracket-path push should succeed");
1436        assert_eq!(
1437            scope.get("services"),
1438            Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
1439        );
1440    }
1441
1442    #[test]
1443    fn walk_append_bracket_path_missing_intermediate_is_a_loud_error() {
1444        let mut scope = Scope::new();
1445        scope.set("services", Value::Json(serde_json::json!({})));
1446        let path = VarPath {
1447            segments: vec![
1448                VarSegment::Field("services".into()),
1449                VarSegment::Key("web".into()),
1450                VarSegment::Key("tags".into()),
1451            ],
1452        };
1453        let r = scope.walk_append(&path, vec![Value::String("x".into())]);
1454        assert!(r.is_err(), "expected a loud error for a missing intermediate");
1455    }
1456
1457    #[test]
1458    fn walk_append_bracket_path_non_list_leaf_is_a_loud_error() {
1459        let mut scope = Scope::new();
1460        scope.set(
1461            "services",
1462            Value::Json(serde_json::json!({"web": {"port": 8080}})),
1463        );
1464        let path = VarPath {
1465            segments: vec![
1466                VarSegment::Field("services".into()),
1467                VarSegment::Key("web".into()),
1468                VarSegment::Key("port".into()),
1469            ],
1470        };
1471        let r = scope.walk_append(&path, vec![Value::Int(1)]);
1472        assert!(r.is_err(), "expected a loud error for a non-list leaf");
1473    }
1474}