blue-lang-cli 0.0.56

The blue command line: run, fmt, ast, erase, check.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! blue's typed configuration surface — [`shikumi::TieredConfig`], four fields.
//!
//! ```text
//! blue config bare        # zero-opinion floor
//! blue config default     # the prescribed defaults, which are the constants
//! blue config env         # resolved from BLUE_TIER
//! blue config default --diff bare
//! ```
//!
//! # The rule that decides what may live here
//!
//! **A knob is admissible only if it is a BOUND, never a preference.** Raising
//! or lowering either field below changes no program's meaning: the same source
//! either resolves/parses, or is refused for exceeding a limit. Nothing in
//! between. That is what makes exposing them safe — a bound cannot freeze a
//! design guess as a public interface, because there is no design to guess at.
//! A *preference* (how something should look, which semantics to pick) would
//! do exactly that, so preferences do not go in this file.
//!
//! Each field also had to already have a **shipped, overridable default in
//! code**. All four do, and the `prescribed_default()` below returns those
//! constants *by name* rather than copying their values — so the config cannot
//! drift from what the code actually does.
//!
//! # Why these four, and not the three the waiver used to name
//!
//! blue's `pending-shikumi: M1` waiver claimed three knobs were "blocked on an
//! unsettled design". Measured 2026-08-01, two of those three are not blocked —
//! they are **settled against being configurable at all**, which is a different
//! and much stronger statement:
//!
//! * **Formatter width** — settled AGAINST. `blue_lang_fmt`'s module docs say
//!   it outright: "There is no configuration type in this crate, and that is
//!   the feature… there is nowhere to put a knob." `theory/BLUE.md` §0 makes
//!   FORM an axis with exactly one way to write a thing, and the content-
//!   addressed identity of §V.16.1 rests on that single rendering. A width
//!   option would forfeit both. Typing it would be a regression, not progress.
//!
//! * **Posture ceiling** — settled elsewhere. §V.24 moved ceilings to the
//!   ROOT, as a Bluefile input: `blue_lang_waku::Waku` deliberately carries no
//!   ceiling ("a frame is a *position*, and narrowing is something the root
//!   does to it"), and `blue_lang_bidama::resolve(bidama, ceiling)` takes it as
//!   an argument. A daemon-level ceiling knob would re-introduce the
//!   package-declares-a-ceiling shape §V.24 names as Cargo's documented
//!   anti-pattern.
//!
//! * **Execution budget** — was unsettled for a concrete reason: no default
//!   constant existed to expose. tatara-lisp-eval 0.3.63 gave both executors
//!   one budget and made exceeding it a catchable error instead of a stack
//!   overflow, so it is now two bounds here — `max_call_depth` (default
//!   `DEFAULT_MAX_DEPTH`) and `max_steps` (default unbounded). Both pass the
//!   admission rule: raising either changes no terminating program's value,
//!   only whether a runaway is refused — and neither DEFAULT may change what a
//!   program does today, which is why depth (past which blue used to abort)
//!   is bounded by default and steps (past which programs ran fine) are not.
//!
//! # Tier honesty
//!
//! The four-field surface is **only-mitigated** against a fifth knob creeping
//! in — `the_surface_is_exactly_four_knobs` is an exhaustive destructure, so
//! adding a field is an `E0027` compile error in the test binary rather than
//! something structurally unrepresentable. It forces acknowledgement, not
//! justification.

use serde::{Deserialize, Serialize};
use shikumi::{ConfigTier, TieredConfig};

/// The environment variable naming which tier to materialize.
pub const TIER_ENV: &str = "BLUE_TIER";

/// The environment variable naming a YAML config file.
///
/// This is the name substrate's module trio derives from `blue`
/// (`mkModuleTrio` uppercases and appends `_CONFIG`), so the nix side and this
/// constant have to agree — `blue_config_env_matches_the_module_trio` pins it.
pub const CONFIG_ENV: &str = "BLUE_CONFIG";

