odcs 0.4.0

Reference implementation of the Open Data Contract Standard (ODCS)
Documentation
//! ODCS document parsers.

mod duplicate_keys;
mod json;
mod yaml;

use std::path::Path;

pub use json::parse_json;
pub use yaml::parse_yaml;

use crate::diagnostics::{
    codes, emit, Diagnostic, DiagnosticCategory, DiagnosticReport, DiagnosticStage,
};
use crate::model::DataContract;
use crate::validation::{validate_with_options, ValidationOptions};

/// Maximum document size accepted by [`parse_file`] (16 MiB).
pub const MAX_PARSE_BYTES: u64 = 16 * 1024 * 1024;

/// Result of parsing an ODCS document.
#[derive(Debug, Clone)]
pub struct ParseResult {
    /// Parsed contract when parsing succeeded.
    pub contract: Option<DataContract>,
    /// Parse-time diagnostics.
    pub report: DiagnosticReport,
}

impl ParseResult {
    /// Returns the parsed contract when parsing and validation succeeded.
    pub fn into_contract(self) -> Result<DataContract, DiagnosticReport> {
        match (self.contract, self.report.is_valid()) {
            (Some(contract), true) => {
                let validation_report = crate::validate(&contract);
                if validation_report.is_valid() {
                    Ok(contract)
                } else {
                    let mut report = self.report;
                    report.merge(validation_report);
                    Err(report)
                }
            }
            (_, false) => Err(self.report),
            (None, true) => Err(self.report),
        }
    }

    /// Parses and validates in one step with default (non-strict) options.
    #[must_use]
    pub fn validate(self) -> DiagnosticReport {
        self.validate_with_options(ValidationOptions::default_options())
    }

    /// Parses and validates in one step with the given options.
    #[must_use]
    pub fn validate_with_options(self, options: ValidationOptions) -> DiagnosticReport {
        let mut report = self.report;
        if let Some(contract) = self.contract {
            report.merge(validate_with_options(&contract, options));
        }
        report
    }
}

/// Supported ODCS document serialization formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentFormat {
    /// YAML encoding.
    Yaml,
    /// JSON encoding.
    Json,
}

impl DocumentFormat {
    /// Infers format from a file extension.
    #[must_use]
    pub fn from_path(path: &Path) -> Option<Self> {
        match path.extension()?.to_str()? {
            "yaml" | "yml" => Some(Self::Yaml),
            "json" => Some(Self::Json),
            _ => None,
        }
    }
}

/// Parse an ODCS document from bytes.
#[must_use]
pub fn parse(content: &[u8], format: DocumentFormat) -> ParseResult {
    if content.len() as u64 > MAX_PARSE_BYTES {
        return failure_document_too_large();
    }
    match format {
        DocumentFormat::Yaml => parse_yaml(content),
        DocumentFormat::Json => parse_json(content),
    }
}

/// Parse and validate an ODCS document, returning an error report on failure.
pub fn parse_strict(
    content: &[u8],
    format: DocumentFormat,
) -> Result<DataContract, DiagnosticReport> {
    parse(content, format).into_contract()
}

/// Build a successful parse result.
pub(crate) fn success(contract: DataContract) -> ParseResult {
    ParseResult {
        contract: Some(contract),
        report: DiagnosticReport::new(),
    }
}

/// Build a failed parse result with an enriched serde diagnostic.
pub(crate) fn failure_from_serde(code: &str, error: impl std::fmt::Display) -> ParseResult {
    let message = error.to_string();
    let mut diagnostic = Diagnostic::error(
        code,
        DiagnosticCategory::Syntax,
        DiagnosticStage::Parse,
        format!("failed to parse document: {message}"),
    );

    if let Some(object_ref) = extract_unknown_field_ref(&message) {
        diagnostic = diagnostic
            .with_object_ref(object_ref)
            .with_remediation("remove the unknown field or use customProperties for extensions");
        diagnostic.id = codes::UNKNOWN_FIELD.to_string();
    } else if let Some(object_ref) = extract_serde_path(&message) {
        diagnostic = diagnostic.with_object_ref(object_ref);
    }

    let mut report = DiagnosticReport::new();
    emit(&mut report, diagnostic);
    ParseResult {
        contract: None,
        report,
    }
}

pub(crate) fn failure_duplicate_key(key: String) -> ParseResult {
    let mut report = DiagnosticReport::new();
    emit(
        &mut report,
        Diagnostic::error(
            codes::DUPLICATE_KEY,
            DiagnosticCategory::Syntax,
            DiagnosticStage::Parse,
            format!("duplicate key '{key}' in document"),
        )
        .with_object_ref(key)
        .with_remediation("remove duplicate keys so each field appears once"),
    );
    ParseResult {
        contract: None,
        report,
    }
}

fn failure_document_too_large() -> ParseResult {
    let mut report = DiagnosticReport::new();
    emit(
        &mut report,
        Diagnostic::error(
            codes::DOCUMENT_TOO_LARGE,
            DiagnosticCategory::Syntax,
            DiagnosticStage::Parse,
            format!("document exceeds maximum size of {MAX_PARSE_BYTES} bytes"),
        )
        .with_remediation("split the contract or reduce document size"),
    );
    ParseResult {
        contract: None,
        report,
    }
}

fn extract_unknown_field_ref(message: &str) -> Option<String> {
    let marker = "unknown field `";
    let start = message.find(marker)? + marker.len();
    let rest = &message[start..];
    let end = rest.find('`')?;
    Some(rest[..end].to_string())
}

fn extract_serde_path(message: &str) -> Option<String> {
    if let Some(path) = message.strip_prefix("at ") {
        return Some(path.trim().to_string());
    }
    let marker = " at line ";
    if !message.contains(marker) {
        return None;
    }
    message
        .split_whitespace()
        .find(|token| token.contains('.'))
        .map(|token| token.trim_end_matches(',').to_string())
}

/// Parse an ODCS document from a file path.
pub fn parse_file(path: impl AsRef<Path>) -> miette::Result<ParseResult> {
    let path = path.as_ref();
    let metadata = std::fs::metadata(path)
        .map_err(|e| miette::miette!("failed to read {}: {e}", path.display()))?;
    if metadata.len() > MAX_PARSE_BYTES {
        return Ok(failure_document_too_large());
    }
    let content = std::fs::read(path)
        .map_err(|e| miette::miette!("failed to read {}: {e}", path.display()))?;
    let format = DocumentFormat::from_path(path).ok_or_else(|| {
        miette::miette!(
            "unsupported file extension for {}: expected .yaml, .yml, or .json",
            path.display()
        )
    })?;
    Ok(parse(&content, format))
}