ridl-cli 0.2.0

The `ridl` command-line toolchain: check, build, fmt, lock, diff, lsp, and mcp, over the shared compiler crates.
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
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
//! Integration tests for `ridl diff` (docs/ROADMAP.md epic E2.8a): the exit
//! contract (0 compatible/identical, 1 breaking, 2 error), source and
//! `.ir.json` inputs, in-process compilation via `ridlc::compile_workspace`,
//! and the stable machine-readable JSON schema.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};

/// A unique directory under the system temp dir, removed on drop.
struct TempDir(PathBuf);

impl TempDir {
    fn new(label: &str) -> Self {
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let mut path = std::env::temp_dir();
        path.push(format!(
            "ridl-diff-{label}-{}-{}",
            std::process::id(),
            COUNTER.fetch_add(1, Ordering::SeqCst),
        ));
        std::fs::create_dir_all(&path).expect("create the temp dir");
        Self(path)
    }

    fn path(&self) -> &Path {
        &self.0
    }

    fn write(&self, relative: &str, text: &str) -> PathBuf {
        let path = self.0.join(relative);
        std::fs::create_dir_all(path.parent().expect("a relative path has a parent"))
            .expect("create parent directories");
        std::fs::write(&path, text).expect("write the fixture file");
        path
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

/// Runs `ridl` with `args`, returning `(exit_code, stdout, stderr)`.
fn ridl(args: &[&std::ffi::OsStr]) -> (i32, String, String) {
    let output = Command::new(env!("CARGO_BIN_EXE_ridl"))
        .args(args)
        .output()
        .expect("the ridl binary must run");
    let code = output.status.code().expect("the process exits with a code");
    (
        code,
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

const MANIFEST: &str = "[package]\nname = \"veh.cluster\"\nversion = \"1.0.0\"\n";

/// Records the interface numbers of the workspace at `root` with plain
/// `ridl lock`: `ridl baseline` refuses a provisional number (RIDL-411), so a
/// workspace publishes only once its lock is written.
fn lock(root: &Path) {
    let (code, _, stderr) = ridl(&["lock".as_ref(), root.as_os_str()]);
    assert_eq!(code, 0, "the fixture's lock is allocated: {stderr}");
}

const BASE: &str = "package veh.cluster
type Speed: km/h [0.0..250.0 step 0.5]
type DoorState: integer [0..1]
interface VehicleStatus {
  signal currentSpeed: Speed @10ms
  event doorOpened: DoorState
}
";

/// A breaking change: the signal payload type changes (Speed -> Speed2).
const BREAKING: &str = "package veh.cluster
type Speed: km/h [0.0..250.0 step 0.5]
type Speed2: km/h [0.0..300.0 step 0.5]
type DoorState: integer [0..1]
interface VehicleStatus {
  signal currentSpeed: Speed2 @10ms
  event doorOpened: DoorState
}
";

/// A compatible change: a new interaction is appended at the end.
const COMPATIBLE: &str = "package veh.cluster
type Speed: km/h [0.0..250.0 step 0.5]
type DoorState: integer [0..1]
interface VehicleStatus {
  signal currentSpeed: Speed @10ms
  event doorOpened: DoorState
  event hoodOpened: DoorState
}
";

/// A source that fails to compile: an unknown payload type.
const BROKEN: &str = "package veh.cluster
interface VehicleStatus {
  signal currentSpeed: NoSuchType @10ms
}
";

/// `ridl diff <file> <file>` over two identical single-file sources exits 0.
#[test]
fn source_files_identical_exits_zero() {
    let dir = TempDir::new("same");
    let old = dir.write("old.ridl", BASE);
    let new = dir.write("new.ridl", BASE);
    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(code, 0, "identical sources exit 0, stderr:\n{stderr}");
    // `render_text` terminates every line itself, so the text output must not
    // pick up a trailing blank line.
    assert_eq!(
        stdout, "identical\n",
        "the text rendering must not gain a blank line, got:\n{stdout:?}"
    );
}

/// `ridl diff` refuses the two non-JSON IR encodings by name — diffs and
/// baselines stay `.ir.json` (ADR-0014 decision 5). Before the refusal the
/// file fell through to the source compiler, which parsed the artifact as
/// `.typl` and reported FORM-104 into it — a misdiagnosis of the actual
/// mistake.
#[test]
fn non_json_ir_snapshots_are_refused() {
    let dir = TempDir::new("refuse");
    let source = dir.write("new.ridl", BASE);
    for name in ["old.ir.txtpb", "old.ir.binpb"] {
        let artifact = dir.write(name, "name: \"veh.cluster\"\n");
        let (code, _, stderr) = ridl(&["diff".as_ref(), artifact.as_os_str(), source.as_os_str()]);
        assert_eq!(code, 2, "`{name}` is an input error, stderr:\n{stderr}");
        assert!(
            stderr.contains(".ir.json"),
            "the refusal must name the accepted encoding:\n{stderr}"
        );
        assert!(
            !stderr.contains("FORM-104"),
            "the artifact must not be parsed as source:\n{stderr}"
        );
    }
}

/// A workspace whose root also holds IR artifacts — exactly what
/// `ridl build ws --out-dir ws --emit ir-text` produces — is still a source
/// tree: the directory refusal below must not fire when the directory
/// directly holds a `ridl.toml` or a `.typl`/`.ridl` file, or a real
/// workspace would be misreported as a snapshot directory and told to
/// re-emit itself.
#[test]
fn a_source_tree_holding_stray_ir_artifacts_still_compiles() {
    let dir = TempDir::new("stray");
    dir.write("ws/ridl.toml", MANIFEST);
    dir.write("ws/iface.ridl", BASE);
    dir.write("ws/veh.cluster.ir.txtpb", "name: \"veh.cluster\"\n");
    let old = dir.path().join("ws");
    let new = dir.write("new.ridl", BASE);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);

    assert_eq!(code, 0, "a source tree compiles as source:\n{stderr}");
    assert_eq!(stdout, "identical\n", "stdout:\n{stdout:?}");
}

/// A directory holding an IR artifact and a `.rsdl` file is a source tree, as
/// it is with a `.typl` or `.ridl` file: it reaches the compiler, which reports
/// the missing manifest, instead of being described as an artifact directory.
#[test]
fn a_directory_holding_an_rsdl_file_is_a_source_tree() {
    let dir = TempDir::new("rsdl-tree");
    dir.write("src/veh.cluster.ir.txtpb", "name: \"veh.cluster\"\n");
    dir.write("src/system.rsdl", "package veh.cluster\n");
    let src = dir.path().join("src");
    let source = dir.write("new.ridl", BASE);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), src.as_os_str(), source.as_os_str()]);

