relayburn-cli 2.8.0

The `burn` CLI — published to crates.io. Crate name is relayburn-cli because `burn` is taken on crates.io; the binary keeps the `burn` invocation.
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
//! Smoke test for the `burn` CLI scaffold.
//!
//! Drives the actual binary (`cargo run -p relayburn-cli --bin burn`)
//! through `assert_cmd` to prove that:
//!
//! 1. `burn --help` exits 0 and emits non-empty stdout listing all
//!    registered subcommands.
//! 2. `burn <subcommand> --help` exits 0 for every subcommand we have a
//!    stub for. clap auto-generates the help block from the `Command`
//!    enum's doc comments, so a regression in the derive layer would
//!    surface here.
//! 3. Invoking a stub without `--help` exits 1 with the documented
//!    "not yet implemented" message — Wave 2 PRs replace this exit
//!    with their real presenter, so the test serves as a tripwire
//!    against an accidentally-empty stub.
//! 4. `burn --version` exits 0 (clap derives this from the workspace
//!    `package.version`).

use assert_cmd::Command;
use predicates::prelude::*;

/// Every top-level subcommand the scaffold registers. Keep this list
/// in sync with `cli::Command` — adding a variant there should bump
/// this list, and Wave 2 PRs that delete a stub should drop the entry
/// here as part of the same PR.
const SUBCOMMANDS: &[&str] = &[
    "summary",
    "hotspots",
    "overhead",
    "compare",
    "state",
    "sessions",
    "stamps",
    "ingest",
    "mcp-server",
];

/// Subcommands that still print "not yet implemented" when invoked
/// without args. Wave 2 D1 wired up `summary` and `hotspots`, D2 wired
/// up `overhead`, D3 wired up `compare`, D4 wired up `state`, and D8 wired
/// up `ingest` + `mcp-server` as real
/// presenters — every subcommand is now wired, so this list is empty
/// and `each_stub_exits_one_with_not_yet_implemented_message` becomes
/// a no-op iteration. The constant is retained so a future scaffold
/// (a new stub subcommand) has somewhere to land without re-introducing
/// the iteration helper.
const UNIMPLEMENTED_SUBCOMMANDS: &[&str] = &[];

/// Helper: build a `Command` driving the locally-built `burn` binary.
fn burn() -> Command {
    Command::cargo_bin("burn").expect("`burn` binary must build for the smoke test")
}

#[test]
fn top_level_help_lists_every_subcommand() {
    let output = burn().arg("--help").assert().success().get_output().clone();
    let stdout = String::from_utf8(output.stdout).expect("help should be valid UTF-8");
    assert!(!stdout.is_empty(), "--help must emit non-empty stdout");
    for sub in SUBCOMMANDS {
        assert!(
            stdout.contains(sub),
            "expected `--help` to mention subcommand `{sub}`; got:\n{stdout}",
        );
    }
    assert!(
        !stdout
            .lines()
            .any(|line| line.trim_start().starts_with("run ")),
        "`burn --help` must not advertise removed `run` command; got:\n{stdout}",
    );
}

#[test]
fn each_subcommand_help_exits_zero_with_non_empty_stdout() {
    for sub in SUBCOMMANDS {
        let output = burn()
            .args([sub, "--help"])
            .assert()
            .success()
            .get_output()
            .clone();
        let stdout = String::from_utf8(output.stdout).expect("help should be valid UTF-8");
        assert!(
            !stdout.is_empty(),
            "`{sub} --help` should emit non-empty stdout; got empty",
        );
    }
}

#[test]
fn overhead_trim_help_exits_zero_with_non_empty_stdout() {
    // `burn overhead` is no longer in UNIMPLEMENTED_SUBCOMMANDS, so the
    // parent `each_subcommand_help_exits_zero_with_non_empty_stdout`
    // covers its top-level help. The nested `trim` subcommand has its
    // own `clap` derive though; cover it explicitly so a regression in
    // the nested-action help wiring doesn't slip past CI.
    let output = burn()
        .args(["overhead", "trim", "--help"])
        .assert()
        .success()
        .get_output()
        .clone();
    let stdout = String::from_utf8(output.stdout).expect("help should be valid UTF-8");
    assert!(
        !stdout.is_empty(),
        "`overhead trim --help` should emit non-empty stdout; got empty",
    );
}

#[test]
fn each_stub_exits_one_with_not_yet_implemented_message() {
    for sub in UNIMPLEMENTED_SUBCOMMANDS {
        // Run the stub with no extra args. The default exit-code
        // contract for the scaffold is `EXIT_NOT_YET_IMPLEMENTED == 1`;
        // assert it explicitly so a future Wave 2 PR that wires up a
        // real presenter is forced to update this assertion (and the
        // scaffold acceptance criterion). Subcommands that have already
        // been wired up live in `SUBCOMMANDS` but not here.
        burn()
            .arg(sub)
            .assert()
            .code(1)
            .stderr(predicate::str::contains("not yet implemented"));
    }
}

