Skip to main content

xbrl_rs/instance/
typed.rs

1use super::{
2    Context, ContextId, Decimals, Fact, FootnoteLink, InstanceDocument, ItemFact, TupleFact, Unit,
3    UnitId,
4};
5use crate::{
6    Concept, ExpandedName, NamespacePrefix, NamespaceUri, TaxonomySet, ValueError, XbrlError,
7    XbrlType,
8};
9use chrono::{NaiveDate, NaiveDateTime};
10use rust_decimal::Decimal;
11use std::{collections::HashMap, str::FromStr};
12
13/// A typed representation of an XBRL instance document.
14#[derive(Debug, Clone)]
15pub struct TypedInstanceDocument {
16    /// Namespace prefixes used in the document.
17    namespaces: HashMap<NamespacePrefix, NamespaceUri>,
18    /// Schema references from link:schemaRef elements.
19    schema_refs: Vec<String>,
20    /// Role references declared in the instance.
21    role_refs: Vec<String>,
22    /// Arcrole references declared in the instance.
23    arcrole_refs: Vec<String>,
24    /// All contexts in the instance.
25    contexts: HashMap<ContextId, Context>,
26    /// All units in the instance.
27    units: HashMap<UnitId, Unit>,
28    /// Top-level typed facts.
29    facts: Vec<TypedFact>,
30    /// Footnote links found in the instance.
31    footnote_links: Vec<FootnoteLink>,
32}
33
34impl TypedInstanceDocument {
35    /// Converts a raw `InstanceDocument` into a `TypedInstanceDocument` where
36    /// `Fact`s are replaced by `TypedFact`s.
37    pub fn from_instance(
38        instance: InstanceDocument,
39        taxonomy: &TaxonomySet,
40    ) -> Result<Self, XbrlError> {
41        let InstanceDocument {
42            namespaces,
43            schema_refs,
44            role_refs,
45            arcrole_refs,
46            contexts,
47            units,
48            facts,
49            footnote_links,
50        } = instance;
51
52        let facts = convert_facts(&facts, taxonomy, &contexts, &units, &namespaces)?;
53
54        Ok(Self {
55            namespaces,
56            schema_refs,
57            role_refs,
58            arcrole_refs,
59            contexts,
60            units,
61            facts,
62            footnote_links,
63        })
64    }
65
66    pub fn facts(&self) -> &[TypedFact] {
67        &self.facts
68    }
69
70    pub fn contexts(&self) -> &HashMap<ContextId, Context> {
71        &self.contexts
72    }
73
74    pub fn units(&self) -> &HashMap<UnitId, Unit> {
75        &self.units
76    }
77
78    pub fn schema_refs(&self) -> &[String] {
79        &self.schema_refs
80    }
81
82    pub fn role_refs(&self) -> &[String] {
83        &self.role_refs
84    }
85
86    pub fn arcrole_refs(&self) -> &[String] {
87        &self.arcrole_refs
88    }
89
90    pub fn namespaces(&self) -> &HashMap<NamespacePrefix, NamespaceUri> {
91        &self.namespaces
92    }
93
94    pub fn footnote_links(&self) -> &[FootnoteLink] {
95        &self.footnote_links
96    }
97}
98
99/// Represents a typed fact in the instance, which can be either an item or a tuple.
100#[allow(clippy::large_enum_variant)]
101#[derive(Debug, Clone)]
102pub enum TypedFact {
103    Item(TypedItemFact),
104    Tuple(TypedTupleFact),
105}
106
107#[derive(Debug, Clone)]
108pub struct TypedItemFact {
109    /// Resolved concept
110    pub concept: Concept,
111    /// Optional XML id
112    pub id: Option<String>,
113    /// Resolved context
114    pub context: Context,
115    /// Resolved unit (if numeric).
116    pub unit: Option<Unit>,
117    /// Typed and validated value. Nil facts are represented as `None`.
118    pub value: Option<FactValue>,
119    /// Decimals attribute (if numeric).
120    pub decimals: Option<Decimals>,
121    /// Precision attribute (if numeric).
122    pub precision: Option<Decimals>,
123}
124
125#[derive(Debug, Clone)]
126pub struct TypedTupleFact {
127    pub concept: Concept,
128    pub id: Option<String>,
129    pub children: Vec<TypedFact>,
130}
131
132#[derive(Debug, Clone)]
133pub enum FactValue {
134    Text(String),
135    Boolean(bool),
136    Integer(i64),
137    Decimal(Decimal),
138    Date(NaiveDate),
139    DateTime(NaiveDateTime),
140    QName(ExpandedName),
141}
142
143fn convert_facts(
144    facts: &[Fact],
145    taxonomy: &TaxonomySet,
146    contexts: &HashMap<ContextId, Context>,
147    units: &HashMap<UnitId, Unit>,
148    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
149) -> Result<Vec<TypedFact>, XbrlError> {
150    facts
151        .iter()
152        .map(|fact| convert_fact(fact, taxonomy, contexts, units, namespaces))
153        .collect()
154}
155
156fn convert_fact(
157    fact: &Fact,
158    taxonomy: &TaxonomySet,
159    contexts: &HashMap<ContextId, Context>,
160    units: &HashMap<UnitId, Unit>,
161    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
162) -> Result<TypedFact, XbrlError> {
163    match fact {
164        Fact::Item(item) => Ok(TypedFact::Item(convert_item(
165            item, taxonomy, contexts, units, namespaces,
166        )?)),
167        Fact::Tuple(tuple) => Ok(TypedFact::Tuple(convert_tuple(
168            tuple, taxonomy, contexts, units, namespaces,
169        )?)),
170    }
171}
172
173fn convert_item(
174    item: &ItemFact,
175    taxonomy: &TaxonomySet,
176    contexts: &HashMap<ContextId, Context>,
177    units: &HashMap<UnitId, Unit>,
178    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
179) -> Result<TypedItemFact, XbrlError> {
180    let concept = taxonomy
181        .find_concept(item.concept_name())
182        .ok_or_else(|| ValueError::UnknownConcept {
183            concept: item.concept_name().to_string(),
184        })?
185        .clone();
186
187    let context =
188        contexts
189            .get(item.context_ref())
190            .cloned()
191            .ok_or_else(|| ValueError::MissingContext {
192                context_ref: item.context_ref().to_owned(),
193            })?;
194
195    let unit = match item.unit_ref() {
196        Some(unit_ref) => {
197            Some(
198                units
199                    .get(unit_ref)
200                    .cloned()
201                    .ok_or_else(|| ValueError::MissingUnit {
202                        unit_ref: unit_ref.to_owned(),
203                    })?,
204            )
205        }
206        None => None,
207    };
208
209    let value = if item.is_nil() {
210        None
211    } else {
212        Some(parse_fact_value(item.value(), &concept, namespaces)?)
213    };
214
215    Ok(TypedItemFact {
216        concept: concept.to_owned(),
217        id: item.id().map(str::to_owned),
218        context,
219        unit,
220        value,
221        decimals: item.decimals().cloned(),
222        precision: item.precision().cloned(),
223    })
224}
225
226fn convert_tuple(
227    tuple: &TupleFact,
228    taxonomy: &TaxonomySet,
229    contexts: &HashMap<ContextId, Context>,
230    units: &HashMap<UnitId, Unit>,
231    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
232) -> Result<TypedTupleFact, XbrlError> {
233    let concept = taxonomy
234        .find_concept(tuple.concept_name())
235        .ok_or_else(|| ValueError::UnknownConcept {
236            concept: tuple.concept_name().to_string(),
237        })?
238        .clone();
239
240    let children = convert_facts(tuple.children(), taxonomy, contexts, units, namespaces)?;
241
242    Ok(TypedTupleFact {
243        concept,
244        id: tuple.id().map(str::to_owned),
245        children,
246    })
247}
248
249pub(crate) fn parse_fact_value(
250    raw: &str,
251    concept: &Concept,
252    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
253) -> Result<FactValue, XbrlError> {
254    let value = raw.trim();
255
256    match concept.data_type {
257        XbrlType::Boolean => parse_boolean(value).map(FactValue::Boolean),
258        XbrlType::Integer => parse_integer(value).map(FactValue::Integer),
259        XbrlType::Monetary
260        | XbrlType::Decimal
261        | XbrlType::Float
262        | XbrlType::Double
263        | XbrlType::Shares
264        | XbrlType::Pure
265        | XbrlType::Fraction
266        | XbrlType::Percent
267        | XbrlType::PerShare => parse_decimal_compatible(value).map(FactValue::Decimal),
268        XbrlType::Date => parse_xsd_date(value).map(FactValue::Date),
269        XbrlType::DateTime => parse_xsd_datetime(value).map(FactValue::DateTime),
270        XbrlType::QName => parse_qname_value(value, namespaces).map(FactValue::QName),
271        XbrlType::String | XbrlType::Simple(_) | XbrlType::Complex(_) => {
272            Ok(FactValue::Text(value.to_owned()))
273        }
274    }
275}
276
277fn parse_boolean(value: &str) -> Result<bool, XbrlError> {
278    if value.eq_ignore_ascii_case("true") || value == "1" {
279        return Ok(true);
280    }
281    if value.eq_ignore_ascii_case("false") || value == "0" {
282        return Ok(false);
283    }
284
285    Err(ValueError::InvalidBoolean {
286        raw: value.to_owned(),
287    }
288    .into())
289}
290
291fn parse_integer(value: &str) -> Result<i64, XbrlError> {
292    i64::from_str(value).map_err(|_| {
293        ValueError::InvalidInteger {
294            raw: value.to_owned(),
295        }
296        .into()
297    })
298}
299
300fn parse_decimal_compatible(value: &str) -> Result<Decimal, XbrlError> {
301    if value.is_empty() {
302        return Err(ValueError::InvalidDecimal {
303            raw: value.to_owned(),
304        }
305        .into());
306    }
307
308    if let Ok(decimal) = Decimal::from_str(value) {
309        return Ok(decimal);
310    }
311
312    Decimal::from_scientific(value).map_err(|_| {
313        ValueError::InvalidDecimal {
314            raw: value.to_owned(),
315        }
316        .into()
317    })
318}
319
320fn parse_xsd_date(value: &str) -> Result<NaiveDate, XbrlError> {
321    let normalized = strip_xsd_timezone(value);
322    NaiveDate::parse_from_str(normalized, "%Y-%m-%d").map_err(|_| {
323        ValueError::InvalidDate {
324            raw: value.to_owned(),
325        }
326        .into()
327    })
328}
329
330fn parse_xsd_datetime(value: &str) -> Result<NaiveDateTime, XbrlError> {
331    let normalized = strip_xsd_timezone(value);
332    NaiveDateTime::parse_from_str(normalized, "%Y-%m-%dT%H:%M:%S%.f")
333        .or_else(|_| NaiveDateTime::parse_from_str(normalized, "%Y-%m-%dT%H:%M:%S"))
334        .map_err(|_| {
335            ValueError::InvalidDateTime {
336                raw: value.to_owned(),
337            }
338            .into()
339        })
340}
341
342fn strip_xsd_timezone(value: &str) -> &str {
343    let trimmed = value.trim();
344    if let Some(without_z) = trimmed.strip_suffix('Z') {
345        return without_z;
346    }
347
348    if trimmed.len() >= 6 {
349        let tz_marker_index = trimmed.len() - 6;
350        let bytes = trimmed.as_bytes();
351        if (bytes[tz_marker_index] == b'+' || bytes[tz_marker_index] == b'-')
352            && bytes.get(tz_marker_index + 3) == Some(&b':')
353        {
354            return &trimmed[..tz_marker_index];
355        }
356    }
357
358    trimmed
359}
360
361fn parse_qname_value(
362    value: &str,
363    namespaces: &HashMap<NamespacePrefix, NamespaceUri>,
364) -> Result<ExpandedName, XbrlError> {
365    if value.is_empty() {
366        return Err(ValueError::InvalidQName {
367            raw: value.to_owned(),
368            reason: "QName value is empty".to_owned(),
369        }
370        .into());
371    }
372
373    let (prefix, local_name) = match value.split_once(':') {
374        Some((prefix, local_name)) => (Some(prefix), local_name),
375        None => (None, value),
376    };
377
378    if local_name.is_empty() {
379        return Err(ValueError::InvalidQName {
380            raw: value.to_owned(),
381            reason: "QName local name is empty".to_owned(),
382        }
383        .into());
384    }
385
386    let namespace_uri = match prefix {
387        Some(prefix) => {
388            namespaces
389                .get(prefix)
390                .cloned()
391                .ok_or_else(|| ValueError::InvalidQName {
392                    raw: value.to_owned(),
393                    reason: format!("Unknown QName prefix '{prefix}'"),
394                })?
395        }
396        None => namespaces
397            .get("")
398            .cloned()
399            .unwrap_or_else(|| NamespaceUri::from("")),
400    };
401
402    Ok(ExpandedName::new(namespace_uri, local_name.to_owned()))
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{FactValue, parse_fact_value};
408    use crate::{
409        Balance, BaseSubstitutionGroup, Concept, ExpandedName, NamespaceUri, SubstitutionGroup,
410        XbrlType,
411    };
412    use std::collections::HashMap;
413
414    fn concept_with_type(data_type: XbrlType) -> Concept {
415        Concept {
416            id: Some("c1".to_owned()),
417            name: ExpandedName::new(
418                NamespaceUri::from("http://example.com"),
419                "Concept".to_owned(),
420            ),
421            data_type,
422            substitution_group: SubstitutionGroup {
423                base: BaseSubstitutionGroup::Item,
424                original: ExpandedName::new(
425                    NamespaceUri::from("http://www.xbrl.org/2003/instance"),
426                    "item".to_owned(),
427                ),
428            },
429            period_type: None,
430            balance: Some(Balance::Debit),
431            nillable: true,
432            is_abstract: false,
433            content_model: None,
434        }
435    }
436
437    #[test]
438    fn parse_integer_value() {
439        let concept = concept_with_type(XbrlType::Integer);
440        let value = parse_fact_value("42", &concept, &HashMap::new()).expect("integer parse");
441        assert!(matches!(value, FactValue::Integer(42)));
442    }
443
444    #[test]
445    fn parse_invalid_boolean_value() {
446        let concept = concept_with_type(XbrlType::Boolean);
447        let value = parse_fact_value("truth", &concept, &HashMap::new());
448        assert!(value.is_err());
449    }
450}