use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
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);
}
}
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";
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
}
";
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
}
";
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
}
";
const BROKEN: &str = "package veh.cluster
interface VehicleStatus {
signal currentSpeed: NoSuchType @10ms
}
";
#[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}");
assert_eq!(
stdout, "identical\n",
"the text rendering must not gain a blank line, got:\n{stdout:?}"
);
}
#[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}"
);
}
}
#[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:?}");
}
#[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}"
);
}
#[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:?}");
}
#[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"
);
}
#[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);
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"
);
}
#[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}",
);
}
#[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:?}");
}
#[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");
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"
),
);
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}");
}
#[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"
),
);
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}");
}
#[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}"
);
}
#[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}"
);
}
#[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}");
}
#[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}"
);
}
#[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}");
}
#[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}"
);
}
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)
}
#[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());
}
#[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"
);
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}"
);
}
#[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"
);
}
#[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"
);
}