Skip to main content

xbrl_rs/taxonomy/
view.rs

1//! Read-only taxonomy-centric views for presentation trees and single concepts.
2
3use super::{
4    Concept, GroupParticle, Label, Occurrence, Particle, PresentationArc, Reference, TaxonomySet,
5};
6use crate::ExpandedName;
7use rust_decimal::Decimal;
8use std::{
9    cmp::Ordering,
10    collections::{HashMap, HashSet},
11};
12
13/// A hierarchical view of taxonomy concepts organized by presentation role.
14#[derive(Debug)]
15pub struct TaxonomyView<'a> {
16    /// One section per extended link role found in the presentation linkbase.
17    pub sections: Vec<TaxonomySectionView<'a>>,
18}
19
20impl<'a> TaxonomyView<'a> {
21    /// Build a taxonomy-wide presentation view across all roles.
22    pub fn build(taxonomy: &'a TaxonomySet) -> Self {
23        build_taxonomy_view(taxonomy)
24    }
25
26    /// Build a taxonomy presentation view for one role URI.
27    pub fn build_for_role(taxonomy: &'a TaxonomySet, role: &'a str) -> Option<Self> {
28        taxonomy.presentation_arcs(role).map(|arcs| TaxonomyView {
29            sections: vec![build_section(role, arcs, taxonomy)],
30        })
31    }
32}
33
34/// One presentation role section in a taxonomy view.
35#[derive(Debug)]
36pub struct TaxonomySectionView<'a> {
37    /// The extended link role URI identifying this section.
38    pub role: &'a str,
39    /// Root concept nodes for this presentation section.
40    pub nodes: Vec<TaxonomyTreeNode<'a>>,
41}
42
43/// A concept node in a taxonomy presentation tree.
44#[derive(Debug)]
45pub struct TaxonomyTreeNode<'a> {
46    /// The concept represented by this node.
47    pub concept: &'a Concept,
48    /// Labels for this concept.
49    pub labels: &'a [Label],
50    /// Depth in the tree. Root nodes have depth 0.
51    pub depth: usize,
52    /// Child concepts, ordered by presentation arc order.
53    pub children: Vec<TaxonomyTreeNode<'a>>,
54}
55
56/// A focused view of a single concept and its relationships.
57#[derive(Debug)]
58pub struct ConceptView<'a> {
59    /// The concept represented by this view.
60    pub concept: &'a Concept,
61    /// Labels for this concept.
62    pub labels: &'a [Label],
63    /// References for this concept, keyed by concept id when available.
64    pub references: &'a [Reference],
65    /// Direct tuple parent concept when this concept belongs to a tuple.
66    pub parent_tuple: Option<&'a Concept>,
67    /// Tuple ancestor chain from root tuple to direct tuple parent.
68    pub tuple_ancestors: Vec<&'a Concept>,
69    /// Presentation parents of this concept across roles.
70    pub presentation_parents: Vec<PresentationRelationView<'a>>,
71    /// Presentation children of this concept across roles.
72    pub presentation_children: Vec<PresentationRelationView<'a>>,
73    /// Tuple content model projected for display, when this concept is a tuple.
74    pub tuple_content: Option<TupleParticleView<'a>>,
75}
76
77impl<'a> ConceptView<'a> {
78    /// Build a concept view from a concept reference.
79    pub fn build(concept: &'a Concept, taxonomy: &'a TaxonomySet) -> Self {
80        build_concept_view(concept, taxonomy)
81    }
82
83    /// Build a concept view from a concept qualified name.
84    pub fn build_from_name(concept_name: &ExpandedName, taxonomy: &'a TaxonomySet) -> Option<Self> {
85        taxonomy
86            .find_concept(concept_name)
87            .map(|concept| build_concept_view(concept, taxonomy))
88    }
89
90    /// Build a concept view from a concept id attribute.
91    pub fn build_from_id(concept_id: &str, taxonomy: &'a TaxonomySet) -> Option<Self> {
92        taxonomy
93            .find_concept_by_id(concept_id)
94            .map(|concept| build_concept_view(concept, taxonomy))
95    }
96}
97
98/// A presentation relationship from or to a concept in a given role.
99#[derive(Debug)]
100pub struct PresentationRelationView<'a> {
101    /// The role URI where this relationship is defined.
102    pub role: &'a str,
103    /// The related concept name used by the arc endpoint.
104    pub concept_name: &'a ExpandedName,
105    /// The resolved related concept when available in the DTS.
106    pub concept: Option<&'a Concept>,
107    /// Arc order among siblings.
108    pub order: Option<Decimal>,
109    /// Preferred label role for this edge, if present.
110    pub preferred_label: Option<&'a str>,
111}
112
113/// A projected tuple content-model particle for display.
114#[derive(Debug)]
115pub enum TupleParticleView<'a> {
116    /// An element particle.
117    Element {
118        /// Element-local view information.
119        element: TupleElementView<'a>,
120        /// Occurrence constraints.
121        occurs: &'a Occurrence,
122    },
123    /// A sequence compositor.
124    Sequence {
125        /// Child particles.
126        children: Vec<TupleParticleView<'a>>,
127        /// Occurrence constraints.
128        occurs: &'a Occurrence,
129    },
130    /// A choice compositor.
131    Choice {
132        /// Child particles.
133        children: Vec<TupleParticleView<'a>>,
134        /// Occurrence constraints.
135        occurs: &'a Occurrence,
136    },
137    /// A group reference.
138    GroupRef {
139        /// Referenced group local name.
140        name: &'a str,
141        /// Occurrence constraints.
142        occurs: &'a Occurrence,
143    },
144    /// An inline group definition.
145    GroupDef {
146        /// Group name, if present.
147        name: Option<&'a str>,
148        /// Wrapped particle.
149        particle: Box<TupleParticleView<'a>>,
150        /// Occurrence constraints.
151        occurs: &'a Occurrence,
152    },
153}
154
155/// A tuple element projection.
156#[derive(Debug)]
157pub struct TupleElementView<'a> {
158    /// Allowed element local name.
159    pub local_name: &'a str,
160    /// Resolved concept for this element, if present in the taxonomy.
161    pub concept: Option<&'a Concept>,
162}
163
164fn build_taxonomy_view<'a>(taxonomy: &'a TaxonomySet) -> TaxonomyView<'a> {
165    let sections = taxonomy
166        .presentations()
167        .iter()
168        .map(|(role, arcs)| build_section(role.as_str(), arcs, taxonomy))
169        .collect();
170
171    TaxonomyView { sections }
172}
173
174fn build_section<'a>(
175    role: &'a str,
176    arcs: &'a [PresentationArc],
177    taxonomy: &'a TaxonomySet,
178) -> TaxonomySectionView<'a> {
179    let concept_index = taxonomy
180        .concepts()
181        .map(|concept| (&concept.name, concept))
182        .collect::<HashMap<_, _>>();
183
184    let mut arc_index: HashMap<&'a ExpandedName, Vec<&'a PresentationArc>> = HashMap::new();
185    for arc in arcs {
186        arc_index.entry(&arc.from).or_default().push(arc);
187    }
188
189    for children in arc_index.values_mut() {
190        children.sort_by(|a, b| match (a.order, b.order) {
191            (Some(x), Some(y)) => x.cmp(&y),
192            (Some(_), None) => Ordering::Less,
193            (None, Some(_)) => Ordering::Greater,
194            (None, None) => Ordering::Equal,
195        });
196    }
197
198    let roots = find_roots(arcs, &arc_index);
199    let mut visited: HashSet<&'a ExpandedName> = HashSet::new();
200    let mut nodes = Vec::with_capacity(roots.len());
201
202    for root_id in roots {
203        if let Some(node) = build_node(
204            &concept_index,
205            &arc_index,
206            root_id,
207            0,
208            taxonomy,
209            &mut visited,
210        ) {
211            nodes.push(node);
212        }
213    }
214
215    TaxonomySectionView { role, nodes }
216}
217
218/// Find root concept IDs: those that appear as from but never as to.
219fn find_roots<'a>(
220    arcs: &'a [PresentationArc],
221    arc_index: &HashMap<&'a ExpandedName, Vec<&'a PresentationArc>>,
222) -> Vec<&'a ExpandedName> {
223    let to_set: HashSet<&ExpandedName> = arcs.iter().map(|arc| &arc.to).collect();
224    let mut roots: Vec<&'a ExpandedName> = arc_index
225        .keys()
226        .copied()
227        .filter(|from| !to_set.contains(*from))
228        .collect();
229
230    roots.sort_by(|a, b| {
231        let min_order = |id: &&ExpandedName| {
232            arc_index
233                .get(*id)
234                .and_then(|children| children.iter().filter_map(|arc| arc.order).min())
235        };
236        match (min_order(a), min_order(b)) {
237            (Some(x), Some(y)) => x.cmp(&y),
238            (Some(_), None) => Ordering::Less,
239            (None, Some(_)) => Ordering::Greater,
240            (None, None) => Ordering::Equal,
241        }
242    });
243
244    roots
245}
246
247fn build_node<'a>(
248    concept_index: &HashMap<&'a ExpandedName, &'a Concept>,
249    arc_index: &HashMap<&'a ExpandedName, Vec<&'a PresentationArc>>,
250    concept_id: &'a ExpandedName,
251    depth: usize,
252    taxonomy: &'a TaxonomySet,
253    visited: &mut HashSet<&'a ExpandedName>,
254) -> Option<TaxonomyTreeNode<'a>> {
255    if !visited.insert(concept_id) {
256        return None;
257    }
258
259    let concept = concept_index.get(concept_id).copied()?;
260    let labels = taxonomy.labels(concept_id).unwrap_or(&[]);
261
262    let child_arcs = arc_index.get(concept_id).map(Vec::as_slice).unwrap_or(&[]);
263    let mut children = Vec::with_capacity(child_arcs.len());
264
265    for arc in child_arcs {
266        if let Some(child_node) = build_node(
267            concept_index,
268            arc_index,
269            &arc.to,
270            depth + 1,
271            taxonomy,
272            visited,
273        ) {
274            children.push(child_node);
275        }
276    }
277
278    visited.remove(concept_id);
279
280    Some(TaxonomyTreeNode {
281        concept,
282        labels,
283        depth,
284        children,
285    })
286}
287
288fn build_concept_view<'a>(concept: &'a Concept, taxonomy: &'a TaxonomySet) -> ConceptView<'a> {
289    let labels = taxonomy.labels(&concept.name).unwrap_or(&[]);
290    let references = concept
291        .id
292        .as_deref()
293        .and_then(|id| taxonomy.references_for(id))
294        .unwrap_or(&[]);
295    let parent_tuple = concept
296        .id
297        .as_deref()
298        .and_then(|id| taxonomy.find_parent_tuple(id));
299    let tuple_ancestors = concept
300        .id
301        .as_deref()
302        .map(|id| {
303            taxonomy
304                .tuple_ancestor_ids(id)
305                .into_iter()
306                .filter_map(|ancestor_id| taxonomy.find_concept_by_id(&ancestor_id))
307                .collect::<Vec<_>>()
308        })
309        .unwrap_or_default();
310
311    let mut presentation_parents = Vec::new();
312    let mut presentation_children = Vec::new();
313
314    for (role, arcs) in taxonomy.presentations() {
315        for arc in arcs {
316            if arc.to == concept.name {
317                presentation_parents.push(PresentationRelationView {
318                    role: role.as_str(),
319                    concept_name: &arc.from,
320                    concept: taxonomy.find_concept(&arc.from),
321                    order: arc.order,
322                    preferred_label: arc.preferred_label.as_ref().map(|x| x.as_ref()),
323                });
324            }
325
326            if arc.from == concept.name {
327                presentation_children.push(PresentationRelationView {
328                    role: role.as_str(),
329                    concept_name: &arc.to,
330                    concept: taxonomy.find_concept(&arc.to),
331                    order: arc.order,
332                    preferred_label: arc.preferred_label.as_ref().map(|x| x.as_ref()),
333                });
334            }
335        }
336    }
337
338    let tuple_content = concept
339        .content_model
340        .as_ref()
341        .map(|particle| project_particle(particle, taxonomy));
342
343    ConceptView {
344        concept,
345        labels,
346        references,
347        parent_tuple,
348        tuple_ancestors,
349        presentation_parents,
350        presentation_children,
351        tuple_content,
352    }
353}
354
355fn project_particle<'a>(
356    particle: &'a Particle,
357    taxonomy: &'a TaxonomySet,
358) -> TupleParticleView<'a> {
359    match particle {
360        Particle::Element { element, occurs } => {
361            let local_name = element.local_name();
362            TupleParticleView::Element {
363                element: TupleElementView {
364                    local_name,
365                    concept: find_concept_by_local_name(taxonomy, local_name),
366                },
367                occurs,
368            }
369        }
370        Particle::Sequence { children, occurs } => TupleParticleView::Sequence {
371            children: children
372                .iter()
373                .map(|child| project_particle(child, taxonomy))
374                .collect(),
375            occurs,
376        },
377        Particle::Choice { children, occurs } => TupleParticleView::Choice {
378            children: children
379                .iter()
380                .map(|child| project_particle(child, taxonomy))
381                .collect(),
382            occurs,
383        },
384        Particle::Group { group, occurs } => match group {
385            GroupParticle::Ref(qname) => TupleParticleView::GroupRef {
386                name: &qname.local_name,
387                occurs,
388            },
389            GroupParticle::Def(group_def) => TupleParticleView::GroupDef {
390                name: group_def
391                    .name
392                    .as_ref()
393                    .map(|qname| qname.local_name.as_str()),
394                particle: Box::new(project_particle(&group_def.particle, taxonomy)),
395                occurs,
396            },
397        },
398    }
399}
400
401fn find_concept_by_local_name<'a>(
402    taxonomy: &'a TaxonomySet,
403    local_name: &str,
404) -> Option<&'a Concept> {
405    taxonomy
406        .concepts()
407        .into_iter()
408        .find(|concept| concept.name.local_name == local_name)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::{ExpandedName, PresentationArc, taxonomy::TaxonomySet};
415    use rust_decimal::Decimal;
416
417    #[test]
418    fn taxonomy_view_empty_taxonomy() {
419        let taxonomy = TaxonomySet::default();
420        let view = TaxonomyView::build(&taxonomy);
421
422        assert!(view.sections.is_empty());
423    }
424
425    #[test]
426    fn taxonomy_view_missing_role_returns_none() {
427        let taxonomy = TaxonomySet::default();
428        let view = TaxonomyView::build_for_role(&taxonomy, "http://example.com/role");
429
430        assert!(view.is_none());
431    }
432
433    #[test]
434    fn taxonomy_view_section_created_for_existing_role_without_concepts() {
435        let mut taxonomy = TaxonomySet::default();
436        taxonomy.add_presentation_arc(
437            "http://example.com/role".to_string(),
438            PresentationArc {
439                from: ExpandedName::new("http://example.com/ns".into(), "root".into()),
440                to: ExpandedName::new("http://example.com/ns".into(), "child".into()),
441                order: Some(Decimal::new(1, 0)),
442                preferred_label: None,
443                arcrole: "http://www.xbrl.org/2003/arcrole/parent-child".into(),
444            },
445        );
446
447        let view = TaxonomyView::build(&taxonomy);
448
449        assert_eq!(view.sections.len(), 1);
450        assert_eq!(view.sections[0].role, "http://example.com/role");
451        // No concept definitions were added, so nodes are skipped.
452        assert!(view.sections[0].nodes.is_empty());
453    }
454
455    #[test]
456    fn concept_view_build_from_name_returns_none_for_missing_concept() {
457        let taxonomy = TaxonomySet::default();
458        let view = ConceptView::build_from_name(
459            &ExpandedName::new("http://example.com/ns".into(), "missing".into()),
460            &taxonomy,
461        );
462
463        assert!(view.is_none());
464    }
465
466    #[test]
467    fn concept_view_build_from_id_returns_none_for_missing_concept() {
468        let taxonomy = TaxonomySet::default();
469        let view = ConceptView::build_from_id("missing", &taxonomy);
470
471        assert!(view.is_none());
472    }
473}