use std::io::Write;
use std::process::{Command, Stdio};
use iac_forge::ir::{IacAttribute, IacType};
use iac_forge::sexpr::{SExpr, ToSExpr};
use iac_forge::testing::{TestAttributeBuilder, test_resource};
fn ruby_available() -> bool {
Command::new("nix-shell").arg("--version").output().is_ok()
}
fn build_vectors() -> Vec<(&'static str, SExpr)> {
let mut resource = test_resource("widget");
resource.attributes = vec![
TestAttributeBuilder::new("name", IacType::String)
.required()
.build(),
TestAttributeBuilder::new("value", IacType::String)
.sensitive()
.build(),
];
vec![
("integer-42", SExpr::Integer(42)),
("float-3.14", SExpr::Float(std::f64::consts::PI)),
("bool-true", SExpr::Bool(true)),
("nil", SExpr::Nil),
("symbol-string", SExpr::Symbol("string".into())),
("string-literal", SExpr::String("hello".into())),
("string-with-escapes", SExpr::String("a\"b\nc".into())),
("empty-list", SExpr::List(vec![])),
(
"list-of-integers",
SExpr::List(vec![
SExpr::Symbol("list".into()),
SExpr::Integer(1),
SExpr::Integer(2),
SExpr::Integer(3),
]),
),
("iac-type-string", IacType::String.to_sexpr()),
(
"iac-type-list-integer",
IacType::List(Box::new(IacType::Integer)).to_sexpr(),
),
(
"iac-type-enum-values",
IacType::Enum {
values: vec!["tcp".into(), "udp".into()],
underlying: Box::new(IacType::String),
}
.to_sexpr(),
),
(
"iac-attribute",
IacAttribute {
api_name: "my-field".into(),
canonical_name: "my_field".into(),
description: "desc".into(),
iac_type: IacType::String,
required: true,
..Default::default()
}
.to_sexpr(),
),
("iac-resource-small", resource.to_sexpr()),
]
}
fn ruby_hashes(emissions: &[String]) -> Option<Vec<String>> {
let script_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cross_lang/sexpr_ref.rb");
let input = emissions.join("\n");
let shell_cmd = format!(
"gem install --user-install --no-document blake3 >/dev/null 2>&1 || true; \
export GEM_HOME=\"$(gem environment user_gemdir)\"; \
export GEM_PATH=\"$GEM_HOME:$(gem environment gemdir)\"; \
ruby '{}' -lines",
script_path.display(),
);
let mut child = Command::new("nix-shell")
.args(["-p", "ruby_3_3", "--run", &shell_cmd])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
stdin.write_all(input.as_bytes()).ok()?;
}
let out = child.wait_with_output().ok()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("BLAKE3_UNAVAILABLE") {
eprintln!("[skip] blake3 gem unavailable: {stderr}");
return None;
}
eprintln!(
"ruby reference failed (exit {:?}):\n{stderr}",
out.status.code()
);
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<String> = stdout
.lines()
.filter(|l| !l.trim().is_empty())
.map(String::from)
.collect();
if lines.len() != emissions.len() {
eprintln!(
"ruby returned {} hashes for {} emissions — output:\n{stdout}",
lines.len(),
emissions.len()
);
return None;
}
Some(lines)
}
#[test]
fn rust_ruby_content_hash_agreement() {
if !ruby_available() {
eprintln!("[skip] nix-shell not available — skipping cross-language agreement");
return;
}
let vectors = build_vectors();
let emissions: Vec<String> = vectors.iter().map(|(_, v)| v.emit()).collect();
let rust_hashes: Vec<String> = vectors
.iter()
.map(|(_, v)| v.content_hash().to_hex())
.collect();
let Some(ruby_hashes) = ruby_hashes(&emissions) else {
eprintln!("[skip] ruby reference run failed — see prior [skip] message");
return;
};
for ((name, _), (rh, ruby)) in vectors.iter().zip(rust_hashes.iter().zip(&ruby_hashes)) {
assert_eq!(
rh, ruby,
"cross-language hash disagreement on vector '{name}'\n rust: {rh}\n ruby: {ruby}"
);
}
}
#[test]
fn rust_emits_canonical_reparseable_forms() {
for (name, v) in build_vectors() {
let emitted = v.emit();
let reparsed = SExpr::parse(&emitted)
.unwrap_or_else(|e| panic!("vector '{name}' didn't parse: {e:?} — text: {emitted}"));
assert_eq!(
reparsed.content_hash(),
v.content_hash(),
"parse round-trip changed hash for vector '{name}'",
);
}
}
#[test]
fn ruby_reference_script_is_syntactically_valid() {
let script_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cross_lang/sexpr_ref.rb");
let contents = std::fs::read_to_string(&script_path)
.expect("sexpr_ref.rb must be committed at tests/cross_lang/");
assert!(
contents.contains("frozen_string_literal"),
"Ruby pragma missing"
);
assert!(contents.contains("Blake3.hexdigest"), "BLAKE3 call missing");
assert!(contents.contains("SExprReader"), "parser class missing");
}