xbrl-rs 0.3.0

XBRL parser and validation
Documentation
use super::ValidationResult;
use crate::{
    InstanceDocument, ItemFact, TaxonomySet,
    taxonomy::{Concept, XbrlType},
};
use chrono::{NaiveDate, NaiveDateTime};
use rust_decimal::Decimal;
use std::{collections::HashMap, str::FromStr};

#[derive(Debug, Clone)]
pub(super) enum FactValue {
    Text(String),
    Numeric(Decimal),
    Boolean(bool),
    Date(NaiveDate),
    DateTime(NaiveDateTime),
}

#[derive(Default)]
pub(super) struct PreparedFactValues {
    values: HashMap<*const ItemFact, Option<FactValue>>,
}

impl PreparedFactValues {
    pub(super) fn insert(&mut self, fact: &ItemFact, value: Option<FactValue>) {
        self.values.insert(fact as *const ItemFact, value);
    }

    pub(super) fn get(&self, fact: &ItemFact) -> Option<&Option<FactValue>> {
        self.values.get(&(fact as *const ItemFact))
    }

    pub(super) fn numeric_value(&self, fact: &ItemFact) -> Option<Decimal> {
        match self.get(fact)? {
            Some(FactValue::Numeric(value)) => Some(*value),
            _ => None,
        }
    }

    #[allow(dead_code)]
    pub(super) fn text_value(&self, fact: &ItemFact) -> Option<&str> {
        match self.get(fact)? {
            Some(FactValue::Text(value)) => Some(value),
            _ => None,
        }
    }

    #[allow(dead_code)]
    pub(super) fn boolean_value(&self, fact: &ItemFact) -> Option<bool> {
        match self.get(fact)? {
            Some(FactValue::Boolean(value)) => Some(*value),
            _ => None,
        }
    }

    #[allow(dead_code)]
    pub(super) fn date_value(&self, fact: &ItemFact) -> Option<NaiveDate> {
        match self.get(fact)? {
            Some(FactValue::Date(value)) => Some(*value),
            _ => None,
        }
    }

    #[allow(dead_code)]
    pub(super) fn datetime_value(&self, fact: &ItemFact) -> Option<NaiveDateTime> {
        match self.get(fact)? {
            Some(FactValue::DateTime(value)) => Some(*value),
            _ => None,
        }
    }
}

enum ExpectedValueKind {
    Numeric,
    Boolean,
    Date,
    DateTime,
    Other,
}

pub(super) fn prepare_fact_values(
    instance: &InstanceDocument,
    taxonomy: &TaxonomySet,
    _result: &mut ValidationResult,
) -> PreparedFactValues {
    let mut prepared = PreparedFactValues::default();

    for fact in instance.item_facts() {
        if fact.is_nil() {
            prepared.insert(fact, None);
            continue;
        }

        let Some(element) = taxonomy.find_concept(fact.concept_name()) else {
            prepared.insert(fact, Some(FactValue::Text(fact.value().to_string())));
            continue;
        };

        let trimmed = fact.value().trim();
        let parsed = match expected_value_kind(element) {
            ExpectedValueKind::Numeric => parse_numeric_compatible(trimmed)
                .map(FactValue::Numeric)
                .or_else(|| Some(FactValue::Text(trimmed.to_string()))),
            ExpectedValueKind::Boolean => parse_boolean(trimmed)
                .map(FactValue::Boolean)
                .or_else(|| Some(FactValue::Text(trimmed.to_string()))),
            ExpectedValueKind::Date => parse_xsd_date(trimmed)
                .map(FactValue::Date)
                .or_else(|| Some(FactValue::Text(trimmed.to_string()))),
            ExpectedValueKind::DateTime => parse_xsd_datetime(trimmed)
                .map(FactValue::DateTime)
                .or_else(|| Some(FactValue::Text(trimmed.to_string()))),
            ExpectedValueKind::Other => Some(FactValue::Text(trimmed.to_string())),
        };

        prepared.insert(fact, parsed);
    }

    prepared
}

fn parse_numeric_compatible(value: &str) -> Option<Decimal> {
    if value.is_empty() {
        return None;
    }

    if let Ok(decimal) = Decimal::from_str(value) {
        return Some(decimal);
    }

    Decimal::from_scientific(value).ok()
}

fn expected_value_kind(element: &Concept) -> ExpectedValueKind {
    match element.data_type {
        XbrlType::Monetary
        | XbrlType::Decimal
        | XbrlType::Integer
        | XbrlType::Float
        | XbrlType::Double
        | XbrlType::Shares
        | XbrlType::Pure
        | XbrlType::Fraction => ExpectedValueKind::Numeric,
        XbrlType::Boolean => ExpectedValueKind::Boolean,
        XbrlType::Date => ExpectedValueKind::Date,
        XbrlType::DateTime => ExpectedValueKind::DateTime,
        XbrlType::Percent
        | XbrlType::PerShare
        | XbrlType::String
        | XbrlType::QName
        | XbrlType::Simple(_)
        | XbrlType::Complex(_) => ExpectedValueKind::Other,
    }
}

fn parse_boolean(value: &str) -> Option<bool> {
    if value.eq_ignore_ascii_case("true") || value == "1" {
        return Some(true);
    }
    if value.eq_ignore_ascii_case("false") || value == "0" {
        return Some(false);
    }
    None
}

fn parse_xsd_date(value: &str) -> Option<NaiveDate> {
    let normalized = strip_xsd_timezone(value);
    NaiveDate::parse_from_str(normalized, "%Y-%m-%d").ok()
}

fn parse_xsd_datetime(value: &str) -> Option<NaiveDateTime> {
    let normalized = strip_xsd_timezone(value);
    NaiveDateTime::parse_from_str(normalized, "%Y-%m-%dT%H:%M:%S%.f")
        .or_else(|_| NaiveDateTime::parse_from_str(normalized, "%Y-%m-%dT%H:%M:%S"))
        .ok()
}

fn strip_xsd_timezone(value: &str) -> &str {
    let trimmed = value.trim();
    if let Some(without_z) = trimmed.strip_suffix('Z') {
        return without_z;
    }

    if trimmed.len() >= 6 {
        let tz_marker_index = trimmed.len() - 6;
        let bytes = trimmed.as_bytes();
        if (bytes[tz_marker_index] == b'+' || bytes[tz_marker_index] == b'-')
            && bytes.get(tz_marker_index + 3) == Some(&b':')
        {
            return &trimmed[..tz_marker_index];
        }
    }

    trimmed
}