#[test]
fn compare_command_rejects_missing_models() {
    // `burn compare` is wired (Wave 2 D3); no positional list means
    // exit 2 + the canonical "needs at least 2 models" message. This
    // asserts the wired path exists so a future regression that nukes
    // the dispatch arm fails loud.
    burn()
        .arg("compare")
        .assert()
        .code(2)
        .stderr(predicate::str::contains("needs at least 2 models"));
}

#[test]
fn json_mode_emits_error_envelope_on_argument_failure() {
    // The `--json` global flips error reporting from a stderr line to
    // a `{"error": …}` JSON envelope on stdout. Cover the toggle so
    // every wired Wave 2 command inherits a consistent JSON-mode error
    // shape. With every subcommand now wired, we pivot from the old
    // "still-stubbed" target to a wired command's argument-validation
    // failure (`burn compare` with no positional models) — same code
    // path through `report_error`, same envelope shape.
    let output = burn()
        .args(["--json", "compare"])
        .assert()
        .code(2)
        .get_output()
        .clone();
    let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8");
    assert!(
        stdout.contains("\"error\""),
        "expected JSON-mode envelope on stdout; got:\n{stdout}",
    );
    assert!(
        stdout.contains("needs at least 2 models"),
        "expected JSON-mode envelope to carry the compare error message; got:\n{stdout}",
    );
}

#[test]
fn version_flag_exits_zero() {
    burn()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::is_empty().not());
}

#[test]
fn unknown_subcommand_exits_non_zero() {
    burn()
        .arg("definitely-not-a-real-subcommand")
        .assert()
        .failure();
}

#[test]
fn run_subcommand_is_not_registered() {
    burn().args(["run", "--help"]).assert().failure();
}

