schemaorg-validate 0.2.0

Parse and validate Schema.org structured data (JSON-LD, Microdata, RDFa) against the official vocabulary and Google Rich Results profiles.
Documentation
//! Integration tests for the `schemaorg-validate` CLI binary.
//!
//! These tests invoke the binary as a subprocess and verify:
//! - Exit codes (0 = clean, 1 = errors, 2 = input errors)
//! - Output format correctness (text, JSON, SARIF)
//! - All CLI flags work as documented

use std::process::Command;

/// Path to the built binary (relative to the project root).
fn binary() -> std::path::PathBuf {
    // cargo test builds the binary in the same target dir
    let mut path = std::env::current_exe()
        .expect("current_exe")
        .parent()
        .expect("parent")
        .parent()
        .expect("grandparent")
        .to_path_buf();
    path.push("schemaorg-validate");
    path
}

fn fixture(name: &str) -> String {
    format!("tests/fixtures/cli/{name}")
}

// ── Exit Code Tests ──

#[test]
fn valid_product_exits_zero() {
    let output = Command::new(binary())
        .args(["--file", &fixture("valid_product.html"), "--profile", "google"])
        .output()
        .expect("failed to run binary");

    assert!(
        output.status.success(),
        "expected exit 0, got {:?}\nstderr: {}",
        output.status.code(),
        String::from_utf8_lossy(&output.stderr),
    );
}

#[test]
fn invalid_product_exits_one() {
    let output = Command::new(binary())
        .args(["--file", &fixture("invalid_product.html"), "--profile", "none"])
        .output()
        .expect("failed to run binary");

    assert_eq!(
        output.status.code(),
        Some(1),
        "expected exit 1 for validation errors\nstdout: {}",
        String::from_utf8_lossy(&output.stdout),
    );
}

#[test]
fn missing_file_exits_two() {
    let output = Command::new(binary())
        .args(["--file", "nonexistent_file.html", "--profile", "none"])
        .output()
        .expect("failed to run binary");

    assert_eq!(
        output.status.code(),
        Some(2),
        "expected exit 2 for input error",
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("cannot read file"),
        "stderr should mention file error: {stderr}",
    );
}

// ── Text Output Tests ──

#[test]
fn text_output_contains_source_and_version() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--profile", "none",
            "--no-color",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Source:"), "should show source: {stdout}");
    assert!(stdout.contains("Schema.org:"), "should show schema version: {stdout}");
}

#[test]
fn text_output_shows_errors_for_invalid() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("invalid_product.html"),
            "--profile", "none",
            "--no-color",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("ERROR"), "should contain ERROR: {stdout}");
    assert!(
        stdout.contains("Unknown type 'Produc'"),
        "should report unknown type: {stdout}",
    );
    assert!(
        stdout.contains("Did you mean 'Product'"),
        "should suggest correction: {stdout}",
    );
}

#[test]
fn text_output_shows_profile_results() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--profile", "google",
            "--no-color",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Profile Results"),
        "should show profile section: {stdout}",
    );
    assert!(
        stdout.contains("ELIGIBLE"),
        "should show eligibility: {stdout}",
    );
}

// ── JSON Output Tests ──

#[test]
fn json_output_is_valid_json() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--format", "json",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");

    assert!(parsed["source"].is_string(), "should have source field");
    assert!(parsed["schema_version"].is_string(), "should have schema_version");
    assert!(parsed["extraction"]["node_count"].is_number(), "should have node_count");
    assert!(parsed["vocabulary"]["error_count"].is_number(), "should have error_count");
}

#[test]
fn json_output_includes_profile_when_requested() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--format", "json",
            "--profile", "google",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");

    assert!(
        parsed["profile"]["eligibility"].is_string(),
        "should have profile.eligibility: {}",
        &stdout,
    );
}

#[test]
fn json_output_omits_profile_when_none() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--format", "json",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");

    assert!(
        parsed.get("profile").is_none(),
        "should NOT have profile field when --profile none",
    );
}

// ── SARIF Output Tests ──

#[test]
fn sarif_output_is_valid_sarif() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("invalid_product.html"),
            "--format", "sarif",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");

    assert_eq!(parsed["version"], "2.1.0", "should be SARIF 2.1.0");
    assert!(
        parsed["$schema"].as_str().unwrap().contains("sarif-schema"),
        "should reference SARIF schema",
    );

    let runs = parsed["runs"].as_array().expect("should have runs array");
    assert_eq!(runs.len(), 1, "should have exactly one run");

    let tool = &runs[0]["tool"]["driver"];
    assert_eq!(tool["name"], "schemaorg-validate");

    let results = runs[0]["results"].as_array().expect("should have results");
    assert!(!results.is_empty(), "invalid fixture should produce SARIF results");

    // All results should have a ruleId
    for result in results {
        assert!(
            result["ruleId"].is_string(),
            "each result should have ruleId",
        );
    }
}

#[test]
fn sarif_output_empty_for_valid() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--format", "sarif",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");

    let results = parsed["runs"][0]["results"].as_array().expect("results");
    assert!(results.is_empty(), "valid file should produce no SARIF results");
}

// ── Quiet Mode ──

#[test]
fn quiet_mode_produces_no_output() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--quiet",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    assert!(
        output.stdout.is_empty(),
        "quiet mode should produce no stdout",
    );
    assert!(output.status.success(), "valid file should exit 0");
}

#[test]
fn quiet_mode_exits_one_on_errors() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("invalid_product.html"),
            "--quiet",
            "--profile", "none",
        ])
        .output()
        .expect("failed to run binary");

    assert!(output.stdout.is_empty(), "quiet mode: no stdout");
    assert_eq!(output.status.code(), Some(1), "should exit 1 for errors");
}

// ── Schema Version Flag ──

#[test]
fn schema_version_flag() {
    let output = Command::new(binary())
        .args(["--schema-version"])
        .output()
        .expect("failed to run binary");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Schema.org v"),
        "should print schema version: {stdout}",
    );
}

// ── Stdin Mode ──

#[test]
fn stdin_mode_works() {
    let html = r#"<script type="application/ld+json">
    {"@context":"https://schema.org","@type":"Person","name":"Alice"}
    </script>"#;

    let output = Command::new(binary())
        .args(["--stdin", "--profile", "none", "--no-color"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .and_then(|mut child| {
            use std::io::Write;
            child.stdin.take().unwrap().write_all(html.as_bytes())?;
            child.wait_with_output()
        })
        .expect("failed to run with stdin");

    assert!(output.status.success(), "stdin with valid Person should exit 0");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("<stdin>"), "should show <stdin> as source");
}

// ── Severity Filter ──

#[test]
fn severity_error_only_hides_warnings() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("valid_product.html"),
            "--profile", "google",
            "--severity", "error",
            "--no-color",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !stdout.contains("WARN"),
        "severity=error should hide warnings: {stdout}",
    );
}

// ── No Color ──

#[test]
fn no_color_flag_removes_ansi() {
    let output = Command::new(binary())
        .args([
            "--file", &fixture("invalid_product.html"),
            "--profile", "none",
            "--no-color",
        ])
        .output()
        .expect("failed to run binary");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !stdout.contains("\x1b["),
        "no-color should strip ANSI codes: {stdout}",
    );
}