/// Every bound the `blue` binary reads from configuration.
///
/// `#[serde(default)]`: a YAML that omits a key takes that key's prescribed
/// default. Without it, a file deployed before a key existed failed to
/// deserialize as a WHOLE, and blue silently ran on every default — the
/// operator's own bounds included — which is how adding `max_call_depth` first
/// broke `solver_max_steps_is_read_from_the_deployed_yaml` (red run,
/// 2026-09-29).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BlueConfig {
    /// Search steps the version solver may take before reporting.
    ///
    /// Read by `blue deps`, which passes it to `Solver::with_max_steps`. The
    /// solver does not learn incompatibilities, so a pathological graph can
    /// search a long time; the bound makes that *report* rather than hang.
    /// Raising it never changes which resolution is correct — only how long
    /// blue is willing to look for it.
    pub solver_max_steps: usize,

    /// Expression/statement nesting the parser accepts before refusing.
    ///
    /// Read by every `blue` subcommand that parses, via
    /// `blue_lang_runtime::parse_with_depth`. Without a bound, deep input does
    /// not fail — it **aborts the process** with a stack overflow that
    /// `catch_unwind` cannot catch. Raising it never changes what a program
    /// means; it only moves the line between "typed `Err`" and "SIGABRT",
    /// which is why the default sits far below the measured overflow point.
    pub max_expr_depth: usize,

    /// Nested (non-tail) calls a program may have alive at once.
    ///
    /// Read by every subcommand that evaluates, via
    /// `blue_lang_runtime::set_execution_bounds`. Past it the call is refused
    /// with a catchable `depth-exceeded` error naming the function; before
    /// tatara-lisp-eval 0.3.63 the same recursion **aborted the process** at
    /// 4-6k frames, where no `try` could see it. The evaluator now grows its
    /// stack on demand, so the bound is this number and not the thread's
    /// stack size. Tail calls do not count against it.
    pub max_call_depth: usize,

    /// Evaluation steps a run may take; `null` (`nil` in `blue.b`) lifts it.
    ///
    /// Set, a loop with no exit ends in a `fuel-exhausted` error naming the
    /// function instead of running until killed; a `try` observes that error
    /// but cannot spend past it. Unbounded by default, because long runs are
    /// behaviour blue has always had: a host running untrusted code sets it.
    pub max_steps: Option<usize>,
}

impl BlueConfig {
    /// The two execution bounds, as the runtime takes them.
    #[must_use]
    pub fn execution_bounds(&self) -> blue_lang_runtime::ExecutionBounds {
        blue_lang_runtime::ExecutionBounds {
            max_call_depth: self.max_call_depth,
            max_steps: self.max_steps,
        }
    }
}

impl Default for BlueConfig {
    fn default() -> Self {
        <Self as TieredConfig>::prescribed_default()
    }
}

impl TieredConfig for BlueConfig {
    fn bare() -> Self {
        Self {
            solver_max_steps: 0,
            max_expr_depth: 0,
            max_call_depth: 0,
            max_steps: Some(0),
        }
    }

    fn prescribed_default() -> Self {
        // The CONSTANTS, never copies of their values. A literal here would be
        // a second place for each bound to be stated, and the two would drift
        // the first time someone tuned one — the `INFIX`-table defect wearing
        // a config hat.
        Self {
            solver_max_steps: blue_lang_pkg::DEFAULT_MAX_STEPS,
            max_expr_depth: blue_lang_syntax::MAX_EXPR_DEPTH,
            max_call_depth: blue_lang_runtime::ExecutionBounds::DEFAULT.max_call_depth,
            max_steps: blue_lang_runtime::ExecutionBounds::DEFAULT.max_steps,
        }
    }
}

/// Resolve the config the `blue` binary should run with.
///
/// Precedence, highest first:
///
/// 1. `BLUE_TIER` — an explicit operator override, so it wins outright.
/// 2. `BLUE_CONFIG` naming a file that exists — the YAML the module trio
///    deploys, overlaid on the prescribed defaults.
/// 3. The prescribed defaults.
///
/// A `BLUE_CONFIG` pointing at a file that is **absent** falls through to (3)
/// rather than failing: the module trio writes the YAML on activation, and a
/// binary that refuses to start between activations would be worse than one
/// that runs on its own defaults.
#[must_use]
pub fn resolve() -> BlueConfig {
    BlueConfig::resolve_tier(tier_from_env())
}

