use std::path::{Path, PathBuf};
use std::process::Command;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .unwrap()
.parent() .unwrap()
.to_path_buf()
}
fn m3l_bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_m3l"));
cmd.current_dir(workspace_root());
cmd
}
#[test]
fn cli_help() {
let output = m3l_bin().arg("--help").output().expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("M3L parser and validator"));
}
#[test]
fn cli_version() {
let output = m3l_bin().arg("--version").output().expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn cli_parse_single_file() {
let output = m3l_bin()
.args(["parse", "samples/01-ecommerce.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let ast: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON output");
assert_eq!(ast["parserVersion"], env!("CARGO_PKG_VERSION"));
assert_eq!(ast["astVersion"], "1.0");
assert!(ast["models"].is_array());
assert!(!ast["models"].as_array().unwrap().is_empty());
}
#[test]
fn cli_parse_directory() {
let output = m3l_bin()
.args(["parse", "samples/multi/"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let ast: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON output");
let sources = ast["sources"].as_array().expect("sources should be array");
assert_eq!(sources.len(), 2); }
#[test]
fn cli_parse_nonexistent() {
let output = m3l_bin()
.args(["parse", "nonexistent/path"])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Error:"));
}
#[test]
fn cli_validate_clean() {
let output = m3l_bin()
.args(["validate", "samples/01-ecommerce.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("0 errors"));
}
#[test]
fn cli_validate_json_format() {
let output = m3l_bin()
.args([
"validate",
"samples/01-ecommerce.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON output");
assert_eq!(result["summary"]["errors"], 0);
assert_eq!(result["summary"]["files"], 1);
}
#[test]
fn cli_validate_with_errors() {
let output = m3l_bin()
.args(["validate", "samples/"])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("error"), "stdout: {stdout}");
assert!(stdout.contains("M3L-E005"), "stdout: {stdout}");
}
#[test]
fn cli_parse_output_file() {
let tmp = std::env::temp_dir().join("m3l-cli-test-output.json");
let output = m3l_bin()
.args([
"parse",
"samples/01-ecommerce.m3l.md",
"-o",
tmp.to_str().unwrap(),
])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let content = std::fs::read_to_string(&tmp).expect("output file should exist");
let ast: serde_json::Value = serde_json::from_str(&content).expect("invalid JSON in file");
assert_eq!(ast["parserVersion"], env!("CARGO_PKG_VERSION"));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn cli_lint_human() {
let output = m3l_bin()
.args(["lint", "samples/01-ecommerce.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("lint"));
}
#[test]
fn cli_lint_json() {
let output = m3l_bin()
.args(["lint", "samples/01-ecommerce.m3l.md", "--format", "json"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(result["diagnostics"].is_array());
assert!(result["summary"]["count"].is_number());
}
#[test]
fn cli_lint_sarif() {
let output = m3l_bin()
.args(["lint", "samples/01-ecommerce.m3l.md", "--format", "sarif"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let sarif: serde_json::Value = serde_json::from_str(&stdout).expect("invalid SARIF JSON");
assert_eq!(sarif["version"], "2.1.0");
assert!(sarif["runs"].is_array());
}
#[test]
fn cli_format_single_file() {
let output = m3l_bin()
.args(["format", "samples/01-ecommerce.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("##"),
"expected model headers, got: {stdout}"
);
}
#[test]
fn cli_format_directory() {
let output = m3l_bin()
.args(["format", "samples/multi/"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("##"),
"expected model headers, got: {stdout}"
);
}
#[test]
fn cli_diff_identical_files() {
let output = m3l_bin()
.args([
"diff",
"samples/01-ecommerce.m3l.md",
"samples/01-ecommerce.m3l.md",
])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("No differences") || stdout.contains("0 added"),
"expected no differences, got: {stdout}"
);
}
#[test]
fn cli_diff_different_files() {
let output = m3l_bin()
.args([
"diff",
"samples/01-ecommerce.m3l.md",
"samples/02-blog-cms.m3l.md",
])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains('+') || stdout.contains('-'));
}
#[test]
fn cli_analyze_mermaid() {
let output = m3l_bin()
.args(["analyze", "samples/01-ecommerce.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("graph LR"));
assert!(stdout.contains("-->"));
assert!(stdout.contains("nodes"));
assert!(stdout.contains("edges"));
}
#[test]
fn cli_analyze_dot() {
let output = m3l_bin()
.args(["analyze", "samples/01-ecommerce.m3l.md", "--format", "dot"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("digraph M3L"));
assert!(stdout.contains("->"));
assert!(stdout.contains("rankdir=LR"));
}
#[test]
fn cli_analyze_directory() {
let output = m3l_bin()
.args(["analyze", "samples/multi/"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("graph LR"));
}
#[test]
fn validate_e001_rollup_no_ref() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e001-rollup-no-ref.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E001"),
"Expected M3L-E001 in diagnostics"
);
}
#[test]
fn validate_e002_lookup_no_ref() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e002-lookup-no-ref.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E002"),
"Expected M3L-E002 in diagnostics"
);
}
#[test]
fn validate_e004_view_bad_source() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e004-view-bad-source.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E004"),
"Expected M3L-E004 in diagnostics"
);
}
#[test]
fn validate_e005_duplicate_name() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e005-duplicate-name.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E005"),
"Expected M3L-E005 in diagnostics"
);
}
#[test]
fn validate_e006_duplicate_field() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e006-duplicate-field.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E006"),
"Expected M3L-E006 in diagnostics"
);
}
#[test]
fn validate_e009_undefined_type() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e009-undefined-type.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E009"),
"Expected M3L-E009 in diagnostics"
);
}
#[test]
fn validate_e010_relations_no_ref() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/e010-relations-no-ref.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-E010"),
"Expected M3L-E010 in diagnostics"
);
}
#[test]
fn validate_w001_long_line_strict() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/w001-long-line.m3l.md",
"--strict",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-W001"),
"Expected M3L-W001 in diagnostics"
);
}
#[test]
fn validate_w003_deprecated() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/w003-deprecated.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
let diags = result["diagnostics"].as_array().unwrap();
let w003_count = diags.iter().filter(|d| d["code"] == "M3L-W003").count();
assert!(
w003_count >= 2,
"Expected at least 2 M3L-W003 warnings (datetime + @cascade)"
);
}
#[test]
fn validate_w004_long_lookup_strict() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/w004-long-lookup.m3l.md",
"--strict",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-W004"),
"Expected M3L-W004 in diagnostics"
);
}
#[test]
fn validate_w005_type_mismatch() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/w005-type-mismatch.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-W005"),
"Expected M3L-W005 in diagnostics"
);
}
#[test]
fn validate_w006_range_violation() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/w006-range-violation.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "M3L-W006"),
"Expected M3L-W006 in diagnostics"
);
}
#[test]
fn validate_clean_fixture() {
let output = m3l_bin()
.args([
"validate",
"samples/test/validate/clean.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert_eq!(result["summary"]["errors"], 0);
assert_eq!(result["summary"]["warnings"], 0);
}
#[test]
fn lint_naming_convention() {
let output = m3l_bin()
.args([
"lint",
"samples/test/lint/naming-bad.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["rule"] == "naming-convention"),
"Expected naming-convention rule hit"
);
}
#[test]
fn lint_model_size() {
let output = m3l_bin()
.args([
"lint",
"samples/test/lint/large-model.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["rule"] == "model-size"),
"Expected model-size rule hit"
);
}
#[test]
fn lint_similar_fields() {
let output = m3l_bin()
.args([
"lint",
"samples/test/lint/similar-fields.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["rule"] == "similar-fields"),
"Expected similar-fields rule hit"
);
}
#[test]
fn lint_relation_complexity() {
let output = m3l_bin()
.args([
"lint",
"samples/test/lint/many-refs.m3l.md",
"--format",
"json",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(
result["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["rule"] == "relation-complexity"),
"Expected relation-complexity rule hit"
);
}
#[test]
fn lint_clean_fixture() {
let output = m3l_bin()
.args(["lint", "samples/test/lint/clean.m3l.md", "--format", "json"])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert_eq!(
result["summary"]["count"], 0,
"Expected 0 lint issues for clean fixture"
);
}
#[test]
fn diff_known_changes() {
let output = m3l_bin()
.args([
"diff",
"samples/test/diff/v1.m3l.md",
"samples/test/diff/v2.m3l.md",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("+ model NewModel"), "stdout: {stdout}");
assert!(stdout.contains("- model OldModel"), "stdout: {stdout}");
assert!(stdout.contains("+ Customer.age"), "stdout: {stdout}");
assert!(stdout.contains("~ Customer.phone"), "stdout: {stdout}");
}
#[test]
fn diff_reverse() {
let output = m3l_bin()
.args([
"diff",
"samples/test/diff/v2.m3l.md",
"samples/test/diff/v1.m3l.md",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("+ model OldModel"), "stdout: {stdout}");
assert!(stdout.contains("- model NewModel"), "stdout: {stdout}");
assert!(stdout.contains("- Customer.age"), "stdout: {stdout}");
}
#[test]
fn diff_summary_counts() {
let output = m3l_bin()
.args([
"diff",
"samples/test/diff/v1.m3l.md",
"samples/test/diff/v2.m3l.md",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("2 added"), "stdout: {stdout}");
assert!(stdout.contains("1 removed"), "stdout: {stdout}");
assert!(stdout.contains("1 modified"), "stdout: {stdout}");
}
#[test]
fn analyze_mermaid_edges() {
let output = m3l_bin()
.args(["analyze", "samples/test/analyze/graph.m3l.md"])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("graph LR"), "stdout: {stdout}");
assert!(
stdout.contains("-->|inherits|"),
"Expected inheritance edge, stdout: {stdout}"
);
assert!(
stdout.contains("-->|ref|"),
"Expected reference edge, stdout: {stdout}"
);
}
#[test]
fn analyze_dot_format() {
let output = m3l_bin()
.args([
"analyze",
"samples/test/analyze/graph.m3l.md",
"--format",
"dot",
])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("digraph M3L"), "stdout: {stdout}");
assert!(stdout.contains("rankdir=LR"), "stdout: {stdout}");
assert!(stdout.contains("->"), "stdout: {stdout}");
assert!(
stdout.contains("style=dashed"),
"Expected inherits edge style, stdout: {stdout}"
);
}
#[test]
fn analyze_isolated_node() {
let output = m3l_bin()
.args(["analyze", "samples/test/analyze/graph.m3l.md"])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains(" Isolated"), "stdout: {stdout}");
assert!(
!stdout.contains("Isolated -->") && !stdout.contains("--> Isolated"),
"Isolated node should have no edges, stdout: {stdout}"
);
}
#[test]
fn format_full_features() {
let output = m3l_bin()
.args(["format", "samples/test/format/full.m3l.md"])
.output()
.expect("failed to run");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("# Namespace:"), "stdout: {stdout}");
assert!(stdout.contains("::interface"), "stdout: {stdout}");
assert!(stdout.contains("::enum"), "stdout: {stdout}");
assert!(stdout.contains("::view"), "stdout: {stdout}");
assert!(stdout.contains("## Customer"), "stdout: {stdout}");
}
#[test]
fn format_nested_fields() {
let output = m3l_bin()
.args(["format", "samples/test/format/full.m3l.md"])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(" - bio:") || stdout.contains(" - avatar_url:"),
"Expected indented nested fields, stdout: {stdout}"
);
}
#[test]
fn format_roundtrip() {
let output = m3l_bin()
.args(["format", "samples/test/format/full.m3l.md"])
.output()
.expect("failed to run");
assert!(output.status.success());
let formatted = String::from_utf8_lossy(&output.stdout);
let tmp = std::env::temp_dir().join("m3l-format-roundtrip.m3l.md");
std::fs::write(&tmp, formatted.as_ref()).expect("write tmp");
let parse_out = m3l_bin()
.args(["parse", tmp.to_str().unwrap()])
.output()
.expect("failed to run");
assert!(
parse_out.status.success(),
"Roundtrip parse failed, stderr: {}",
String::from_utf8_lossy(&parse_out.stderr)
);
let stdout = String::from_utf8_lossy(&parse_out.stdout);
let ast: serde_json::Value =
serde_json::from_str(&stdout).expect("invalid JSON from roundtrip");
assert!(
ast["models"].is_array(),
"Roundtrip should produce models array"
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn format_idempotent() {
let inputs = &[
"samples/test/format/full.m3l.md",
"spec/conformance/inputs/01-ecommerce.m3l.md",
"spec/conformance/inputs/02-blog-cms.m3l.md",
"spec/conformance/inputs/03-types-showcase.m3l.md",
];
for input_path in inputs {
let output1 = m3l_bin()
.args(["format", input_path])
.output()
.expect("failed to run");
assert!(
output1.status.success(),
"[{input_path}] First format failed: {}",
String::from_utf8_lossy(&output1.stderr)
);
let formatted1 = String::from_utf8_lossy(&output1.stdout);
let tmp = std::env::temp_dir().join("m3l-idempotent-test.m3l.md");
std::fs::write(&tmp, formatted1.as_ref()).expect("write tmp");
let output2 = m3l_bin()
.args(["format", tmp.to_str().unwrap()])
.output()
.expect("failed to run");
assert!(
output2.status.success(),
"[{input_path}] Second format failed: {}",
String::from_utf8_lossy(&output2.stderr)
);
let formatted2 = String::from_utf8_lossy(&output2.stdout);
assert_eq!(
formatted1.as_ref(),
formatted2.as_ref(),
"[{input_path}] Format is not idempotent — second format produced different output"
);
let parse_out = m3l_bin()
.args(["parse", tmp.to_str().unwrap()])
.output()
.expect("failed to run");
assert!(
parse_out.status.success(),
"[{input_path}] Formatted output failed to parse: {}",
String::from_utf8_lossy(&parse_out.stderr)
);
std::fs::remove_file(&tmp).ok();
}
}
#[test]
fn format_preserves_ast() {
let inputs = &[
"samples/test/format/full.m3l.md",
"spec/conformance/inputs/01-ecommerce.m3l.md",
"spec/conformance/inputs/03-types-showcase.m3l.md",
];
for input_path in inputs {
let parse1 = m3l_bin()
.args(["parse", input_path])
.output()
.expect("failed to run");
assert!(
parse1.status.success(),
"[{input_path}] Original parse failed"
);
let ast1: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&parse1.stdout))
.expect("invalid JSON from original parse");
let format_out = m3l_bin()
.args(["format", input_path])
.output()
.expect("failed to run");
assert!(format_out.status.success(), "[{input_path}] Format failed");
let tmp = std::env::temp_dir().join("m3l-ast-equiv-test.m3l.md");
std::fs::write(&tmp, &format_out.stdout).expect("write tmp");
let parse2 = m3l_bin()
.args(["parse", tmp.to_str().unwrap()])
.output()
.expect("failed to run");
assert!(
parse2.status.success(),
"[{input_path}] Formatted parse failed"
);
let ast2: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&parse2.stdout))
.expect("invalid JSON from formatted parse");
let models1 = ast1["models"].as_array().expect("models1 array");
let models2 = ast2["models"].as_array().expect("models2 array");
assert_eq!(
models1.len(),
models2.len(),
"[{input_path}] Model count differs after format"
);
for (m1, m2) in models1.iter().zip(models2.iter()) {
assert_eq!(m1["name"], m2["name"], "[{input_path}] Model name differs");
assert_eq!(
m1["type"], m2["type"],
"[{input_path}] Model type differs for {}",
m1["name"]
);
let f1: Vec<&str> = m1["fields"]
.as_array()
.map(|a| a.iter().filter_map(|f| f["name"].as_str()).collect())
.unwrap_or_default();
let f2: Vec<&str> = m2["fields"]
.as_array()
.map(|a| a.iter().filter_map(|f| f["name"].as_str()).collect())
.unwrap_or_default();
assert_eq!(
f1, f2,
"[{input_path}] Field names differ for model {}",
m1["name"]
);
}
let enums1 = ast1["enums"].as_array();
let enums2 = ast2["enums"].as_array();
if let (Some(e1), Some(e2)) = (enums1, enums2) {
assert_eq!(
e1.len(),
e2.len(),
"[{input_path}] Enum count differs after format"
);
for (en1, en2) in e1.iter().zip(e2.iter()) {
assert_eq!(en1["name"], en2["name"], "[{input_path}] Enum name differs");
}
}
std::fs::remove_file(&tmp).ok();
}
}
#[test]
fn parse_file_with_errors() {
let output = m3l_bin()
.args(["parse", "samples/test/validate/e009-undefined-type.m3l.md"])
.output()
.expect("failed to run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let ast: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON");
assert!(ast["models"].is_array());
assert!(!ast["models"].as_array().unwrap().is_empty());
}
#[test]
fn parse_empty_dir() {
let tmp = std::env::temp_dir().join("m3l-empty-dir-test");
std::fs::create_dir_all(&tmp).ok();
let output = m3l_bin()
.args(["parse", tmp.to_str().unwrap()])
.output()
.expect("failed to run");
assert!(
!output.status.success(),
"Expected failure for empty directory"
);
std::fs::remove_dir_all(&tmp).ok();
}