    assert_eq!(code, 2, "the tree has no manifest:\n{stderr}");
    assert!(stdout.is_empty(), "no report over a failed side:\n{stdout}");
    assert!(
        !stderr.contains("the directory holds IR artifacts"),
        "a source tree is compiled, not described as artifacts:\n{stderr}"
    );
}

/// A `ridl.toml` path designates its workspace, the same as handing the
/// workspace root itself — only IR artifacts are recognised by name, and
/// every other file reaches the source compiler ([`load_diff_side`]).
#[test]
fn a_manifest_path_designates_its_workspace() {
    let dir = TempDir::new("manifest");
    let manifest = dir.write("ws/ridl.toml", MANIFEST);
    dir.write("ws/iface.ridl", BASE);
    let new = dir.write("new.ridl", BASE);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), manifest.as_os_str(), new.as_os_str()]);

    assert_eq!(
        code, 0,
        "the manifest path compiles its workspace:\n{stderr}"
    );
    assert_eq!(stdout, "identical\n", "stdout:\n{stdout:?}");
}

/// A directory holding IR artifacts, no `.ir.json`, and no source — `ridl
/// diff out/ src/` after `--emit ir-text` — draws a message that describes
/// it (issue #218 item 4). Before the fix, the directory fell through to the
/// source compiler, which reported `` no `ridl.toml` found at or above ... ``
/// — a misdiagnosis of the actual mistake.
#[test]
fn a_directory_of_non_json_artifacts_is_described_not_compiled() {
    let dir = TempDir::new("txtpb-dir");
    dir.write("out/veh.cluster.ir.txtpb", "name: \"veh.cluster\"\n");
    let out = dir.path().join("out");
    let source = dir.write("new.ridl", BASE);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), out.as_os_str(), source.as_os_str()]);

    assert_eq!(code, 2, "the directory is an input error:\n{stderr}");
    assert!(
        stdout.is_empty(),
        "no report over a refused input:\n{stdout}"
    );
    assert_eq!(
        stderr,
        format!(
            "error: {}: the directory holds IR artifacts (`veh.cluster.ir.txtpb`) but no \
             `.ir.json` snapshot; `ridl diff` compares `.ir.json` snapshots only (ADR-0014 \
             decision 5); emit the packages with `--emit ir-json` to compare them\n",
            out.display()
        ),
        "the message describes the directory, not a missing manifest"
    );
}