#[test]
fn hotspots_session_without_id_is_an_explicit_stub() {
    // `--session` with no value is the per-session aggregate / gap report
    // mode in the TS surface. The Rust port doesn't expose a relationship
    // / chronology query verb yet, so we exit 2 with a directed message
    // pointing users at the supported `--session <id>` filter. Cover the
    // tripwire so a future PR that lands the per-session view is forced
    // to update this assertion alongside the wiring.
    burn()
        .args(["hotspots", "--session"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains(
            "per-session aggregate view (`--session` with no id)",
        ));
}

#[test]
fn hotspots_explain_drift_is_an_explicit_stub() {
    burn()
        .args(["hotspots", "--explain-drift"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("--explain-drift"));
}

#[test]
fn hotspots_unknown_pattern_value_is_rejected() {
    // `--patterns` accepts a CSV of detector kinds; an unknown kind is a
    // hard fail (exit 2) rather than a silent ignore.
    burn()
        .args(["hotspots", "--patterns", "definitely-not-a-detector"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("unknown --patterns value"));
}

#[test]
fn hotspots_group_by_and_patterns_are_mutually_exclusive() {
    burn()
        .args(["hotspots", "--group-by", "file", "--patterns", "retry-loop"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("mutually exclusive"));
}

/// `burn state reset` (no `--force`) is a dry-run: it must open the
/// ledger, count what would be dropped, print a "would drop ... " line,
/// and exit 0 *without* mutating either DB. Pin the contract here so a
/// future refactor can't silently turn the dry-run destructive.
#[test]
fn state_reset_dry_run_does_not_mutate() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    burn()
        .args(["state", "reset"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .stdout(predicate::str::contains("dry run"))
        .stdout(predicate::str::contains("--force"));

    // Both DB files should exist (Ledger::open creates them) and be
    // sized like a freshly-bootstrapped empty layout.
    assert!(
        home.path().join("burn.sqlite").is_file(),
        "burn.sqlite must exist after dry-run open"
    );
    assert!(
        home.path().join("content.sqlite").is_file(),
        "content.sqlite must exist after dry-run open"
    );
}

/// `burn state reset --force` actually wipes; pair it with `--json` so
/// we can assert on the structured envelope without depending on the
/// human-readable format.
#[test]
fn state_reset_force_emits_executed_envelope() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    let output = burn()
        .args(["--json", "state", "reset", "--force"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .get_output()
        .clone();

    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let value: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("--json output is valid JSON");
    assert_eq!(value["executed"], serde_json::Value::Bool(true));
    assert_eq!(value["rowsDropped"], serde_json::Value::from(0));
    assert_eq!(value["stampsDropped"], serde_json::Value::from(0));
    assert_eq!(value["contentRowsDropped"], serde_json::Value::from(0));
    assert!(
        value.get("reingest").is_none(),
        "no `reingest` key without --reingest"
    );
}

/// `--reingest` requires `--force`. Clap should reject the lone flag at
/// parse time so a typo can't silently no-op.
#[test]
fn state_reset_reingest_requires_force() {
    burn()
        .args(["state", "reset", "--reingest"])
        .assert()
        .failure();
}

/// `burn sessions` is a parent verb that requires a nested subcommand;
/// invoking it bare should fail (clap's required-subcommand check) so a
/// future PR adding a sibling verb can't accidentally regress to a
/// silent no-op default.
#[test]
fn sessions_without_subcommand_fails() {
    burn().arg("sessions").assert().failure();
}

/// `burn sessions list` against an empty isolated ledger should open
/// cleanly, scan zero turns, and report "no sessions found" with exit 0.
/// Pins the empty-ledger path so a future regression in the SDK verb
/// can't silently start erroring on a fresh install.
#[test]
fn sessions_list_against_empty_ledger_reports_no_sessions() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    burn()
        .args(["sessions", "list"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .stdout(predicate::str::contains("no sessions found"));
}

/// `burn sessions list --json` against an empty isolated ledger should
/// emit a valid JSON envelope with an empty `sessions` array and the
/// resolved filters echoed back. Asserting on `--json` separately from
/// the human form keeps the structured contract under test even if the
/// human format gets stylistically tweaked later.
#[test]
fn sessions_list_json_envelope_shape() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    let output = burn()
        .args(["--json", "sessions", "list", "--limit", "5"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .get_output()
        .clone();

    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let value: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("--json output is valid JSON");
    assert_eq!(value["limit"], serde_json::Value::from(5));
    assert_eq!(value["truncated"], serde_json::Value::Bool(false));
    assert_eq!(
        value["sessions"],
        serde_json::Value::Array(Vec::new()),
        "expected empty sessions array against fresh ledger"
    );
    assert_eq!(value["filters"]["since"], serde_json::Value::from("7d"));
}

/// `burn state rebuild classify` collapses onto the shared
/// `rebuild_derivable` transaction every other target uses. Against an
/// empty ledger this should open cleanly, drop zero rows, and exit 0;
/// `--json` carries the envelope shape so callers can structure-match
/// without depending on the human-readable form.
#[test]
fn state_rebuild_classify_emits_drop_envelope() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    let output = burn()
        .args(["--json", "state", "rebuild", "classify"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .get_output()
        .clone();

    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let value: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("--json output is valid JSON");
    assert_eq!(value["rowsDropped"], serde_json::Value::from(0));
    assert_eq!(value["contentRowsDropped"], serde_json::Value::from(0));
}

// ---------------------------------------------------------------------------
// `burn stamps` — stamps export tests
// ---------------------------------------------------------------------------

/// `burn stamps` is a parent verb that requires a nested subcommand;
/// invoking it bare should fail (clap's required-subcommand check) so a
/// future PR adding a sibling verb can't accidentally regress to a
/// silent no-op default.
#[test]
fn stamps_without_subcommand_fails() {
    burn().arg("stamps").assert().failure();
}

/// `burn stamps export` against an empty isolated ledger should open
/// cleanly, export zero stamps, and produce an empty output with exit 0.
#[test]
fn stamps_export_against_empty_ledger_produces_empty_output() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    burn()
        .args(["stamps", "export"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .stdout("")
        .stderr("");
}

/// `burn stamps export --out <path>` against an empty isolated ledger should
/// write an empty file and report success. Pins the file output path separate
/// from the stdout default.
#[test]
fn stamps_export_to_file_against_empty_ledger() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");
    let out_path = home.path().join("stamps.jsonl");

    burn()
        .args(["stamps", "export", "--out", out_path.to_str().unwrap()])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .stdout("")
        .stderr(predicate::str::contains("Exported 0 stamp(s)"));

    assert!(
        out_path.is_file(),
        "expected output file to be created at {}",
        out_path.display()
    );
    let content = std::fs::read_to_string(&out_path).expect("read exported file");
    assert!(
        content.is_empty(),
        "expected empty JSONL for empty ledger, got {:?}",
        content
    );
}

/// `burn stamps export --json` against an empty ledger should still only
/// emit JSONL on stdout (--json is not yet implemented for this verb,
/// but the command should still succeed).
#[test]
fn stamps_export_json_flag_succeeds() {
    let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

    burn()
        .args(["--json", "stamps", "export"])
        .env("RELAYBURN_HOME", home.path())
        .env("HOME", home.path())
        .env("NO_COLOR", "1")
        .assert()
        .success()
        .stdout("");
}