rvt 0.4.0

Apache-2.0 clean-room reader for Autodesk Revit files (.rvt, .rfa, .rte, .rft) without Revit: metadata, previews and the embedded schema of 2016-2026 files, and typed elements with measured geometry from Revit 2024 and 2025 projects, exported as IFC4, glTF and plan SVG.
Documentation
mod common;

use common::{sample_for_year, samples_dir};
use serde_json::Value;
use std::{path::Path, process::Command};

fn corpus_available() -> bool {
    sample_for_year(2024).exists()
        && sample_for_year(2023).exists()
        && sample_for_year(2022).exists()
}

fn load_schema(name: &str) -> Value {
    let path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("docs")
        .join("schemas")
        .join(name);
    let text = std::fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("read schema {}: {err}", path.display()));
    serde_json::from_str(&text)
        .unwrap_or_else(|err| panic!("parse schema {}: {err}", path.display()))
}

fn command_json(command: &str, args: &[&std::ffi::OsStr]) -> Value {
    let output = Command::new(command)
        .args(args)
        .output()
        .unwrap_or_else(|err| panic!("run {command}: {err}"));
    assert!(
        output.status.success(),
        "{command} failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout)
        .unwrap_or_else(|err| panic!("parse {command} JSON: {err}"))
}

fn validate(schema: &Value, value: &Value) -> Result<(), String> {
    validate_at(schema, schema, value, "$")
}

fn validate_at(root: &Value, schema: &Value, value: &Value, path: &str) -> Result<(), String> {
    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
        let resolved = resolve_ref(root, reference)?;
        return validate_at(root, resolved, value, path);
    }

    if let Some(options) = schema.get("anyOf").and_then(Value::as_array) {
        if options
            .iter()
            .any(|option| validate_at(root, option, value, path).is_ok())
        {
            return Ok(());
        }
        return Err(format!("{path}: did not match any anyOf branch"));
    }

    if let Some(expected) = schema.get("type") {
        validate_type(expected, value, path)?;
    }

    if let Some(enumerants) = schema.get("enum").and_then(Value::as_array) {
        if !enumerants.iter().any(|candidate| candidate == value) {
            return Err(format!("{path}: value {value:?} is not in enum"));
        }
    }

    if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
        if let Some(number) = value.as_f64() {
            if number < minimum {
                return Err(format!("{path}: {number} is below minimum {minimum}"));
            }
        }
    }

    if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
        if let Some(number) = value.as_f64() {
            if number > maximum {
                return Err(format!("{path}: {number} is above maximum {maximum}"));
            }
        }
    }

    if let Some(object) = value.as_object() {
        if let Some(required) = schema.get("required").and_then(Value::as_array) {
            for key in required.iter().filter_map(Value::as_str) {
                if !object.contains_key(key) {
                    return Err(format!("{path}: missing required property {key}"));
                }
            }
        }

        if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
            for (key, property_schema) in properties {
                if let Some(child) = object.get(key) {
                    validate_at(root, property_schema, child, &format!("{path}.{key}"))?;
                }
            }
        }

        if let Some(additional) = schema.get("additionalProperties") {
            if additional.is_object() {
                let properties = schema
                    .get("properties")
                    .and_then(Value::as_object)
                    .cloned()
                    .unwrap_or_default();
                for (key, child) in object {
                    if !properties.contains_key(key) {
                        validate_at(root, additional, child, &format!("{path}.{key}"))?;
                    }
                }
            }
        }
    }

    if let Some(array) = value.as_array() {
        if let Some(item_schema) = schema.get("items") {
            for (idx, child) in array.iter().enumerate() {
                validate_at(root, item_schema, child, &format!("{path}[{idx}]"))?;
            }
        }
    }

    Ok(())
}

fn validate_type(expected: &Value, value: &Value, path: &str) -> Result<(), String> {
    match expected {
        Value::String(kind) => validate_type_name(kind, value, path),
        Value::Array(kinds) => {
            if kinds
                .iter()
                .filter_map(Value::as_str)
                .any(|kind| validate_type_name(kind, value, path).is_ok())
            {
                Ok(())
            } else {
                Err(format!(
                    "{path}: value {value:?} did not match any allowed type"
                ))
            }
        }
        _ => Err(format!(
            "{path}: schema type must be a string or string array"
        )),
    }
}

