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    /// Trash mode (set -o trash): move deleted files to freedesktop.org Trash.
412    trash_enabled: bool,
413    /// Maximum file size (bytes) for trash. Files larger than this bypass trash.
414    /// Default: 10 MB.
415    trash_max_size: u64,
416    /// Glob expansion mode (set -o glob): expand bare glob patterns in arguments.
417    glob_enabled: bool,
418    /// Kaish session identifier ($$). A monotonic counter assigned at Kernel
419    /// construction (see `KERNEL_COUNTER` in kernel.rs) — *not* the OS PID.
420    /// Subshells / forks inherit the parent's value (Scope clone copies it).
421    /// 0 is a sentinel meaning "this scope was constructed outside a Kernel"
422    /// (e.g. arithmetic unit tests, kaish-clear before its setter runs).
423    pid: u64,
424}
425
426impl Scope {
427    /// Create a new scope with one empty frame.
428    ///
429    /// `pid` defaults to 0 (sentinel). The owning Kernel calls `set_pid()`
430    /// during construction to assign the real session identifier.
431    pub fn new() -> Self {
432        Self {
433            frames: Arc::new(vec![HashMap::new()]),
434            exported: HashSet::new(),
435            last_result: Box::new(ExecResult::default()),
436            script_name: String::new(),
437            positional: Vec::new(),
438            error_exit: false,
439            errexit_suppressed: 0,
440            show_ast: false,
441            trash_enabled: false,
442            trash_max_size: 10 * 1024 * 1024, // 10 MB
443            glob_enabled: true,
444            pid: 0,
445        }
446    }
447
448    /// Get the kaish session identifier ($$).
449    pub fn pid(&self) -> u64 {
450        self.pid
451    }
452
453    /// Set the kaish session identifier ($$). Called by the Kernel during
454    /// construction to thread the assigned counter value into the scope.
455    /// Also used by `kaish-clear` to preserve $$ across a session reset.
456    pub fn set_pid(&mut self, pid: u64) {
457        self.pid = pid;
458    }
459
460    /// Push a new scope frame (for entering a loop, tool call, etc.)
461    pub fn push_frame(&mut self) {
462        Arc::make_mut(&mut self.frames).push(HashMap::new());
463    }
464
465    /// Pop the innermost scope frame.
466    ///
467    /// Panics if attempting to pop the last frame.
468    pub fn pop_frame(&mut self) {
469        if self.frames.len() > 1 {
470            Arc::make_mut(&mut self.frames).pop();
471        } else {
472            panic!("cannot pop the root scope frame");
473        }
474    }
475
476    /// Set a variable in the current (innermost) frame.
477    ///
478    /// Use this for `local` variable declarations.
479    pub fn set(&mut self, name: impl Into<String>, value: Value) {
480        if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
481            frame.insert(name.into(), value);
482        }
483    }
484
485    /// Set a variable with global semantics (shell default).
486    ///
487    /// If the variable exists in any frame, update it there.
488    /// Otherwise, create it in the outermost (root) frame.
489    /// Use this for non-local variable assignments.
490    pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
491        let name = name.into();
492
493        // Search from innermost to outermost to find existing variable
494        let frames = Arc::make_mut(&mut self.frames);
495        for frame in frames.iter_mut().rev() {
496            if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
497                e.insert(value);
498                return;
499            }
500        }
501
502        // Variable doesn't exist - create in root frame (index 0)
503        if let Some(frame) = frames.first_mut() {
504            frame.insert(name, value);
505        }
506    }
507
508    /// Get a variable by name, searching from innermost to outermost frame.
509    pub fn get(&self, name: &str) -> Option<&Value> {
510        for frame in self.frames.iter().rev() {
511            if let Some(value) = frame.get(name) {
512                return Some(value);
513            }
514        }
515        None
516    }
517
518    /// Remove a variable, searching from innermost to outermost frame.
519    ///
520    /// Returns the removed value if found, None otherwise.
521    pub fn remove(&mut self, name: &str) -> Option<Value> {
522        for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
523            if let Some(value) = frame.remove(name) {
524                return Some(value);
525            }
526        }
527        None
528    }
529
530    /// Set the last command result (accessible via `$?`).
531    pub fn set_last_result(&mut self, result: ExecResult) {
532        // Write through the existing box rather than reallocating one.
533        *self.last_result = result;
534    }
535
536    /// Get the last command result.
537    pub fn last_result(&self) -> &ExecResult {
538        &self.last_result
539    }
540
541    /// Set the positional parameters ($0, $1-$9, $@, $#).
542    ///
543    /// The script_name becomes $0, and args become $1, $2, etc.
544    pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
545        self.script_name = script_name.into();
546        self.positional = args;
547    }
548
549    /// Save current positional parameters for later restoration.
550    ///
551    /// Returns (script_name, args) tuple that can be passed to set_positional.
552    pub fn save_positional(&self) -> (String, Vec<String>) {
553        (self.script_name.clone(), self.positional.clone())
554    }
555
556    /// Get a positional parameter by index ($0-$9).
557    ///
558    /// $0 returns the script name, $1-$9 return arguments.
559    pub fn get_positional(&self, n: usize) -> Option<&str> {
560        if n == 0 {
561            if self.script_name.is_empty() {
562                None
563            } else {
564                Some(&self.script_name)
565            }
566        } else {
567            self.positional.get(n - 1).map(|s| s.as_str())
568        }
569    }
570
571    /// Get all positional arguments as a slice ($@).
572    pub fn all_args(&self) -> &[String] {
573        &self.positional
574    }
575
576    /// Get the count of positional arguments ($#).
577    pub fn arg_count(&self) -> usize {
578        self.positional.len()
579    }
580
581    /// Check if error-exit mode is active (set -e and not suppressed).
582    ///
583    /// Returns false when inside the left side of `&&` or `||` chains,
584    /// matching bash behavior where those operators handle failure themselves.
585    pub fn error_exit_enabled(&self) -> bool {
586        self.error_exit && self.errexit_suppressed == 0
587    }
588
589    /// Set error-exit mode (set -e / set +e).
590    pub fn set_error_exit(&mut self, enabled: bool) {
591        self.error_exit = enabled;
592    }
593
594    /// Suppress errexit temporarily (for `&&`/`||` left side).
595    pub fn suppress_errexit(&mut self) {
596        self.errexit_suppressed += 1;
597    }
598
599    /// Unsuppress errexit (after `&&`/`||` left side completes).
600    pub fn unsuppress_errexit(&mut self) {
601        self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
602    }
603
604    /// Check if AST display mode is enabled (kaish-ast -on).
605    pub fn show_ast(&self) -> bool {
606        self.show_ast
607    }
608
609    /// Set AST display mode (kaish-ast -on / kaish-ast -off).
610    pub fn set_show_ast(&mut self, enabled: bool) {
611        self.show_ast = enabled;
612    }
613
614    /// Check if trash mode is enabled (set -o trash).
615    pub fn trash_enabled(&self) -> bool {
616        self.trash_enabled
617    }
618
619    /// Set trash mode (set -o trash / set +o trash).
620    pub fn set_trash_enabled(&mut self, enabled: bool) {
621        self.trash_enabled = enabled;
622    }
623
624    /// Get the maximum file size for trash (bytes).
625    pub fn trash_max_size(&self) -> u64 {
626        self.trash_max_size
627    }
628
629    /// Set the maximum file size for trash (bytes).
630    pub fn set_trash_max_size(&mut self, size: u64) {
631        self.trash_max_size = size;
632    }
633
634    /// Check if glob expansion is enabled (set -o glob, default true).
635    pub fn glob_enabled(&self) -> bool {
636        self.glob_enabled
637    }
638
639    /// Set glob expansion mode (set -o glob / set +o glob).
640    pub fn set_glob_enabled(&mut self, enabled: bool) {
641        self.glob_enabled = enabled;
642    }
643
644    /// Mark a variable as exported (visible to child processes).
645    ///
646    /// The variable doesn't need to exist yet; it will be exported when set.
647    pub fn export(&mut self, name: impl Into<String>) {
648        self.exported.insert(name.into());
649    }
650
651    /// Check if a variable is marked for export.
652    pub fn is_exported(&self, name: &str) -> bool {
653        self.exported.contains(name)
654    }
655
656    /// Set a variable in the **innermost** frame and mark it as exported.
657    ///
658    /// Used for frame-scoped overlays (`execute_with_vars`, `FOO=bar cmd`) and
659    /// for seeding root-frame exports at construction. For the `export`
660    /// builtin's assignment form use [`set_exported_global`](Self::set_exported_global)
661    /// so the value survives a function return (shared-scope semantics).
662    pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
663        let name = name.into();
664        self.set(&name, value);
665        self.export(name);
666    }
667
668    /// Set a variable with **global** (shared-scope) semantics and mark it as
669    /// exported. This is `export NAME=VALUE`: like a plain assignment, the value
670    /// updates an existing variable wherever it lives or lands in the root frame,
671    /// so it persists past a function return rather than dying with the
672    /// function's frame.
673    pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
674        let name = name.into();
675        self.set_global(&name, value);
676        self.export(name);
677    }
678
679    /// Unmark a variable from export.
680    pub fn unexport(&mut self, name: &str) {
681        self.exported.remove(name);
682    }
683
684    /// Get all exported variables with their values.
685    ///
686    /// Only returns variables that exist and are marked for export.
687    pub fn exported_vars(&self) -> Vec<(String, Value)> {
688        let mut result = Vec::new();
689        for name in &self.exported {
690            if let Some(value) = self.get(name) {
691                result.push((name.clone(), value.clone()));
692            }
693        }
694        result.sort_by(|(a, _), (b, _)| a.cmp(b));
695        result
696    }
697
698    /// Get all exported variable names.
699    pub fn exported_names(&self) -> Vec<&str> {
700        let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
701        names.sort();
702        names
703    }
704
705    /// Resolve a variable path: `${VAR}`, `${xs[0]}`, `${r[key]}`, `${a[b][c]}`.
706    ///
707    /// The first segment is the root name; the rest are bracket subscripts,
708    /// walked left to right into the root's `Value::Json`. A subscript landing
709    /// on a JSON scalar unwraps to a native `Value` (envelope-free); a subscript
710    /// landing on a collection stays `Value::Json`. `$?` resolves to the
711    /// previous command's exit code (bare only).
712    ///
713    /// Traversal borrows into the root's JSON tree and clones only the selected
714    /// leaf (a slice builds a new list); the whole-root clone is never taken, so
715    /// repeated `${u[$k]}` in a loop stays O(depth), not O(root size). The
716    /// per-hop classification lives in `resolve_step`, shared with the future
717    /// lvalue-write walk so read and write can never diverge.
718    ///
719    /// Errors distinguish an undefined root (soft) from a loud path error (see
720    /// [`PathError`]).
721    pub fn resolve_path(&self, path: &VarPath) -> Result<Value, PathError> {
722        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
723            // Empty path, or a first segment the parser never emits as root.
724            return Err(PathError::UndefinedRoot(String::new()));
725        };
726
727        // Special case: $? (last result) — bare only.
728        if root_name == "?" {
729            if path.segments.len() == 1 {
730                return Ok(Value::Int(self.last_result.code));
731            }
732            return Err(PathError::Shape(
733                "$? is the POSIX exit code, not a collection — use `kaish-last` for structured data"
734                    .to_string(),
735            ));
736        }
737
738        let root = self
739            .get(root_name)
740            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
741
742        // Bare `${VAR}`: return the stored value unchanged — no subscript, no
743        // envelope unwrap.
744        let subscripts = &path.segments[1..];
745        if subscripts.is_empty() {
746            return Ok(root.clone());
747        }
748
749        // A leading dotted segment is brackets-only regardless of the root's
750        // type (matches the per-hop Field-before-container precedence in
751        // `resolve_step`, which the root-collection check below would otherwise
752        // preempt on a scalar root).
753        if let Some(VarSegment::Field(name)) = subscripts.first() {
754            return Err(dotted_access_error(root_name, name));
755        }
756
757        // Subscripted: the root must be a collection to descend into. A scalar
758        // root reports the same "not a collection" message a mid-path scalar
759        // would (via `resolve_step`).
760        let root_json = match root {
761            Value::Json(j) => j,
762            other => {
763                return Err(PathError::Shape(format!(
764                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
765                    type_name(other)
766                )))
767            }
768        };
769
770        // Walk the subscripts, borrowing into the tree; only a slice (which
771        // builds a new list) and the terminal unwrap allocate. `prefix`
772        // accumulates the path walked so far so a nested failure names the real
773        // path (`${a[b][9]}`, not `${a[9]}`).
774        let mut current = Cow::Borrowed(root_json);
775        let mut prefix = root_name.clone();
776        for seg in subscripts {
777            let step = resolve_step(&current, seg, self, &prefix)?;
778            current = descend(current, step, &prefix)?;
779            prefix.push_str(&render_segment(seg));
780        }
781        Ok(json_to_value_no_envelope(current.into_owned()))
782    }
783
784    /// Write a value into a collection lvalue path: `xs[0]=9`,
785    /// `user[email]=amy@example.com`, `services[web][port]=9090`.
786    ///
787    /// Shares `resolve_step` with [`resolve_path`](Self::resolve_path) so
788    /// classification (bounds, shape) never drifts between read and write.
789    /// The walk itself diverges at the leaf: every intermediate hop requires
790    /// the child to already exist (`descend_mut` — **no autovivification**),
791    /// while the final hop may insert a new record key (`apply_leaf_write`) —
792    /// the ONLY thing a path-set may create. A list index write is in-bounds
793    /// update only (`resolve_step`'s `classify_index` already turns an
794    /// out-of-bounds index into a loud `Absence`); `push` is how lists grow.
795    /// A slice lvalue (`xs[0:2]=…`) is always a `Shape` error.
796    ///
797    /// The root must already be defined (`UndefinedRoot`) and be a collection
798    /// (`Shape` for a scalar root) — same rule as a read. On success the
799    /// mutated root replaces the old value via `set_global`. A bracket-path
800    /// write updates the variable wherever it lives and ignores `local`,
801    /// because it mutates an existing binding instead of creating one. See
802    /// `docs/LANGUAGE.md`, "Assignment — bracket-path lvalues".
803    pub fn walk_write(&mut self, path: &VarPath, value: Value) -> Result<(), PathError> {
804        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
805            return Err(PathError::UndefinedRoot(String::new()));
806        };
807
808        let root = self
809            .get(root_name)
810            .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
811
812        let mut root_json = match root {
813            Value::Json(j) => j.clone(),
814            other => {
815                return Err(PathError::Shape(format!(
816                    "${{{root_name}…}}: cannot subscript {} — it is not a collection",
817                    type_name(other)
818                )))
819            }
820        };
821
822        let subscripts = &path.segments[1..];
823        let Some((last, intermediates)) = subscripts.split_last() else {
824            // A bare name never reaches walk_write — the kernel routes a
825            // one-segment path through set/set_global. Guard defensively
826            // rather than silently no-op.
827            return Err(PathError::Shape(format!(
828                "{root_name}: assignment target has no subscript"
829            )));
830        };
831
832        let mut current = &mut root_json;
833        let mut prefix = root_name.clone();
834        for seg in intermediates {
835            let step = resolve_step(current, seg, self, &prefix)?;
836            current = descend_mut(current, step, &prefix)?;
837            prefix.push_str(&render_segment(seg));
838        }
839
840        let step = resolve_step(current, last, self, &prefix)?;
841        apply_leaf_write(current, step, value_to_json(&value), &prefix)?;
842
843        self.set_global(root_name.clone(), Value::Json(root_json));
844        Ok(())
845    }
846
847    /// Append value(s) to a list variable, in place: a top-level bareword
848    /// target (`push xs val`) or a bracket-path target
849    /// (`push services[web][tags] item`).
850    ///
851    /// The target must already exist and be a list — an undefined root, a
852    /// non-list leaf, or a missing intermediate hop is a loud error, never a
853    /// silent create or autoviv. See `docs/LANGUAGE.md`, "Assignment —
854    /// bracket-path lvalues + `push`". Intermediate hops share `walk_write`'s
855    /// `resolve_step`/`descend_mut`, so a `push` path and an assignment path
856    /// classify identically. Only the final hop differs: it appends instead
857    /// of replacing.
858    pub fn walk_append(&mut self, path: &VarPath, values: Vec<Value>) -> Result<(), String> {
859        let Some(VarSegment::Field(root_name)) = path.segments.first() else {
860            return Err("push: target has no root".to_string());
861        };
862        let root_name = root_name.clone();
863        let current = self
864            .get(&root_name)
865            .ok_or_else(|| format!("push: {root_name} is not defined"))?
866            .clone();
867
868        let subscripts = &path.segments[1..];
869        if subscripts.is_empty() {
870            if !matches!(current, Value::Json(serde_json::Value::Array(_))) {
871                return Err(format!("push: {root_name} is not a list ({})", type_name(&current)));
872            }
873            let Value::Json(serde_json::Value::Array(mut arr)) = current else {
874                unreachable!("checked above")
875            };
876            arr.extend(values.iter().map(value_to_json));
877            self.set_global(root_name, Value::Json(serde_json::Value::Array(arr)));
878            return Ok(());
879        }
880
881        let mut root_json = match current {
882            Value::Json(j) => j,
883            other => {
884                return Err(format!(
885                    "push: {root_name}…: cannot subscript {} — it is not a collection",
886                    type_name(&other)
887                ))
888            }
889        };
890
891        // Walk every subscript — including the last — with the shared
892        // no-autoviv intermediate walker: the container the values append
893        // into must already exist, matching `walk_write`'s policy.
894        let mut cur = &mut root_json;
895        let mut prefix = root_name.clone();
896        for seg in subscripts {
897            let step = resolve_step(cur, seg, self, &prefix)
898                .map_err(|e| push_path_error_message(e, &root_name))?;
899            cur = descend_mut(cur, step, &prefix)
900                .map_err(|e| push_path_error_message(e, &root_name))?;
901            prefix.push_str(&render_segment(seg));
902        }
903
904        let serde_json::Value::Array(arr) = cur else {
905            return Err(format!(
906                "push: {prefix} is not a list ({})",
907                type_name(&json_to_value_no_envelope(cur.clone()))
908            ));
909        };
910        arr.extend(values.iter().map(value_to_json));
911        self.set_global(root_name, Value::Json(root_json));
912        Ok(())
913    }
914
915    /// Check if a variable exists in any frame.
916    pub fn contains(&self, name: &str) -> bool {
917        self.get(name).is_some()
918    }
919
920    /// Get all variable names in scope (for debugging/introspection).
921    pub fn all_names(&self) -> Vec<&str> {
922        let mut names: Vec<&str> = self
923            .frames
924            .iter()
925            .flat_map(|f| f.keys().map(|s| s.as_str()))
926            .collect();
927        names.sort();
928        names.dedup();
929        names
930    }
931
932    /// Get all variables as (name, value) pairs.
933    ///
934    /// Variables are deduplicated, with inner frames shadowing outer ones.
935    pub fn all(&self) -> Vec<(String, Value)> {
936        let mut result = std::collections::HashMap::new();
937        // Iterate outer to inner so inner frames override
938        for frame in self.frames.iter() {
939            for (name, value) in frame {
940                result.insert(name.clone(), value.clone());
941            }
942        }
943        let mut pairs: Vec<_> = result.into_iter().collect();
944        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
945        pairs
946    }
947}
948
949impl Default for Scope {
950    fn default() -> Self {
951        Self::new()
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn new_scope_has_one_frame() {
961        let scope = Scope::new();
962        assert_eq!(scope.frames.len(), 1);
963    }
964
965    #[test]
966    fn set_and_get_variable() {
967        let mut scope = Scope::new();
968        scope.set("X", Value::Int(42));
969        assert_eq!(scope.get("X"), Some(&Value::Int(42)));
970    }
971
972    #[test]
973    fn get_nonexistent_returns_none() {
974        let scope = Scope::new();
975        assert_eq!(scope.get("MISSING"), None);
976    }
977
978    #[test]
979    fn inner_frame_shadows_outer() {
980        let mut scope = Scope::new();
981        scope.set("X", Value::Int(1));
982        scope.push_frame();
983        scope.set("X", Value::Int(2));
984        assert_eq!(scope.get("X"), Some(&Value::Int(2)));
985        scope.pop_frame();
986        assert_eq!(scope.get("X"), Some(&Value::Int(1)));
987    }
988
989    #[test]
990    fn inner_frame_can_see_outer_vars() {
991        let mut scope = Scope::new();
992        scope.set("OUTER", Value::String("visible".into()));
993        scope.push_frame();
994        assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
995    }
996
997    #[test]
998    fn resolve_simple_path() {
999        let mut scope = Scope::new();
1000        scope.set("NAME", Value::String("Alice".into()));
1001
1002        let path = VarPath::simple("NAME");
1003        assert_eq!(
1004            scope.resolve_path(&path),
1005            Ok(Value::String("Alice".into()))
1006        );
1007    }
1008
1009    #[test]
1010    fn resolve_bare_last_result_returns_exit_code() {
1011        let mut scope = Scope::new();
1012        scope.set_last_result(ExecResult::failure(127, "not found"));
1013
1014        let path = VarPath {
1015            segments: vec![VarSegment::Field("?".into())],
1016        };
1017        assert_eq!(scope.resolve_path(&path), Ok(Value::Int(127)));
1018    }
1019
1020    #[test]
1021    fn resolve_last_result_field_access_is_rejected() {
1022        // Field access on $? was removed — use `kaish-last` for structured data.
1023        // The resolver now returns a loud error; the validator also catches it
1024        // earlier with a specific error code for actionable diagnostics.
1025        let mut scope = Scope::new();
1026        scope.set_last_result(ExecResult::success_with_data(
1027            "1",
1028            Value::Json(serde_json::json!({"count": 5})),
1029        ));
1030
1031        let path = VarPath {
1032            segments: vec![
1033                VarSegment::Field("?".into()),
1034                VarSegment::Field("data".into()),
1035            ],
1036        };
1037        assert!(matches!(
1038            scope.resolve_path(&path),
1039            Err(PathError::Shape(_))
1040        ));
1041    }
1042
1043    #[test]
1044    fn resolve_dotted_access_on_scalar_is_a_loud_error() {
1045        let mut scope = Scope::new();
1046        scope.set("X", Value::Int(42));
1047
1048        // Dotted access `${X.invalid}` — brackets-only, so it's a loud error.
1049        let path = VarPath {
1050            segments: vec![
1051                VarSegment::Field("X".into()),
1052                VarSegment::Field("invalid".into()),
1053            ],
1054        };
1055        assert!(matches!(
1056            scope.resolve_path(&path),
1057            Err(PathError::Shape(_))
1058        ));
1059    }
1060
1061    #[test]
1062    fn resolve_undefined_root_is_soft() {
1063        let scope = Scope::new();
1064        let path = VarPath::simple("NOPE");
1065        assert!(matches!(
1066            scope.resolve_path(&path),
1067            Err(PathError::UndefinedRoot(_))
1068        ));
1069    }
1070
1071    // ── PathError classification (Absence vs Shape) ─────────────────────────
1072    // Pins the three-way split: `${path:-default}` (a later commit) leans on
1073    // Absence-vs-Shape, so a misclassification here is a real semantic bug, not
1074    // cosmetics. All three stay loud for a bare access.
1075
1076    /// Build `${root[seg]}` with one bracket subscript.
1077    fn subscripted(scope: &mut Scope, root: &str, value: serde_json::Value, seg: VarSegment) -> Result<Value, PathError> {
1078        scope.set(root, Value::Json(value));
1079        let path = VarPath {
1080            segments: vec![VarSegment::Field(root.into()), seg],
1081        };
1082        scope.resolve_path(&path)
1083    }
1084
1085    #[test]
1086    fn out_of_bounds_index_is_absence() {
1087        let mut scope = Scope::new();
1088        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Index(9));
1089        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1090    }
1091
1092    #[test]
1093    fn missing_record_key_is_absence() {
1094        let mut scope = Scope::new();
1095        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Key("nope".into()));
1096        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1097    }
1098
1099    #[test]
1100    fn string_key_on_a_list_is_shape() {
1101        let mut scope = Scope::new();
1102        let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Key("web".into()));
1103        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1104    }
1105
1106    #[test]
1107    fn integer_index_on_a_record_is_shape() {
1108        let mut scope = Scope::new();
1109        let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Index(0));
1110        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1111    }
1112
1113    #[test]
1114    fn subscripting_a_scalar_is_shape() {
1115        let mut scope = Scope::new();
1116        scope.set("s", Value::String("hello".into()));
1117        let path = VarPath {
1118            segments: vec![VarSegment::Field("s".into()), VarSegment::Index(0)],
1119        };
1120        assert!(matches!(scope.resolve_path(&path), Err(PathError::Shape(_))));
1121    }
1122
1123    #[test]
1124    fn unset_dynamic_key_is_undefined_root_not_absence() {
1125        // `${r[$k]}` with `$k` unset: the *variable* is missing, so it's
1126        // UndefinedRoot-class (which `:-` treats as absence), not a Shape error.
1127        let mut scope = Scope::new();
1128        let r = subscripted(
1129            &mut scope,
1130            "r",
1131            serde_json::json!({"name": "amy"}),
1132            VarSegment::Dynamic("k".into()),
1133        );
1134        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1135    }
1136
1137    #[test]
1138    fn contains_finds_variable() {
1139        let mut scope = Scope::new();
1140        scope.set("EXISTS", Value::Bool(true));
1141        assert!(scope.contains("EXISTS"));
1142        assert!(!scope.contains("MISSING"));
1143    }
1144
1145    #[test]
1146    fn all_names_lists_variables() {
1147        let mut scope = Scope::new();
1148        scope.set("A", Value::Int(1));
1149        scope.set("B", Value::Int(2));
1150        scope.push_frame();
1151        scope.set("C", Value::Int(3));
1152
1153        let names = scope.all_names();
1154        assert!(names.contains(&"A"));
1155        assert!(names.contains(&"B"));
1156        assert!(names.contains(&"C"));
1157    }
1158
1159    #[test]
1160    #[should_panic(expected = "cannot pop the root scope frame")]
1161    fn pop_root_frame_panics() {
1162        let mut scope = Scope::new();
1163        scope.pop_frame();
1164    }
1165
1166    #[test]
1167    fn positional_params_basic() {
1168        let mut scope = Scope::new();
1169        scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
1170
1171        // $0 is the script/tool name
1172        assert_eq!(scope.get_positional(0), Some("my_tool"));
1173        // $1, $2, $3 are the arguments
1174        assert_eq!(scope.get_positional(1), Some("arg1"));
1175        assert_eq!(scope.get_positional(2), Some("arg2"));
1176        assert_eq!(scope.get_positional(3), Some("arg3"));
1177        // $4 doesn't exist
1178        assert_eq!(scope.get_positional(4), None);
1179    }
1180
1181    #[test]
1182    fn positional_params_empty() {
1183        let scope = Scope::new();
1184        // No positional params set
1185        assert_eq!(scope.get_positional(0), None);
1186        assert_eq!(scope.get_positional(1), None);
1187        assert_eq!(scope.arg_count(), 0);
1188        assert!(scope.all_args().is_empty());
1189    }
1190
1191    #[test]
1192    fn all_args_returns_slice() {
1193        let mut scope = Scope::new();
1194        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1195
1196        let args = scope.all_args();
1197        assert_eq!(args, &["a", "b", "c"]);
1198    }
1199
1200    #[test]
1201    fn arg_count_returns_count() {
1202        let mut scope = Scope::new();
1203        scope.set_positional("test", vec!["one".into(), "two".into()]);
1204
1205        assert_eq!(scope.arg_count(), 2);
1206    }
1207
1208    #[test]
1209    fn export_marks_variable() {
1210        let mut scope = Scope::new();
1211        scope.set("X", Value::Int(42));
1212
1213        assert!(!scope.is_exported("X"));
1214        scope.export("X");
1215        assert!(scope.is_exported("X"));
1216    }
1217
1218    #[test]
1219    fn set_exported_sets_and_exports() {
1220        let mut scope = Scope::new();
1221        scope.set_exported("PATH", Value::String("/usr/bin".into()));
1222
1223        assert!(scope.is_exported("PATH"));
1224        assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
1225    }
1226
1227    #[test]
1228    fn unexport_removes_export_marker() {
1229        let mut scope = Scope::new();
1230        scope.set_exported("VAR", Value::Int(1));
1231        assert!(scope.is_exported("VAR"));
1232
1233        scope.unexport("VAR");
1234        assert!(!scope.is_exported("VAR"));
1235        // Variable still exists, just not exported
1236        assert!(scope.get("VAR").is_some());
1237    }
1238
1239    #[test]
1240    fn exported_vars_returns_only_exported_with_values() {
1241        let mut scope = Scope::new();
1242        scope.set_exported("A", Value::Int(1));
1243        scope.set_exported("B", Value::Int(2));
1244        scope.set("C", Value::Int(3)); // Not exported
1245        scope.export("D"); // Exported but no value
1246
1247        let exported = scope.exported_vars();
1248        assert_eq!(exported.len(), 2);
1249        assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
1250        assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
1251    }
1252
1253    #[test]
1254    fn exported_names_returns_sorted_names() {
1255        let mut scope = Scope::new();
1256        scope.export("Z");
1257        scope.export("A");
1258        scope.export("M");
1259
1260        let names = scope.exported_names();
1261        assert_eq!(names, vec!["A", "M", "Z"]);
1262    }
1263
1264    // ── walk_write (lvalue assignment) ──────────────────────────────────────
1265
1266    /// Build `xs[seg]=value` and apply it.
1267    fn write_at(
1268        scope: &mut Scope,
1269        root: &str,
1270        segs: Vec<VarSegment>,
1271    ) -> Result<(), PathError> {
1272        let mut segments = vec![VarSegment::Field(root.into())];
1273        segments.extend(segs);
1274        scope.walk_write(&VarPath { segments }, Value::Int(0))
1275    }
1276
1277    #[test]
1278    fn walk_write_list_index_update() {
1279        let mut scope = Scope::new();
1280        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1281        let path = VarPath {
1282            segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(0)],
1283        };
1284        scope.walk_write(&path, Value::Int(9)).expect("write should succeed");
1285        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([9, 2, 3]))));
1286    }
1287
1288    #[test]
1289    fn walk_write_negative_index() {
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(-1)],
1294        };
1295        scope.walk_write(&path, Value::Int(7)).expect("write should succeed");
1296        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([1, 2, 7]))));
1297    }
1298
1299    #[test]
1300    fn walk_write_inserts_a_new_record_key() {
1301        let mut scope = Scope::new();
1302        scope.set("u", Value::Json(serde_json::json!({"port": 8080})));
1303        let path = VarPath {
1304            segments: vec![VarSegment::Field("u".into()), VarSegment::Key("host".into())],
1305        };
1306        scope
1307            .walk_write(&path, Value::String("localhost".into()))
1308            .expect("write should succeed");
1309        assert_eq!(
1310            scope.get("u"),
1311            Some(&Value::Json(serde_json::json!({"port": 8080, "host": "localhost"})))
1312        );
1313    }
1314
1315    #[test]
1316    fn walk_write_deep_path_updates_nested_key() {
1317        let mut scope = Scope::new();
1318        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1319        let path = VarPath {
1320            segments: vec![
1321                VarSegment::Field("s".into()),
1322                VarSegment::Key("web".into()),
1323                VarSegment::Key("port".into()),
1324            ],
1325        };
1326        scope.walk_write(&path, Value::Int(9000)).expect("write should succeed");
1327        assert_eq!(
1328            scope.get("s"),
1329            Some(&Value::Json(serde_json::json!({"web": {"port": 9000}})))
1330        );
1331    }
1332
1333    #[test]
1334    fn walk_write_out_of_bounds_index_is_absence() {
1335        let mut scope = Scope::new();
1336        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1337        let r = write_at(&mut scope, "xs", vec![VarSegment::Index(9)]);
1338        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1339    }
1340
1341    #[test]
1342    fn walk_write_missing_intermediate_is_absence_no_autoviv() {
1343        let mut scope = Scope::new();
1344        scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1345        let r = write_at(
1346            &mut scope,
1347            "s",
1348            vec![VarSegment::Key("api".into()), VarSegment::Key("port".into())],
1349        );
1350        assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1351        // The root is untouched — no partial autovivification.
1352        assert_eq!(
1353            scope.get("s"),
1354            Some(&Value::Json(serde_json::json!({"web": {"port": 8080}})))
1355        );
1356    }
1357
1358    #[test]
1359    fn walk_write_scalar_root_is_shape() {
1360        let mut scope = Scope::new();
1361        scope.set("y", Value::String("hi".into()));
1362        let r = write_at(&mut scope, "y", vec![VarSegment::Index(0)]);
1363        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1364    }
1365
1366    #[test]
1367    fn walk_write_undefined_root_is_undefined_root() {
1368        let mut scope = Scope::new();
1369        let r = write_at(&mut scope, "z", vec![VarSegment::Index(0)]);
1370        assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1371    }
1372
1373    #[test]
1374    fn walk_write_slice_lvalue_is_shape() {
1375        let mut scope = Scope::new();
1376        scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1377        let r = write_at(&mut scope, "xs", vec![VarSegment::Slice(Some(0), Some(2))]);
1378        assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1379    }
1380
1381    // ── walk_append (push) ──────────────────────────────────────────────────
1382
1383    #[test]
1384    fn walk_append_extends_a_list_in_place() {
1385        let mut scope = Scope::new();
1386        scope.set("xs", Value::Json(serde_json::json!(["a", "b"])));
1387        scope
1388            .walk_append(&VarPath::simple("xs"), vec![Value::String("c".into())])
1389            .expect("push should succeed");
1390        assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!(["a", "b", "c"]))));
1391    }
1392
1393    #[test]
1394    fn walk_append_undefined_target_is_a_loud_error() {
1395        let mut scope = Scope::new();
1396        let r = scope.walk_append(&VarPath::simple("nope"), vec![Value::Int(1)]);
1397        assert!(r.is_err(), "expected a loud error for an undefined target");
1398    }
1399
1400    #[test]
1401    fn walk_append_non_list_target_is_a_loud_error() {
1402        let mut scope = Scope::new();
1403        scope.set("y", Value::String("hi".into()));
1404        let r = scope.walk_append(&VarPath::simple("y"), vec![Value::Int(1)]);
1405        assert!(r.is_err(), "expected a loud error for a non-list target");
1406    }
1407
1408    #[test]
1409    fn walk_append_bracket_path_extends_a_nested_list_in_place() {
1410        let mut scope = Scope::new();
1411        scope.set(
1412            "services",
1413            Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
1414        );
1415        let path = VarPath {
1416            segments: vec![
1417                VarSegment::Field("services".into()),
1418                VarSegment::Key("web".into()),
1419                VarSegment::Key("tags".into()),
1420            ],
1421        };
1422        scope
1423            .walk_append(&path, vec![Value::String("b".into())])
1424            .expect("bracket-path push should succeed");
1425        assert_eq!(
1426            scope.get("services"),
1427            Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
1428        );
1429    }
1430
1431    #[test]
1432    fn walk_append_bracket_path_missing_intermediate_is_a_loud_error() {
1433        let mut scope = Scope::new();
1434        scope.set("services", Value::Json(serde_json::json!({})));
1435        let path = VarPath {
1436            segments: vec![
1437                VarSegment::Field("services".into()),
1438                VarSegment::Key("web".into()),
1439                VarSegment::Key("tags".into()),
1440            ],
1441        };
1442        let r = scope.walk_append(&path, vec![Value::String("x".into())]);
1443        assert!(r.is_err(), "expected a loud error for a missing intermediate");
1444    }
1445
1446    #[test]
1447    fn walk_append_bracket_path_non_list_leaf_is_a_loud_error() {
1448        let mut scope = Scope::new();
1449        scope.set(
1450            "services",
1451            Value::Json(serde_json::json!({"web": {"port": 8080}})),
1452        );
1453        let path = VarPath {
1454            segments: vec![
1455                VarSegment::Field("services".into()),
1456                VarSegment::Key("web".into()),
1457                VarSegment::Key("port".into()),
1458            ],
1459        };
1460        let r = scope.walk_append(&path, vec![Value::Int(1)]);
1461        assert!(r.is_err(), "expected a loud error for a non-list leaf");
1462    }
1463}