/// A directory whose snapshots sit one level down — `ridl diff .ridl ws/`
/// instead of `ridl diff .ridl/baseline ws/` — is described, exit 2 (issue
/// #230).
///
/// This one failed **open**, which is worse than the silent desk check the
/// issue was filed for. The directory holds no `.ir.json` directly and is not
/// a source tree, so it fell through to the source compiler; the compiler
/// walked up to the workspace's own `ridl.toml` and compiled the *current*
/// source as the baseline side. Both sides were then the same tree, and the
/// gate printed `identical` and exited 0 over a breaking change. The control
/// below pins that the change really is breaking, so a regression here shows
/// up as a gate that passes rather than as a wording change.
#[test]
fn a_directory_whose_snapshots_are_nested_is_described_not_compiled() {
    let dir = TempDir::new("nested-dir");
    let root = dir.path().join("ws");
    dir.write("ws/ridl.toml", MANIFEST);
    dir.write("ws/iface.ridl", BASE);
    lock(&root);
    let (code, _, stderr) = ridl(&["baseline".as_ref(), root.as_os_str()]);
    assert_eq!(code, 0, "the baseline publishes:\n{stderr}");
    dir.write("ws/iface.ridl", BREAKING);

    // The control: aimed at the directory that holds the snapshots, this
    // exact comparison gates.
    let published = root.join(".ridl/baseline");
    let (control, control_out, control_err) =
        ridl(&["diff".as_ref(), published.as_os_str(), root.as_os_str()]);
    assert_eq!(
        control, 1,
        "the edit is breaking:\n{control_out}{control_err}"
    );

    let nest = root.join(".ridl");
    let (code, stdout, stderr) = ridl(&["diff".as_ref(), nest.as_os_str(), root.as_os_str()]);

    assert_eq!(code, 2, "one level too high is an input error:\n{stderr}");
    assert_eq!(
        stdout, "",
        "a refused input yields no report — least of all `identical`:\n{stdout}"
    );
    assert_eq!(
        stderr,
        format!(
            "error: {}: no `.ir.json` snapshot directly inside, but the subdirectory \
             `baseline` holds one; snapshots are read from one directory, never from the \
             directories below it; compare `{}` instead\n",
            nest.display(),
            published.display(),
        ),
        "the message names the subdirectory that holds the snapshots"
    );
}

/// A workspace that published its baseline *inside itself* — `ridl baseline
/// ws --out ws/published` — holds `.ir.json` files one level down, so the
/// nested-snapshot refusal above must not fire on it: it is a source tree,
/// and `ridl diff ws/ other` is an ordinary source comparison. The direct
/// `ridl.toml` is what keeps the two apart, the same test that exempts a tree
/// holding stray artifacts.
#[test]
fn a_source_tree_holding_its_own_snapshots_still_compiles() {
    let dir = TempDir::new("ownsnap");
    let root = dir.path().join("ws");
    dir.write("ws/ridl.toml", MANIFEST);
    dir.write("ws/iface.ridl", BASE);
    lock(&root);
    let published = root.join("published");
    let (code, _, stderr) = ridl(&[
        "baseline".as_ref(),
        root.as_os_str(),
        "--out".as_ref(),
        published.as_os_str(),
    ]);
    assert_eq!(code, 0, "the baseline publishes:\n{stderr}");
    assert!(
        published.join("veh.cluster.ir.json").is_file(),
        "the snapshots sit one level below the workspace root",
    );
    let new = dir.write("new.ridl", BREAKING);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), root.as_os_str(), new.as_os_str()]);

    assert_eq!(code, 1, "the source tree still compiles:\n{stderr}");
    assert!(
        stdout.starts_with("breaking"),
        "and the breaking change is found:\n{stdout}",
    );
}

