main-spex 0.20.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::collections::{HashMap, HashSet};
use std::path::Path;

use serde_json::Value;

use crate::error::Result;
use crate::rule::RULES_DIR;
use crate::spec::SPECS_DIR;
use crate::support::yaml_dir::yaml_files;
use crate::templates::SCHEMAS_DIRNAME;

use super::super::finding::{Finding, Location};
use super::conformance_error;

#[cfg(test)]
#[path = "property_order_test.rs"]
mod property_order_test;

pub(super) const PROPERTY_ORDER_CHECK: &str = "property-order";

fn load_schema_file(cache: &mut HashMap<String, Value>, schemas_dir: &Path, name: &str) -> Value {
    if let Some(value) = cache.get(name) {
        return value.clone();
    }
    let value = std::fs::read_to_string(schemas_dir.join(name))
        .ok()
        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
        .unwrap_or(Value::Null);
    cache.insert(name.to_string(), value.clone());
    value
}

fn resolve_ref(
    cache: &mut HashMap<String, Value>,
    schemas_dir: &Path,
    root: &Value,
    node: &Value,
) -> (Value, Value) {
    let mut current = node.clone();
    let mut current_root = root.clone();
    loop {
        let Some(reference) = current.get("$ref").and_then(Value::as_str) else {
            return (current, current_root);
        };
        current = if let Some(pointer) = reference.strip_prefix("#/") {
            current_root
                .pointer(&format!("/{pointer}"))
                .cloned()
                .unwrap_or(Value::Null)
        } else {
            let external = load_schema_file(cache, schemas_dir, reference);
            current_root = external.clone();
            external
        };
    }
}

fn ordered_properties(resolved: &Value) -> Vec<String> {
    let mut order: Vec<String> = Vec::new();
    let mut seen: HashSet<String> = HashSet::new();

    if let Some(properties) = resolved.get("properties").and_then(Value::as_object) {
        for key in properties.keys() {
            if seen.insert(key.clone()) {
                order.push(key.clone());
            }
        }
    }

    if let Some(all_of) = resolved.get("allOf").and_then(Value::as_array) {
        for entry in all_of {
            let discriminator = entry
                .get("if")
                .and_then(|node| node.get("properties"))
                .and_then(Value::as_object)
                .and_then(|properties| properties.keys().next())
                .cloned();
            let then_keys: Vec<String> = entry
                .get("then")
                .and_then(|then| then.get("properties"))
                .and_then(Value::as_object)
                .map(|properties| properties.keys().cloned().collect())
                .unwrap_or_default();

            let mut insert_at = discriminator
                .and_then(|key| order.iter().position(|existing| *existing == key))
                .map(|position| position + 1)
                .unwrap_or(order.len());

            for key in then_keys {
                if seen.insert(key.clone()) {
                    order.insert(insert_at, key);
                    insert_at += 1;
                }
            }
        }
    }

    order
}

fn property_schema(resolved: &Value, key: &str) -> Option<Value> {
    if let Some(node) = resolved
        .get("properties")
        .and_then(|properties| properties.get(key))
    {
        return Some(node.clone());
    }
    resolved.get("allOf").and_then(Value::as_array).and_then(
        |all_of: &Vec<Value>| -> Option<Value> {
            all_of.iter().find_map(|entry| {
                entry
                    .get("then")
                    .and_then(|then| then.get("properties"))
                    .and_then(|properties| properties.get(key))
                    .cloned()
            })
        },
    )
}

fn find_order_violation(canonical: &[String], actual: &[String]) -> Option<(String, String)> {
    let filtered_actual: Vec<&String> = actual.iter().filter(|k| canonical.contains(k)).collect();
    let filtered_canonical: Vec<&String> =
        canonical.iter().filter(|k| actual.contains(k)).collect();
    filtered_actual
        .iter()
        .zip(filtered_canonical.iter())
        .find(|(found, expected)| found != expected)
        .map(|(found, expected)| ((*found).clone(), (*expected).clone()))
}

