cli-engine 0.9.3

Rust CLI framework for consistent command modules
Documentation
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! End-to-end coverage for the feature-flagging system, driven entirely through
//! [`Cli::run`] the way a real consumer binary would: cascading resolution across
//! module/group/command with an override, pruning a hidden node from `--help` and
//! `--schema` (and the unknown-command error on direct invocation), environment
//! `min_stage`/`feature_overrides` layering (compiled and via `environments.toml`),
//! and the built-in `flags list`/`flags info` introspection commands.
//!
//! Cascading resolution, pruning internals, and env-var precedence already have
//! thorough unit coverage in `src/cli.rs` and `src/environments.rs`; this file
//! proves the same mechanism end to end through the only surface a real consumer
//! CLI uses, rather than re-testing those internals directly.
#![allow(unsafe_code)]
// The env-var test holds `ENV_LOCK` across an `.await` on purpose, to keep the
// mutation race-free for the whole `Cli::new` + `Cli::run` sequence.
#![allow(clippy::await_holding_lock)]

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, MutexGuard};

use cli_engine::{
    Cli, CliConfig, CommandResult, CommandSpec, EnvTable, Environments, GroupSpec, Module,
    RuntimeCommandSpec, RuntimeGroupSpec, Stage,
};
use serde_json::{Value, json};

/// A no-op, unauthenticated leaf command used to populate the fixture trees.
fn trivial_command(name: &'static str) -> RuntimeCommandSpec {
    RuntimeCommandSpec::new(
        CommandSpec::new(name, "Fixture command").no_auth(true),
        async |_credential, _args| Ok(CommandResult::new(json!({ "ok": true }))),
    )
}

/// Builds a `devkit` module: an unflagged top-level group with an unflagged
/// `status` command, and a nested `sandbox` group flagged Experimental whose
/// `peek` command has no flag of its own (so it inherits `sandbox`'s flag).
///
/// `status` is a sibling of the flagged `sandbox` group (not of `peek`): per the
/// engine's pruning semantics an invisible ancestor drops its whole subtree
/// unconditionally, so `peek` could never survive as a *direct* sibling of an
/// inherited-and-hidden command under the same flagged parent. Nesting the flag
/// one level below `devkit` is what lets `status` demonstrate an unrelated,
/// always-visible command surviving right next to a fully pruned subtree.
fn gated_module() -> Module {
    Module::new("Feature Flag Fixtures", |_ctx| {
        RuntimeGroupSpec::new(GroupSpec::new("devkit", "Devkit commands"))
            .with_group(
                RuntimeGroupSpec::new(
                    GroupSpec::new("sandbox", "Experimental sandbox tools")
                        .with_feature_flag("sandbox-flag", Stage::Experimental),
                )
                .with_command(trivial_command("peek")),
            )
            .with_command(trivial_command("status"))
    })
}