fn validate_type_name(kind: &str, value: &Value, path: &str) -> Result<(), String> {
    let ok = match kind {
        "object" => value.is_object(),
        "array" => value.is_array(),
        "string" => value.is_string(),
        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
        "number" => value.as_f64().is_some(),
        "boolean" => value.is_boolean(),
        "null" => value.is_null(),
        other => return Err(format!("{path}: unsupported schema type {other}")),
    };
    if ok {
        Ok(())
    } else {
        Err(format!("{path}: expected {kind}, got {value:?}"))
    }
}

fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Result<&'a Value, String> {
    let Some(pointer) = reference.strip_prefix('#') else {
        return Err(format!("unsupported non-local ref {reference}"));
    };
    root.pointer(pointer)
        .ok_or_else(|| format!("unresolved ref {reference}"))
}

#[test]
fn checked_in_schemas_are_valid_json_schema_documents() {
    for name in [
        "summary.schema.json",
        "schema-diagnostics.schema.json",
        "element-records.schema.json",
        "export-diagnostics.schema.json",
        "corpus-report.schema.json",
        "element-counts.schema.json",
        "support-matrix.schema.json",
        "es-observation.schema.json",
        "es-capability.schema.json",
        "file-metadata.schema.json",
        "inventory-row.schema.json",
    ] {
        let schema = load_schema(name);
        assert_eq!(
            schema["$schema"], "https://json-schema.org/draft/2020-12/schema",
            "{name} should declare the draft it uses"
        );
        assert!(schema["$id"].is_string(), "{name} should declare an id");
        assert!(schema["title"].is_string(), "{name} should declare a title");
        assert_eq!(schema["type"], "object", "{name} should describe an object");
    }

    let decoded = load_schema("decoded-elements.schema.json");
    assert_eq!(
        decoded["$schema"],
        "https://json-schema.org/draft/2020-12/schema"
    );
    assert!(decoded["$id"].is_string());
    assert!(decoded["title"].is_string());
    assert_eq!(decoded["type"], "array");
}

/// `summary.schema.json` embeds the metadata definition (the in-test
/// validator resolves only local refs); it must not drift from the
/// standalone `file-metadata.schema.json`.
#[test]
fn summary_embeds_the_file_metadata_schema_verbatim() {
    let summary = load_schema("summary.schema.json");
    let metadata = load_schema("file-metadata.schema.json");
    for key in ["type", "required", "properties"] {
        assert_eq!(
            summary["$defs"]["file_metadata"][key], metadata[key],
            "summary.schema.json $defs.file_metadata.{key} drifted from file-metadata.schema.json"
        );
    }
}

/// `rvt-info` on one file and on a folder, against the tier-1 synthetic
/// fixtures — runs with no Autodesk corpus present.
#[test]
fn tier1_info_cli_validates_summary_and_inventory_schemas() {
    let tier1 = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("corpus")
        .join("tier1");
    let fixture = tier1
        .join("architectural-2024")
        .join("architectural-2024.rvt");
    if !fixture.exists() {
        eprintln!(
            "skipping tier1 info schema test: missing {}",
            fixture.display()
        );
        return;
    }
    let summary = command_json(
        env!("CARGO_BIN_EXE_rvt-info"),
        &[fixture.as_os_str(), std::ffi::OsStr::new("--json")],
    );
    validate(&load_schema("summary.schema.json"), &summary).expect("summary schema validation");

    let rows = command_json(
        env!("CARGO_BIN_EXE_rvt-info"),
        &[tier1.as_os_str(), std::ffi::OsStr::new("--json")],
    );
    let rows = rows.as_array().expect("inventory is a JSON array");
    assert!(
        rows.len() >= 3,
        "expected the three tier-1 fixtures, got {rows:?}"
    );
    let schema = load_schema("inventory-row.schema.json");
    for row in rows {
        validate(&schema, row).expect("inventory-row schema validation");
    }
}