/// `ridl diff` over a snapshot directory (the shape `.ridl/baseline/` takes)
/// and a source tree keeps working: the directory's `.ir.json` files are the
/// baseline side, the source tree is compiled, and an unchanged workspace is
/// identical.
#[test]
fn snapshot_dir_vs_source_tree_identical_exits_zero() {
    let dir = TempDir::new("snapdir");
    let root = dir.path().join("ws");
    dir.write("ws/ridl.toml", MANIFEST);
    dir.write("ws/iface.ridl", BASE);
    lock(&root);
    let published = dir.path().join("published");
    let (baseline_code, _, baseline_err) = ridl(&[
        "baseline".as_ref(),
        root.as_os_str(),
        "--out".as_ref(),
        published.as_os_str(),
    ]);
    assert_eq!(baseline_code, 0, "the baseline publishes:\n{baseline_err}");

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), published.as_os_str(), root.as_os_str()]);

    assert_eq!(code, 0, "an unchanged workspace exits 0:\n{stderr}");
    assert_eq!(stdout, "identical\n", "stdout:\n{stdout:?}");
}

/// Retiring an interaction but writing its `reserved` tombstone at the end of
/// the body frees the retired ordinal, so a surviving interaction slides down
/// into it (ridl §11). Driven through real source so the compiler assigns the
/// ordinals: a retirement is compatible, but this one shifts a wire identity
/// and must gate.
#[test]
fn a_tombstone_written_out_of_its_slot_exits_one() {
    const HEADER: &str = "package veh.cluster\ntype T: integer [0..10]\n";
    let dir = TempDir::new("tombstone");
    // a=1, b=2, c=3
    let old = dir.write(
        "old.ridl",
        &format!(
            "{HEADER}interface I {{\n  signal a: T @10ms\n  signal b: T @10ms\n  signal c: T @10ms\n}}\n"
        ),
    );
    // a=1, c=2 (slid down from 3), reserved b=3
    let new = dir.write(
        "new.ridl",
        &format!(
            "{HEADER}interface I {{\n  signal a: T @10ms\n  signal c: T @10ms\n  reserved b\n}}\n"
        ),
    );

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(
        code, 1,
        "an out-of-slot tombstone frees a wire slot, stdout:\n{stdout}stderr:\n{stderr}"
    );
    assert!(stdout.starts_with("breaking"), "stdout:\n{stdout}");
}

/// Control for the case above: the same retirement written in the retired
/// interaction's own slot keeps every later ordinal and stays compatible.
#[test]
fn a_tombstone_written_in_its_slot_exits_zero() {
    const HEADER: &str = "package veh.cluster\ntype T: integer [0..10]\n";
    let dir = TempDir::new("tombstone-ok");
    let old = dir.write(
        "old.ridl",
        &format!(
            "{HEADER}interface I {{\n  signal a: T @10ms\n  signal b: T @10ms\n  signal c: T @10ms\n}}\n"
        ),
    );
    // reserved b holds ordinal 2, so c keeps ordinal 3.
    let new = dir.write(
        "new.ridl",
        &format!(
            "{HEADER}interface I {{\n  signal a: T @10ms\n  reserved b\n  signal c: T @10ms\n}}\n"
        ),
    );

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(
        code, 0,
        "an in-slot retirement is compatible, stdout:\n{stdout}stderr:\n{stderr}"
    );
    assert!(stdout.contains("interaction_retired"), "stdout:\n{stdout}");
}

/// `ridl diff <dir> <dir>` over two package directories: a breaking change
/// exits 1 (in-process compilation via `ridlc::compile_workspace`).
#[test]
fn source_dir_vs_source_dir_breaking_exits_one() {
    let old = TempDir::new("dir-old");
    old.write("ridl.toml", MANIFEST);
    old.write("iface.ridl", BASE);
    let new = TempDir::new("dir-new");
    new.write("ridl.toml", MANIFEST);
    new.write("iface.ridl", BREAKING);

    let (code, stdout, stderr) = ridl(&[
        "diff".as_ref(),
        old.path().as_os_str(),
        new.path().as_os_str(),
    ]);
    assert_eq!(code, 1, "a breaking change exits 1, stderr:\n{stderr}");
    assert!(stdout.starts_with("breaking"), "stdout:\n{stdout}");
    assert!(
        stdout.contains("payload_changed veh.cluster/VehicleStatus/currentSpeed"),
        "the honest path must surface, stdout:\n{stdout}"
    );
}

