main-spex 0.25.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::Args;
use eros::Context;
use jsonschema::{Retrieve, Uri, Validator};
use serde_json::Value;

use crate::error::{Result, could_not_parse, could_not_read, describe};

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

#[derive(Args)]
pub struct CheckSchemaArgs {
    #[arg(long, help = "JSON Schema the files are validated against")]
    schema: String,
    #[arg(
        long,
        num_args = 1..,
        required = true,
        help = "JSON or YAML files to validate against the schema"
    )]
    files: Vec<String>,
}

pub fn run(args: CheckSchemaArgs) -> Result<ExitCode> {
    let validator = build_validator(Path::new(&args.schema))?;

    let mut failed = false;
    for file in &args.files {
        match validate_file(&validator, Path::new(file)) {
            Ok(messages) => {
                for message in messages {
                    failed = true;
                    eprintln!("{file}: {message}");
                }
            }
            Err(error) => {
                failed = true;
                eprintln!("{file}: {}", describe(&error));
            }
        }
    }

    Ok(if failed {
        ExitCode::FAILURE
    } else {
        ExitCode::SUCCESS
    })
}

pub(crate) fn build_validator(schema_path: &Path) -> Result<Validator> {
    let text = fs::read_to_string(schema_path).with_context(|| could_not_read(schema_path))?;
    let schema: Value =
        serde_json::from_str(&text).with_context(|| could_not_parse(schema_path))?;

    let dir = schema_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("."));

    jsonschema::options()
        .with_retriever(DirRetriever { dir })
        .build(&schema)
        .map_err(|err| eros::error!("invalid schema `{}`: {err}", schema_path.display()))
}

pub(crate) fn validate_file(validator: &Validator, path: &Path) -> Result<Vec<String>> {
    let text = fs::read_to_string(path).with_context(|| could_not_read(path))?;
    let instance: Value = serde_yaml_ng::from_str(&text).with_context(|| could_not_parse(path))?;

    Ok(validate_instance(validator, &instance))
}

pub(crate) fn validate_instance(validator: &Validator, instance: &Value) -> Vec<String> {
    let mut messages: Vec<String> = validator
        .iter_errors(instance)
        .map(|err| {
            let location = err.instance_path().to_string();
            if location.is_empty() {
                err.to_string()
            } else {
                format!("{location}: {err}")
            }
        })
        .collect();
    messages.sort();
    messages.dedup();
    messages
}

struct DirRetriever {
    dir: PathBuf,
}

impl Retrieve for DirRetriever {
    fn retrieve(
        &self,
        uri: &Uri<String>,
    ) -> core::result::Result<Value, Box<dyn std::error::Error + Send + Sync>> {
        let name = uri.as_str().rsplit('/').next().unwrap_or_default();
        let text = fs::read_to_string(self.dir.join(name))?;
        Ok(serde_json::from_str(&text)?)
    }
}