#[test]
fn tier1_elements_cli_validates_decoded_and_counts_schemas() {
    let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("corpus")
        .join("tier1")
        .join("architectural-2024")
        .join("architectural-2024.rvt");
    if !fixture.exists() {
        eprintln!(
            "skipping tier1 elements schema test: missing {}",
            fixture.display()
        );
        return;
    }

    let elements = command_json(env!("CARGO_BIN_EXE_rvt-elements"), &[fixture.as_os_str()]);
    validate(&load_schema("decoded-elements.schema.json"), &elements)
        .expect("decoded-elements schema validation");

    let counts = command_json(
        env!("CARGO_BIN_EXE_rvt-elements"),
        &[fixture.as_os_str(), std::ffi::OsStr::new("--counts")],
    );
    validate(&load_schema("element-counts.schema.json"), &counts)
        .expect("element-counts schema validation");
    assert!(counts["total"].as_u64().is_some());
}

#[test]
fn cli_json_outputs_validate_against_stable_schemas() {
    if !corpus_available() {
        eprintln!(
            "skipping JSON schema contract test: family corpus missing at {}",
            samples_dir().display()
        );
        return;
    }

    let sample_2024 = sample_for_year(2024);
    let sample_2023 = sample_for_year(2023);
    let sample_2022 = sample_for_year(2022);

    let summary = command_json(
        env!("CARGO_BIN_EXE_rvt-info"),
        &[
            sample_2024.as_os_str(),
            std::ffi::OsStr::new("-f"),
            std::ffi::OsStr::new("json"),
        ],
    );
    validate(&load_schema("summary.schema.json"), &summary).expect("summary schema validation");

    let schema_diagnostics = command_json(
        env!("CARGO_BIN_EXE_rvt-schema"),
        &[
            sample_2024.as_os_str(),
            std::ffi::OsStr::new("-f"),
            std::ffi::OsStr::new("json"),
            std::ffi::OsStr::new("--diagnostics"),
        ],
    );
    validate(
        &load_schema("schema-diagnostics.schema.json"),
        &schema_diagnostics,
    )
    .expect("schema diagnostics schema validation");

    let element_records = command_json(
        env!("CARGO_BIN_EXE_rvt-doc"),
        &[
            sample_2024.as_os_str(),
            std::ffi::OsStr::new("--json"),
            std::ffi::OsStr::new("--redact"),
        ],
    );
    validate(
        &load_schema("element-records.schema.json"),
        &element_records,
    )
    .expect("element records schema validation");

    let diagnostics_path = std::env::temp_dir().join(format!(
        "rvt-schema-contract-{}-diagnostics.json",
        std::process::id()
    ));
    let ifc_path = std::env::temp_dir().join(format!(
        "rvt-schema-contract-{}-out.ifc",
        std::process::id()
    ));
    let ifc_output = Command::new(env!("CARGO_BIN_EXE_rvt-ifc"))
        .args([
            sample_2024.as_os_str(),
            std::ffi::OsStr::new("-o"),
            ifc_path.as_os_str(),
            std::ffi::OsStr::new("--diagnostics"),
            diagnostics_path.as_os_str(),
        ])
        .output()
        .expect("run rvt-ifc diagnostics");
    assert!(
        ifc_output.status.success(),
        "rvt-ifc failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&ifc_output.stdout),
        String::from_utf8_lossy(&ifc_output.stderr)
    );
    let export_diagnostics: Value = serde_json::from_str(
        &std::fs::read_to_string(&diagnostics_path).expect("read diagnostics JSON"),
    )
    .expect("parse diagnostics JSON");
    let _ = std::fs::remove_file(&diagnostics_path);
    let _ = std::fs::remove_file(&ifc_path);
    validate(
        &load_schema("export-diagnostics.schema.json"),
        &export_diagnostics,
    )
    .expect("export diagnostics schema validation");

    let corpus_report = command_json(
        env!("CARGO_BIN_EXE_rvt-corpus"),
        &[
            sample_2022.as_os_str(),
            sample_2023.as_os_str(),
            sample_2024.as_os_str(),
            std::ffi::OsStr::new("-f"),
            std::ffi::OsStr::new("json"),
        ],
    );
    validate(&load_schema("corpus-report.schema.json"), &corpus_report)
        .expect("corpus report schema validation");
}