/// A compatible source change (an appended interaction) exits 0.
#[test]
fn source_dir_vs_source_dir_compatible_exits_zero() {
    let old = TempDir::new("dir-old-ok");
    old.write("ridl.toml", MANIFEST);
    old.write("iface.ridl", BASE);
    let new = TempDir::new("dir-new-ok");
    new.write("ridl.toml", MANIFEST);
    new.write("iface.ridl", COMPATIBLE);

    let (code, stdout, stderr) = ridl(&[
        "diff".as_ref(),
        old.path().as_os_str(),
        new.path().as_os_str(),
    ]);
    assert_eq!(code, 0, "a compatible change exits 0, stderr:\n{stderr}");
    assert!(stdout.starts_with("compatible"), "stdout:\n{stdout}");
    assert!(
        stdout.contains("interaction_appended veh.cluster/VehicleStatus/hoodOpened"),
        "stdout:\n{stdout}"
    );
}

/// `ridl diff` over two `.ir.json` snapshots produced by `ridl build`: a
/// breaking change exits 1.
#[test]
fn ir_json_vs_ir_json_breaking_exits_one() {
    let dir = TempDir::new("irjson");
    let old_src = dir.write("base.ridl", BASE);
    let new_src = dir.write("candidate.ridl", BREAKING);
    let out_old = TempDir::new("irjson-out-old");
    let out_new = TempDir::new("irjson-out-new");

    let (build_old, _, err_old) = ridl(&[
        "build".as_ref(),
        old_src.as_os_str(),
        "--emit".as_ref(),
        "ir-json".as_ref(),
        "--out-dir".as_ref(),
        out_old.path().as_os_str(),
    ]);
    assert_eq!(build_old, 0, "baseline builds, stderr:\n{err_old}");
    let (build_new, _, err_new) = ridl(&[
        "build".as_ref(),
        new_src.as_os_str(),
        "--emit".as_ref(),
        "ir-json".as_ref(),
        "--out-dir".as_ref(),
        out_new.path().as_os_str(),
    ]);
    assert_eq!(build_new, 0, "candidate builds, stderr:\n{err_new}");

    let old_ir = out_old.path().join("base.ir.json");
    let new_ir = out_new.path().join("candidate.ir.json");
    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old_ir.as_os_str(), new_ir.as_os_str()]);
    assert_eq!(code, 1, "the snapshot diff is breaking, stderr:\n{stderr}");
    assert!(stdout.starts_with("breaking"), "stdout:\n{stdout}");
}

/// A source that fails to compile on either side yields exit 2 with the
/// compiler diagnostics rendered to stderr — `ridlc` stays untouched, but its
/// errors gate the diff.
#[test]
fn a_broken_source_exits_two() {
    let dir = TempDir::new("broken");
    let old = dir.write("good.ridl", BASE);
    let new = dir.write("broken.ridl", BROKEN);
    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(code, 2, "a compile error exits 2, stdout:\n{stdout}");
    assert!(
        stderr.contains("NoSuchType"),
        "the compiler diagnostic must render to stderr, got:\n{stderr}"
    );
    assert!(
        stdout.is_empty(),
        "no diff report is written when a side fails to compile, stdout:\n{stdout}"
    );
}

/// A missing input path is a usage error: exit 2.
#[test]
fn a_missing_input_exits_two() {
    let dir = TempDir::new("missing");
    let old = dir.write("old.ridl", BASE);
    let missing = dir.path().join("does-not-exist.ridl");
    let (code, _, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), missing.as_os_str()]);
    assert_eq!(code, 2, "a missing input exits 2, stderr:\n{stderr}");
}