fn tier_from_env() -> ConfigTier {
    if std::env::var_os(TIER_ENV).is_some() {
        return ConfigTier::from_env(TIER_ENV);
    }
    match std::env::var_os(CONFIG_ENV) {
        Some(raw) => {
            let path = std::path::PathBuf::from(raw);
            if path.is_file() {
                ConfigTier::Custom(path)
            } else {
                ConfigTier::Default
            }
        }
        None => ConfigTier::Default,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The gate on "exactly four knobs".
    ///
    /// An exhaustive destructure: a fifth field makes this `E0027 pattern does
    /// not mention field`, so the test binary stops compiling and `cargo test`
    /// goes red. Red run recorded 2026-08-01 by adding a `formatter_width`
    /// field — `error[E0027]: pattern does not mention field
    /// `formatter_width`` — then removing it. Re-recorded 2026-09-29: adding
    /// `max_call_depth` and `max_steps` failed it the same way until the
    /// pattern named them.
    ///
    /// Tier-honest: this catches an addition, it does not make one
    /// unrepresentable. Whoever updates the pattern has to have read the rule
    /// in this module's docs, which is the whole mechanism.
    #[test]
    fn the_surface_is_exactly_four_knobs() {
        let BlueConfig {
            solver_max_steps: _,
            max_expr_depth: _,
            max_call_depth: _,
            max_steps: _,
        } = BlueConfig::prescribed_default();
    }

    #[test]
    fn bare_is_zero_opinion() {
        let b = BlueConfig::bare();
        assert_eq!(b.solver_max_steps, 0);
        assert_eq!(b.max_expr_depth, 0);
        assert_eq!(b.max_call_depth, 0);
        assert_eq!(
            b.max_steps,
            Some(0),
            "the floor runs nothing, like its siblings"
        );
    }

    /// The prescribed tier IS the shipped constants — not a copy of them.
    ///
    /// This is the test that makes the config non-decorative in the other
    /// direction: tune `DEFAULT_MAX_STEPS` or `MAX_EXPR_DEPTH` and the
    /// prescribed tier follows automatically, because it never held a literal.
    #[test]
    fn prescribed_default_is_the_constants_themselves() {
        let d = BlueConfig::prescribed_default();
        assert_eq!(d.solver_max_steps, blue_lang_pkg::DEFAULT_MAX_STEPS);
        assert_eq!(d.max_expr_depth, blue_lang_syntax::MAX_EXPR_DEPTH);
        assert_eq!(
            d.execution_bounds(),
            blue_lang_runtime::ExecutionBounds::DEFAULT
        );
        assert_eq!(d.max_call_depth, tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH);
        assert_eq!(
            d.max_steps, None,
            "a default step bound would end runs that work today"
        );
        assert_eq!(d.solver_max_steps, 100_000, "the shipped value, pinned");
        assert_eq!(d.max_expr_depth, 256, "the shipped value, pinned");
        assert_eq!(d.max_call_depth, 100_000, "the shipped value, pinned");
        assert_eq!(d.max_steps, None, "the shipped value, pinned");
    }

    #[test]
    fn bare_and_default_differ() {
        assert_ne!(BlueConfig::bare(), BlueConfig::prescribed_default());
    }

    #[test]
    fn default_trait_delegates_to_prescribed() {
        assert_eq!(BlueConfig::default(), BlueConfig::prescribed_default());
    }

    #[test]
    fn resolve_tier_dispatches_through_shikumi() {
        assert_eq!(
            BlueConfig::resolve_tier(ConfigTier::Bare),
            BlueConfig::bare()
        );
        assert_eq!(
            BlueConfig::resolve_tier(ConfigTier::Default),
            BlueConfig::prescribed_default()
        );
    }

    /// The progressive fold — the canonical shikumi resolution path — reaches
    /// the same answer as the tier selector, and attributes each leaf.
    #[test]
    fn the_progressive_fold_resolves_the_prescribed_tier() {
        let resolved = BlueConfig::resolve_progressive();
        assert_eq!(*resolved.value(), BlueConfig::prescribed_default());
    }

    /// The nix module trio and this struct must agree on the YAML key names.
    ///
    /// They are a **silent** contract otherwise: serde ignores unknown keys, so
    /// renaming a field here would leave the deployed YAML setting nothing at
    /// all, and blue would run on defaults while an operator read their own
    /// config file and believed it. Reading `flake.nix` is crude, and it is the
    /// only thing on either side that can see both.
    ///
    /// Red run recorded 2026-08-01: a `#[serde(rename = "max_expression_depth")]`
    /// on `max_expr_depth` — the wire-name drift this exists to catch, in its
    /// purest form — turned this red with
    /// ``flake.nix must emit the `max_expression_depth` key``.
    #[test]
    fn every_field_is_emitted_by_the_module_trio() {
        let json =
            serde_json::to_value(BlueConfig::prescribed_default()).expect("BlueConfig serializes");
        let keys: Vec<String> = json
            .as_object()
            .expect("BlueConfig is a struct, so a JSON object")
            .keys()
            .cloned()
            .collect();
        assert_eq!(keys.len(), 4, "four knobs, per this module's docs");

        let flake =
            std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../flake.nix"))
                .expect("the workspace flake is two directories up from this crate");
        for key in keys {
            assert!(
                flake.contains(&key),
                "flake.nix must emit the `{key}` key in its shikumiDefaults — \
                 otherwise the deployed YAML sets nothing and blue silently \
                 runs on defaults"
            );
        }
    }

    /// The env-var name has to match what `mkModuleTrio` derives from the tool
    /// name, or the deployed YAML is never found.
    #[test]
    fn blue_config_env_matches_the_module_trio() {
        // substrate/lib/module-trio.nix:248 — uppercase, `-` → `_`, + "_CONFIG".
        assert_eq!(CONFIG_ENV, "BLUE_CONFIG");
        assert_eq!(TIER_ENV, "BLUE_TIER");
    }

    /// `blue.b` — blue configured in blue — lowers to what shikumi accepts.
    ///
    /// shikumi's `sexp_to_value_root` takes the first top-level form, drops a
    /// leading head symbol, and requires the remainder to be a kwargs list:
    /// non-empty, even length, every even slot an `Atom::Keyword`. That
    /// predicate is re-stated here against the tree blue's OWN parser produces,
    /// which is the honest form of the claim available from inside this repo —
    /// see the note below on why the provider is not called directly.
    ///
    /// **Executed evidence, recorded because this test cannot produce it:**
    /// the shipped `shikumi::blue_provider::load_from_str` was run against this
    /// exact source on 2026-08-01 and returned
    /// `Dict({"max_expr_depth": Num(I64(…)), "solver_max_steps": Num(I64(…))})`,
    /// which then resolved through `BlueConfig::resolve_progressive_with` into
    /// a populated `BlueConfig`. Three near-miss surface forms were measured to
    /// FAIL in the same run; `blue.b`'s header records which and why.
    ///
    /// **Why the provider is not a dependency here.** shikumi's `blue` feature
    /// pins `blue-lang-syntax = "0.0.2"` from crates.io. Turning it on inside
    /// this workspace resolves a SECOND copy of blue's parser beside the local
    /// path dep, so the assertion would be about a published 0.0.2 rather than
    /// the 0.0.9 this repo ships and tests — a test that measures the wrong
    /// parser is worse than one that measures the shape. Closing that gap is
    /// shikumi's bump to make, not blue's.
    #[test]
    fn blue_dot_b_lowers_to_a_shape_shikumi_accepts() {
        let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../blue.b"))
            .expect("blue.b sits at the workspace root");
        let forms = blue_lang_syntax::parse_program(&src).expect("blue.b must parse");
        assert_eq!(forms.len(), 1, "shikumi reads the FIRST form and only it");

        let blue_lang_syntax::Sexp::List(items) = &forms[0] else {
            panic!("a config form must be a list, got {:?}", forms[0]);
        };
        // `sexp_to_value_root`: a leading Symbol is the head and is dropped.
        // For a map literal that head is `hash-map`.
        let start = match items.first() {
            Some(blue_lang_syntax::Sexp::Atom(blue_lang_syntax::Atom::Symbol(s))) => {
                assert_eq!(s, blue_lang_syntax::LOWERED_MAP);
                1
            }
            other => panic!("expected a head symbol, got {other:?}"),
        };
        let rest = &items[start..];

        // `is_kwargs_list`, restated.
        assert!(!rest.is_empty(), "an empty body maps to an empty dict");
        assert_eq!(rest.len() % 2, 0, "kwargs pair up");
        let keys: Vec<&str> = rest
            .iter()
            .step_by(2)
            .map(|s| match s {
                blue_lang_syntax::Sexp::Atom(blue_lang_syntax::Atom::Keyword(k)) => k.as_str(),
                other => panic!(
                    "every key must be a KEYWORD — a string key is `Atom::Str` and \
                     shikumi's kwargs test rejects it. Got {other:?}"
                ),
            })
            .collect();
        assert_eq!(
            keys,
            vec![
                "solver_max_steps",
                "max_expr_depth",
                "max_call_depth",
                "max_steps"
            ]
        );

        // And the values are the prescribed bounds, so the file documents the
        // shipped defaults rather than drifting from them. `nil` is an absent
        // bound (shikumi maps `Sexp::Nil` to none): how an unbounded `Option`
        // field is stated.
        let bounds: Vec<Option<i64>> = rest
            .iter()
            .skip(1)
            .step_by(2)
            .map(|s| match s {
                blue_lang_syntax::Sexp::Atom(blue_lang_syntax::Atom::Int(n)) => Some(*n),
                blue_lang_syntax::Sexp::Nil => None,
                other => panic!("a bound must be an integer or nil, got {other:?}"),
            })
            .collect();
        let d = BlueConfig::prescribed_default();
        assert_eq!(
            bounds,
            vec![
                Some(d.solver_max_steps as i64),
                Some(d.max_expr_depth as i64),
                Some(d.max_call_depth as i64),
                d.max_steps.map(|n| n as i64)
            ],
            "blue.b must state the shipped defaults"
        );
    }

    /// An absent `BLUE_CONFIG` target degrades to the prescribed tier rather
    /// than erroring — see [`resolve`]'s docs for why that is the right choice.
    #[test]
    fn a_missing_config_file_falls_through_to_the_prescribed_tier() {
        let missing = std::path::PathBuf::from("/nonexistent/blue/blue.yaml");
        assert!(!missing.is_file(), "precondition");
        // Exercised through the same predicate `tier_from_env` applies, without
        // mutating process-global env (which would race other tests).
        let tier = if missing.is_file() {
            ConfigTier::Custom(missing)
        } else {
            ConfigTier::Default
        };
        assert_eq!(
            BlueConfig::resolve_tier(tier),
            BlueConfig::prescribed_default()
        );
    }
}