#[tokio::test]
async fn cascading_flag_prunes_inherited_descendant_but_keeps_unflagged_sibling() {
    // Default policy: `CliConfig` leaves `min_stage` at its `Stage::Ga` default,
    // so the Experimental `sandbox` subgroup (and everything it cascades into)
    // must be pruned, while the unrelated `status` command stays.
    let cli = Cli::new(
        CliConfig::new("flagcascade", "Flag cascade test", "flagcascade-app")
            .with_module(gated_module()),
    );

    let help = cli.run(["flagcascade", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("status"), "{}", help.rendered);
    assert!(!help.rendered.contains("sandbox"), "{}", help.rendered);

    // The pruned command was never mounted into the clap tree, so dispatching
    // it directly fails the same way a typo'd command would.
    let dispatch = cli.run(["flagcascade", "devkit", "sandbox", "peek"]).await;
    assert_ne!(dispatch.exit_code, 0, "{}", dispatch.rendered);
    assert!(
        dispatch.rendered.contains("unknown command"),
        "{}",
        dispatch.rendered
    );

    // `--schema` does not resurrect it either: the path still resolves to
    // "unknown command", not a schema (or no-schema) envelope.
    let schema = cli
        .run([
            "flagcascade",
            "devkit",
            "sandbox",
            "peek",
            "--schema",
            "--output",
            "json",
        ])
        .await;
    assert_ne!(schema.exit_code, 0, "{}", schema.rendered);
    assert!(
        schema.rendered.contains("unknown command"),
        "{}",
        schema.rendered
    );

    // The unflagged sibling dispatches normally.
    let visible = cli
        .run(["flagcascade", "devkit", "status", "--output", "json"])
        .await;
    assert_eq!(visible.exit_code, 0, "{}", visible.rendered);
    let visible_json: Value = serde_json::from_str(&visible.rendered).expect("json output");
    assert_eq!(visible_json["data"]["ok"], true);
}

#[tokio::test]
async fn permissive_min_stage_reveals_previously_pruned_subtree() {
    // Same tree, but the consumer opts into Experimental visibility, so the
    // subgroup and its inheriting command are mounted and dispatchable.
    let cli = Cli::new(
        CliConfig::new(
            "flagpermissive",
            "Flag permissive test",
            "flagpermissive-app",
        )
        .with_min_stage(Stage::Experimental)
        .with_module(gated_module()),
    );

    let help = cli.run(["flagpermissive", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("sandbox"), "{}", help.rendered);

    let dispatch = cli
        .run([
            "flagpermissive",
            "devkit",
            "sandbox",
            "peek",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(dispatch.exit_code, 0, "{}", dispatch.rendered);
}

#[tokio::test]
async fn environment_min_stage_loosens_consumer_policy_end_to_end() {
    // The `CliConfig` itself stays at its Ga default; only the active
    // ("flagtest-envmin") environment's compiled `min_stage` loosens visibility.
    // Proves the environment layer reaches pruning through the full `Cli::new` +
    // `Cli::run` pipeline, not just `Environments::resolve` in isolation.
    //
    // The environment name is deliberately test-scoped (not a real name like
    // "prod") so its derived `FLAGTEST_ENVMIN_MIN_STAGE` env var cannot collide
    // with an `<ENV>_MIN_STAGE` a developer/CI might have set for a real
    // environment, which would otherwise silently override the compiled
    // `min_stage` this test asserts on. This test therefore needs no `ENV_LOCK`.
    let cli = Cli::new(
        CliConfig::new("flagenv", "Flag environment test", "flagenv-app")
            .with_environments(Arc::new(
                Environments::new("flagtest-envmin").with_environment(
                    "flagtest-envmin",
                    EnvTable::new().with("min_stage", "experimental"),
                ),
            ))
            .with_module(gated_module()),
    );

    let help = cli.run(["flagenv", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("sandbox"), "{}", help.rendered);

    let dispatch = cli
        .run(["flagenv", "devkit", "sandbox", "peek", "--output", "json"])
        .await;
    assert_eq!(dispatch.exit_code, 0, "{}", dispatch.rendered);
}

#[tokio::test]
async fn environment_feature_override_reveals_pruned_subtree_end_to_end() {
    // Distinct from the environment `min_stage` layer above: here both the
    // `CliConfig` and the environment leave `min_stage` at Ga, and it is the
    // environment's compiled per-key `feature_overrides` entry (forcing
    // `sandbox-flag` to Ga) that lifts the otherwise-Experimental subgroup into
    // visibility. Proves the environment feature-override layer (not just
    // `min_stage`) reaches pruning through the full `Cli::new` + `Cli::run`
    // pipeline.
    //
    // The environment name is test-scoped for the same collision reason as the
    // `min_stage` test above: its derived `FLAGTEST_FEATOVERRIDE_*` env vars
    // cannot clash with a real environment's, so no `ENV_LOCK` is needed.
    let cli = Cli::new(
        CliConfig::new(
            "flagenvoverride",
            "Flag environment override test",
            "flagenvoverride-app",
        )
        .with_environments(Arc::new(
            Environments::new("flagtest-featoverride").with_environment(
                "flagtest-featoverride",
                EnvTable::new().with(
                    "feature_overrides",
                    BTreeMap::from([("sandbox-flag", "ga")]),
                ),
            ),
        ))
        .with_module(gated_module()),
    );

    let help = cli.run(["flagenvoverride", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("sandbox"), "{}", help.rendered);

    let dispatch = cli
        .run([
            "flagenvoverride",
            "devkit",
            "sandbox",
            "peek",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(dispatch.exit_code, 0, "{}", dispatch.rendered);
}

#[tokio::test]
async fn environment_min_stage_tightens_permissive_consumer_policy_end_to_end() {
    // The tightening direction, opposite every test above: the `CliConfig` is
    // *permissive* (`min_stage` = Experimental, which on its own reveals the
    // Experimental `sandbox` subgroup), but the active environment raises
    // `min_stage` back up to Ga and re-hides it. Proves the environment layer's
    // unconditional replace in `Cli::new` can strengthen — not only loosen — the
    // consumer's compiled policy, all the way through pruning and dispatch.
    //
    // Compiled-only environment, so no env var and no `ENV_LOCK`; the name is
    // test-scoped for the same collision reason as the loosening tests above.
    let cli = Cli::new(
        CliConfig::new("flagtighten", "Flag tighten test", "flagtighten-app")
            .with_min_stage(Stage::Experimental)
            .with_environments(Arc::new(
                Environments::new("flagtest-tighten")
                    .with_environment("flagtest-tighten", EnvTable::new().with("min_stage", "ga")),
            ))
            .with_module(gated_module()),
    );

    // `sandbox` would be visible under the consumer's Experimental floor alone,
    // but the environment's Ga floor prunes it again.
    let help = cli.run(["flagtighten", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("status"), "{}", help.rendered);
    assert!(!help.rendered.contains("sandbox"), "{}", help.rendered);

    // And the re-hidden node is not dispatchable: it was never mounted.
    let dispatch = cli
        .run([
            "flagtighten",
            "devkit",
            "sandbox",
            "peek",
            "--output",
            "json",
        ])
        .await;
    assert_ne!(dispatch.exit_code, 0, "{}", dispatch.rendered);
    assert!(
        dispatch.rendered.contains("unknown command"),
        "{}",
        dispatch.rendered
    );
}

/// Serializes this file's env-var mutations across parallel test threads.
static ENV_LOCK: Mutex<()> = Mutex::new(());

fn lock() -> MutexGuard<'static, ()> {
    ENV_LOCK
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// RAII guard that restores (or removes) an env var on drop, even on panic.
struct EnvGuard {
    key: &'static str,
    prev: Option<std::ffi::OsString>,
}

impl EnvGuard {
    /// Sets `key` to `value`. Caller must hold [`ENV_LOCK`] for the guard's
    /// entire lifetime.
    fn set(key: &'static str, value: &str) -> Self {
        let prev = std::env::var_os(key);
        // SAFETY: serialized by ENV_LOCK; guard restores/removes on any exit
        // incl. panic.
        unsafe { std::env::set_var(key, value) };
        Self { key, prev }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        // SAFETY: serialized by ENV_LOCK; guard restores/removes on any exit
        // incl. panic.
        unsafe {
            match &self.prev {
                Some(v) => std::env::set_var(self.key, v),
                None => std::env::remove_var(self.key),
            }
        }
    }
}

// A distinctive, test-scoped app id so its derived `GLOBALMINSTAGE_MIN_STAGE`
// env var cannot collide with a real app id a developer/CI might have set.
const GLOBAL_MIN_STAGE_APP_ID: &str = "globalminstage";
const GLOBAL_MIN_STAGE_VAR: &str = "GLOBALMINSTAGE_MIN_STAGE";

#[tokio::test]
async fn global_min_stage_env_var_reveals_previously_pruned_subtree_end_to_end() {
    // Confirms the full `${APP_ID}_MIN_STAGE` env var -> `Cli::new` ->
    // `FlagPolicy` -> tree-pruning -> clap chain works with no `with_environments`
    // configured at all: the global override is independent of the environments
    // system.
    let _lock = lock();
    let _guard = EnvGuard::set(GLOBAL_MIN_STAGE_VAR, "experimental");

    let cli = Cli::new(
        CliConfig::new(
            "globalminstage",
            "Global min-stage test",
            GLOBAL_MIN_STAGE_APP_ID,
        )
        .with_module(gated_module()),
    );

    let help = cli.run(["globalminstage", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("sandbox"), "{}", help.rendered);

    let dispatch = cli
        .run([
            "globalminstage",
            "devkit",
            "sandbox",
            "peek",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(dispatch.exit_code, 0, "{}", dispatch.rendered);
}

const GLOBAL_MIN_STAGE_ENV_APP_ID: &str = "globalminstageenv";
const GLOBAL_MIN_STAGE_ENV_VAR: &str = "GLOBALMINSTAGEENV_MIN_STAGE";

#[tokio::test]
async fn active_environment_tightens_beyond_global_min_stage_env_var_end_to_end() {
    // The global env var loosens the consumer's Ga default to Experimental, but
    // the active environment's own compiled `min_stage` (Ga) re-tightens it —
    // proving the global override is folded in *before* the environment layer,
    // which can still loosen or tighten beyond it.
    let _lock = lock();
    let _guard = EnvGuard::set(GLOBAL_MIN_STAGE_ENV_VAR, "experimental");

    let cli = Cli::new(
        CliConfig::new(
            "globalminstageenv",
            "Global min-stage + environment test",
            GLOBAL_MIN_STAGE_ENV_APP_ID,
        )
        .with_environments(Arc::new(
            Environments::new("globalminstageenv-env").with_environment(
                "globalminstageenv-env",
                EnvTable::new().with("min_stage", "ga"),
            ),
        ))
        .with_module(gated_module()),
    );

    let help = cli.run(["globalminstageenv", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("status"), "{}", help.rendered);
    assert!(!help.rendered.contains("sandbox"), "{}", help.rendered);

    let dispatch = cli
        .run([
            "globalminstageenv",
            "devkit",
            "sandbox",
            "peek",
            "--output",
            "json",
        ])
        .await;
    assert_ne!(dispatch.exit_code, 0, "{}", dispatch.rendered);
    assert!(
        dispatch.rendered.contains("unknown command"),
        "{}",
        dispatch.rendered
    );
}

const MALFORMED_GLOBAL_MIN_STAGE_APP_ID: &str = "malformedminstage";
const MALFORMED_GLOBAL_MIN_STAGE_VAR: &str = "MALFORMEDMINSTAGE_MIN_STAGE";

#[tokio::test]
async fn malformed_global_min_stage_env_var_is_ignored_end_to_end() {
    // A malformed `${APP_ID}_MIN_STAGE` is best-effort, like a malformed
    // config.toml: it is ignored (with a warning logged) rather than failing
    // the run, so the Ga default stays in force and the Experimental subgroup
    // stays pruned.
    let _lock = lock();
    let _guard = EnvGuard::set(MALFORMED_GLOBAL_MIN_STAGE_VAR, "nightly");

    let cli = Cli::new(
        CliConfig::new(
            "malformedminstage",
            "Malformed global min-stage test",
            MALFORMED_GLOBAL_MIN_STAGE_APP_ID,
        )
        .with_module(gated_module()),
    );

    let help = cli.run(["malformedminstage", "devkit"]).await;
    assert_eq!(help.exit_code, 0, "{}", help.rendered);
    assert!(help.rendered.contains("status"), "{}", help.rendered);
    assert!(!help.rendered.contains("sandbox"), "{}", help.rendered);
}

/// Builds a module whose group carries its own feature flag (so it, and its
/// unflagged `list` command, both cascade to `key`/`stage`).
fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
    Module::new("Flags Introspection Fixtures", move |_ctx| {
        RuntimeGroupSpec::new(
            GroupSpec::new(group_name, "Introspection fixture group").with_feature_flag(key, stage),
        )
        .with_command(trivial_command("list"))
    })
}

#[tokio::test]
async fn flags_list_and_info_report_override_and_min_stage_decisions_end_to_end() {
    // One flag key is forced visible by a consumer-level override despite its
    // own Experimental declaration; the other relies solely on `min_stage`.
    // `flags list`/`flags info` should distinguish the two via `decided_by`.
    let cli = Cli::new(
        CliConfig::new("flagintro", "Flags introspection test", "flagintro-app")
            .with_min_stage(Stage::Beta)
            .with_feature_override("override-key", Stage::Ga)
            .with_module(flagged_module(
                "override-group",
                "override-key",
                Stage::Experimental,
            ))
            .with_module(flagged_module(
                "min-stage-group",
                "min-stage-key",
                Stage::Beta,
            )),
    );

    let list = cli
        .run(["flagintro", "flags", "list", "--output", "json"])
        .await;
    assert_eq!(list.exit_code, 0, "{}", list.rendered);
    let rendered: Value = serde_json::from_str(&list.rendered).expect("json output");
    let entries = rendered["data"].as_array().expect("data should be array");

    let override_entry = entries
        .iter()
        .find(|entry| entry["path"] == "override-group:list")
        .expect("override-decided command entry present");
    assert_eq!(override_entry["key"], "override-key");
    assert_eq!(override_entry["stage"], "experimental");
    assert_eq!(override_entry["visible"], true);

    let min_stage_entry = entries
        .iter()
        .find(|entry| entry["path"] == "min-stage-group:list")
        .expect("min-stage-decided command entry present");
    assert_eq!(min_stage_entry["key"], "min-stage-key");
    assert_eq!(min_stage_entry["stage"], "beta");
    assert_eq!(min_stage_entry["visible"], true);

    let override_info = cli
        .run([
            "flagintro",
            "flags",
            "info",
            "override-key",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(override_info.exit_code, 0, "{}", override_info.rendered);
    let override_data: Value = serde_json::from_str(&override_info.rendered).expect("json output");
    assert_eq!(override_data["data"]["policy"]["override"], "ga");
    assert!(
        override_data["data"]["entries"]
            .as_array()
            .expect("entries should be array")
            .iter()
            .all(|entry| entry["decided_by"] == "override")
    );

    let min_stage_info = cli
        .run([
            "flagintro",
            "flags",
            "info",
            "min-stage-key",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(min_stage_info.exit_code, 0, "{}", min_stage_info.rendered);
    let min_stage_data: Value =
        serde_json::from_str(&min_stage_info.rendered).expect("json output");
    assert!(min_stage_data["data"]["policy"]["override"].is_null());
    assert!(
        min_stage_data["data"]["entries"]
            .as_array()
            .expect("entries should be array")
            .iter()
            .all(|entry| entry["decided_by"] == "min_stage")
    );

    let unknown = cli.run(["flagintro", "flags", "info", "no-such-key"]).await;
    assert_ne!(unknown.exit_code, 0, "{}", unknown.rendered);
    assert!(
        unknown.rendered.contains("no such flag"),
        "{}",
        unknown.rendered
    );
}

#[tokio::test]
async fn flags_info_decides_per_entry_when_multiple_nodes_share_a_key() {
    // Two unrelated nodes declare the same key with different stages. The
    // override (to Beta) flips one node's outcome (Experimental -> visible)
    // but not the other's (Beta was already >= min_stage). `decided_by` must
    // reflect that per entry, not uniformly for the whole key.
    let cli = Cli::new(
        CliConfig::new("flagintro2", "Flags introspection test", "flagintro2-app")
            .with_min_stage(Stage::Beta)
            .with_feature_override("shared-key", Stage::Beta)
            .with_module(flagged_module(
                "already-visible-group",
                "shared-key",
                Stage::Beta,
            ))
            .with_module(flagged_module(
                "flipped-group",
                "shared-key",
                Stage::Experimental,
            )),
    );

    let info = cli
        .run([
            "flagintro2",
            "flags",
            "info",
            "shared-key",
            "--output",
            "json",
        ])
        .await;
    assert_eq!(info.exit_code, 0, "{}", info.rendered);
    let data: Value = serde_json::from_str(&info.rendered).expect("json output");
    let entries = data["data"]["entries"].as_array().expect("entries array");

    let already_visible = entries
        .iter()
        .find(|entry| entry["path"] == "already-visible-group:list")
        .expect("already-visible entry present");
    assert_eq!(already_visible["visible"], true);
    assert_eq!(already_visible["decided_by"], "min_stage");

    let flipped = entries
        .iter()
        .find(|entry| entry["path"] == "flipped-group:list")
        .expect("flipped entry present");
    assert_eq!(flipped["visible"], true);
    assert_eq!(flipped["decided_by"], "override");
}