/// An rsdl workspace whose `deployment` has no `for` clause used to panic
/// `ridl diff`: `compile_workspace` lowers the system unconditionally, before
/// `load_diff_side`'s error check runs, while `ridlc build` cannot reach the
/// same lowering because FORM-101 blocks every artifact
/// (`crates/ridlc/src/lib.rs:973`). The missing `for` clause is a parse error
/// the parser still recovers from, so the deployment's closure is never
/// placed; the fix blocks that deployment's lowering the same way an
/// RSDL-7xx error does, instead of lowering a placement that is not there.
#[test]
fn a_deployment_with_no_for_clause_exits_two_not_panics() {
    let dir = TempDir::new("no-for");
    dir.write(
        "ws/ridl.toml",
        "[package]\nname = \"veh.demo\"\nversion = \"1.0.0\"\n",
    );
    dir.write(
        "ws/lane.ridl",
        "package veh.demo\n\ntype Flag: boolean\n\n\
         interface LaneAssist {\n  signal active: Flag @[100ms..1s]\n}\n\n\
         service veh.demo.lane : LaneAssist\n",
    );
    dir.write(
        "ws/topology.rsdl",
        "package veh.demo\n\n\
         component Lane { offers veh.demo.lane }\n\
         component Panel { requires LaneAssist }\n\
         system Vehicle { Lane, Panel }\n\
         deployment Desk {\n\
         \x20 machine Top { Lane }\n\
         \x20 machine Front { Panel }\n\
         }\n",
    );
    let root = dir.path().join("ws");
    let new = dir.write("new.ridl", BASE);

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), root.as_os_str(), new.as_os_str()]);

    assert_eq!(
        code, 2,
        "the missing `for` clause is a compile error, not a panic:\n{stderr}"
    );
    assert!(stdout.is_empty(), "no report over a failed side:\n{stdout}");
    assert!(
        stderr.contains("error[FORM-101]"),
        "the missing `for` clause is reported:\n{stderr}"
    );
}

/// A package whose one deployment places `Panel` on `machine`: a contract and
/// a topology in one directory.
fn placed_workspace(dir: &TempDir, root: &str, machine: &str) -> PathBuf {
    dir.write(
        &format!("{root}/ridl.toml"),
        "[package]\nname = \"veh.demo\"\nversion = \"1.0.0\"\n",
    );
    dir.write(
        &format!("{root}/lane.ridl"),
        "package veh.demo\n\ntype Flag: boolean\n\n\
         interface LaneAssist {\n  signal active: Flag @[100ms..1s]\n}\n\n\
         service veh.demo.lane : LaneAssist\n",
    );
    dir.write(
        &format!("{root}/topology.rsdl"),
        &format!(
            "package veh.demo\n\n\
             component Lane {{ offers veh.demo.lane }}\n\
             component Panel {{ requires LaneAssist }}\n\
             system Vehicle {{ Lane, Panel }}\n\
             deployment Desk for Vehicle {{\n\
             \x20 machine Top {{ Lane }}\n\
             \x20 machine {machine} {{ Panel }}\n\
             }}\n"
        ),
    );
    dir.path().join(root)
}

/// rsdl reference §14 (roadmap E6.18): moving an instance to another machine
/// leaves the contracts untouched, so the verdict is `identical` and the exit
/// code 0, and the move is listed under "placement changed" with no verdict.
#[test]
fn a_moved_instance_is_listed_under_placement_changed() {
    let dir = TempDir::new("placement");
    let old = placed_workspace(&dir, "old", "Front");
    let new = placed_workspace(&dir, "new", "Rear");
    let expected = "identical\n\
                    placement changed\n\
                    \x20 Desk/Rear: (absent) -> machine\n\
                    \x20 Desk/Front: machine -> (removed)\n\
                    \x20 Desk/veh.demo.Panel.Unit: Front -> Rear\n";

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(
        code, 0,
        "a placement change has no verdict, stderr:\n{stderr}"
    );
    assert_eq!(stdout, expected);

    let (code, stdout, stderr) = ridl(&[
        "diff".as_ref(),
        old.as_os_str(),
        new.as_os_str(),
        "--format".as_ref(),
        "json".as_ref(),
    ]);
    assert_eq!(code, 0, "stderr:\n{stderr}");
    let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert_eq!(value["verdict"], "identical");
    assert_eq!(
        value["placement_changed"][2],
        serde_json::json!({
            "path": "Desk/veh.demo.Panel.Unit",
            "before": "Front",
            "after": "Rear"
        })
    );
    assert!(value.get("composition_changed").is_none());
}