fn property_order_message(
    found: &str,
    expected: &str,
    canonical: &[String],
    label: &str,
) -> String {
    let scope = if label.is_empty() {
        String::new()
    } else {
        format!(" in `{label}`")
    };
    format!(
        "property `{found}` is out of order{scope}: expected `{expected}` at this position (canonical order: {})",
        canonical.join(", ")
    )
}

pub(super) fn validate_property_order(spex_dir: &Path) -> Result<Vec<Finding>> {
    let schemas_dir = spex_dir.join(SCHEMAS_DIRNAME);
    let mut cache: HashMap<String, Value> = HashMap::new();
    let mut findings = Vec::new();

    let spec_schema = load_schema_file(&mut cache, &schemas_dir, "spec.schema.json");
    for file in yaml_files(&spex_dir.join(SPECS_DIR))? {
        check_file_property_order(&file, &spec_schema, &mut cache, &schemas_dir, &mut findings);
    }

    let rule_schema = load_schema_file(&mut cache, &schemas_dir, "rule.schema.json");
    for file in yaml_files(&spex_dir.join(RULES_DIR))? {
        check_file_property_order(&file, &rule_schema, &mut cache, &schemas_dir, &mut findings);
    }

    let spex_yaml = spex_dir.join(crate::config::SPEX_CONFIG_FILE);
    if spex_yaml.is_file() {
        let spex_schema = load_schema_file(&mut cache, &schemas_dir, "spex.schema.json");
        check_file_property_order(
            &spex_yaml,
            &spex_schema,
            &mut cache,
            &schemas_dir,
            &mut findings,
        );
    }
    Ok(findings)
}

fn check_file_property_order(
    file: &Path,
    schema: &Value,
    cache: &mut HashMap<String, Value>,
    schemas_dir: &Path,
    findings: &mut Vec<Finding>,
) {
    if schema.is_null() {
        return;
    }
    let Ok(text) = std::fs::read_to_string(file) else {
        return;
    };
    let Ok(value) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&text) else {
        return;
    };

    check_node_order(
        file,
        "",
        &value,
        schema,
        schema,
        cache,
        schemas_dir,
        findings,
    );
}

#[allow(clippy::too_many_arguments)]
fn check_node_order(
    file: &Path,
    label: &str,
    actual: &serde_yaml_ng::Value,
    schema_node: &Value,
    root: &Value,
    cache: &mut HashMap<String, Value>,
    schemas_dir: &Path,
    findings: &mut Vec<Finding>,
) {
    let (resolved, resolved_root) = resolve_ref(cache, schemas_dir, root, schema_node);

    match actual {
        serde_yaml_ng::Value::Mapping(mapping) => {
            let canonical = ordered_properties(&resolved);
            if canonical.is_empty() {
                return;
            }

            let actual_keys: Vec<String> = mapping
                .keys()
                .filter_map(|k| k.as_str().map(str::to_string))
                .collect();
            if let Some((found, expected)) = find_order_violation(&canonical, &actual_keys) {
                findings.push(conformance_error(
                    PROPERTY_ORDER_CHECK,
                    Some(Location::in_file(file)),
                    property_order_message(&found, &expected, &canonical, label),
                ));
            }

            for (key, value) in mapping {
                let Some(key) = key.as_str() else {
                    continue;
                };
                let Some(child_schema) = property_schema(&resolved, key) else {
                    continue;
                };
                let child_label = if label.is_empty() {
                    key.to_string()
                } else {
                    format!("{label}.{key}")
                };
                check_node_order(
                    file,
                    &child_label,
                    value,
                    &child_schema,
                    &resolved_root,
                    cache,
                    schemas_dir,
                    findings,
                );
            }
        }
        serde_yaml_ng::Value::Sequence(items) => {
            let Some(items_schema) = resolved.get("items") else {
                return;
            };
            for (index, item) in items.iter().enumerate() {
                let child_label = format!("{label}[{index}]");
                check_node_order(
                    file,
                    &child_label,
                    item,
                    items_schema,
                    &resolved_root,
                    cache,
                    schemas_dir,
                    findings,
                );
            }
        }
        _ => {}
    }
}