/// `--format json` prints the stable schema and still keys the exit code on
/// the verdict.
#[test]
fn format_json_matches_the_schema_and_exit_code() {
    let dir = TempDir::new("json");
    let old = dir.write("old.ridl", BASE);
    let new = dir.write("new.ridl", BREAKING);
    let (code, stdout, stderr) = ridl(&[
        "diff".as_ref(),
        old.as_os_str(),
        new.as_os_str(),
        "--format".as_ref(),
        "json".as_ref(),
    ]);
    assert_eq!(code, 1, "a breaking change exits 1, stderr:\n{stderr}");

    let value: serde_json::Value =
        serde_json::from_str(&stdout).expect("--format json emits valid JSON");
    assert_eq!(value["verdict"], "breaking");
    let changes = value["changes"].as_array().expect("changes is an array");
    assert!(!changes.is_empty(), "there is at least one change");
    for change in changes {
        assert!(change.get("path").is_some(), "every change has a path");
        assert!(
            change.get("category").is_some(),
            "every change has a category"
        );
        assert!(
            change.get("verdict").is_some(),
            "every change has a verdict"
        );
        // before/after are always present (null when not applicable).
        assert!(change.as_object().unwrap().contains_key("before"));
        assert!(change.as_object().unwrap().contains_key("after"));
    }
    assert!(
        changes.iter().any(
            |change| change["category"] == "payload_changed" && change["verdict"] == "breaking"
        ),
        "the payload change is present and breaking, stdout:\n{stdout}"
    );
}

/// A named-form service's list is a set (ADR-0015 decision 19 as amended):
/// an interface leaving it is compatible on the wire and visible in source,
/// so the text report lists it under that heading, after every unheaded
/// change, with the heading printed once.
#[test]
fn a_service_set_removal_renders_under_the_heading() {
    const HEADER: &str = "package veh.cluster\ntype T: integer [0..10]\ninterface I {\n  signal a: T @10ms\n}\ninterface J {\n  signal b: T @10ms\n}\n";
    let dir = TempDir::new("service-set");
    let old = dir.write(
        "old.ridl",
        &format!("{HEADER}service veh.cluster.dash : I, J\n"),
    );
    let new = dir.write(
        "new.ridl",
        &format!("{HEADER}interface K {{\n  signal c: T @10ms\n}}\nservice veh.cluster.dash : I\n"),
    );

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(code, 0, "a set removal is compatible, stderr:\n{stderr}");
    assert_eq!(
        stdout,
        "compatible\n  [compatible] decl_added veh.cluster/K: (absent) -> interface\ncompatible on the wire, visible in source:\n  [compatible] service_interface_removed veh.cluster/veh.cluster.dash/J: J -> (removed)\n"
    );
}

/// An interface is matched by its `interfaces.lock` number (lock design §7):
/// a rename that keeps its number is `interface_renamed`, compatible on the
/// wire and visible in source, so the text report groups it with a set
/// removal under the one heading, printed once. Each side is a bare `.ridl`
/// file whose directory holds the lock (plan decision PD-8).
#[test]
fn the_text_report_groups_renames_and_set_removals_under_one_heading() {
    const HEADER: &str = "# interfaces.lock — written by ridl lock; do not edit by hand.\n";
    const TYPES: &str =
        "package veh.cluster\ntype T: integer [0..10]\ninterface I {\n  signal a: T @10ms\n}\n";
    let dir = TempDir::new("renamed-by-number");
    let old = dir.write(
        "old/dash.ridl",
        &format!(
            "{TYPES}interface J {{\n  signal b: T @10ms\n}}\nservice veh.cluster.dash : I, J\n"
        ),
    );
    dir.write(
        "old/interfaces.lock",
        &format!("{HEADER}next 3\nI 1\nJ 2\n"),
    );
    let new = dir.write(
        "new/dash.ridl",
        &format!(
            "{TYPES}interface Jay {{\n  signal b: T @10ms\n}}\nservice veh.cluster.dash : I\n"
        ),
    );
    dir.write(
        "new/interfaces.lock",
        &format!("{HEADER}next 3\nI 1\nJay 2\n"),
    );

    let (code, stdout, stderr) = ridl(&["diff".as_ref(), old.as_os_str(), new.as_os_str()]);
    assert_eq!(
        code, 0,
        "a rename on its number and a set removal are both compatible, stderr:\n{stderr}"
    );
    assert_eq!(
        stdout,
        "compatible\ncompatible on the wire, visible in source:\n  [compatible] interface_renamed veh.cluster/Jay: J -> Jay\n  [compatible] service_interface_removed veh.cluster/veh.cluster.dash/J: J -> (removed)\n"
    );
}