Skip to main content

ripbi_core/
report.rs

1//! Format-agnostic report AST: the normalized shape every report source format
2//! (PBIR `definition/` folders, PBIR-Legacy `report.json` Layout) is parsed into.
3//!
4//! The types here are plain data with no parsing or I/O behaviour. Their only logic
5//! is the enumeration at the bottom of this module ([`ReportModel::bindings`] and
6//! [`ReportModel::dax_expressions`]), which is the single place that knows where
7//! report-side reachability roots and report-owned DAX live. The graph layer
8//! consumes those two functions instead of walking the AST itself, so a new
9//! binding-bearing field cannot be silently omitted from reachability analysis.
10//!
11//! Bindings hold references *as written* — structured entity trees in PBIR,
12//! written names in legacy Layout — never resolved model objects. Resolution
13//! against the semantic model is the graph layer's job, via
14//! [`ModelIndex`](crate::ModelIndex); keeping the written form is what lets both
15//! source formats populate the same structures.
16
17use std::fmt;
18
19use crate::identity::{FieldRef, NameKey, Quoted};
20use crate::model::{DaxExpressionKind, DaxExpressionRef, ExpressionOwner};
21
22/// Normalized report definition, regardless of source format (PBIR, PBIR-Legacy
23/// Layout). One instance per report: an analysis runs one
24/// [`TabularDatabase`](crate::TabularDatabase) against the reports that share it,
25/// and each report's `name` completes its bindings' provenance.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ReportModel {
28    /// Report identity for provenance: the `.platform` display name, or the report
29    /// folder's name when the source records none.
30    pub name: Option<String>,
31    /// The semantic model this report connects to (PBIR `datasetReference`).
32    pub dataset: DatasetReference,
33    /// Report-level filters (PBIR `report.json` filterConfig).
34    pub filters: Vec<Filter>,
35    /// Pages in source order.
36    pub pages: Vec<Page>,
37    /// Bookmarks in source order.
38    pub bookmarks: Vec<Bookmark>,
39    /// Report-level measures (PBIR `reportExtensions.json`): DAX that lives in the
40    /// report, not the model.
41    pub measures: Vec<ReportMeasure>,
42}
43
44/// How a report reaches its semantic model (PBIR `datasetReference`).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum DatasetReference {
47    /// Relative path to a sibling semantic-model folder (`byPath`). Forward
48    /// slashes are the Power BI-written form; hand-edited files may carry
49    /// backslashes, so the CLI accepts both when resolving.
50    ByPath {
51        /// Path as written, e.g. `../Sales.SemanticModel`.
52        path: String,
53    },
54    /// Live connection to a remote semantic model (`byConnection`).
55    ByConnection {
56        /// Connection string as written.
57        connection_string: String,
58    },
59    /// Absent, unrecognized, or not yet parsed.
60    Unresolved,
61}
62
63impl Default for DatasetReference {
64    /// An unparsed reference is `Unresolved`, never a path or a connection,
65    /// mirroring [`PartitionSource::Other`](crate::PartitionSource): schema drift
66    /// must never fabricate a report↔model pairing.
67    fn default() -> Self {
68        Self::Unresolved
69    }
70}
71
72/// One page of a report.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Page {
75    /// Page object name, e.g. `ReportSectionacd41c847407a998c130`. PBIR keys its
76    /// folders and files by it, and bookmarks reference pages by it.
77    pub name: NameKey,
78    /// Author-facing name, e.g. `Overview`.
79    pub display_name: Option<String>,
80    /// Hidden pages still bind fields — their visuals render on demand — so this
81    /// flag is display-only, never liveness.
82    pub is_hidden: bool,
83    /// Filters applied to the whole page.
84    pub filters: Vec<Filter>,
85    /// The page's drillthrough/tooltip role, if it has one.
86    pub binding: Option<PageBinding>,
87    /// Visuals on the page, in source order.
88    pub visuals: Vec<Visual>,
89}
90
91/// The role a page plays in drillthrough and tooltips (PBIR `pageBinding.type`).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
93pub enum PageBindingKind {
94    /// An ordinary page. The default.
95    #[default]
96    Default,
97    /// Reached via drillthrough; its parameters bind fields.
98    Drillthrough,
99    /// Rendered as a tooltip for other visuals.
100    Tooltip,
101}
102
103/// A page's drillthrough/tooltip configuration (PBIR `pageBinding`).
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub struct PageBinding {
106    /// What kind of page binding this is.
107    pub kind: PageBindingKind,
108    /// Fields a drillthrough caller must supply, in source order.
109    pub parameters: Vec<DrillthroughParameter>,
110}
111
112/// One drillthrough field (PBIR `pageBinding.parameters[]`).
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DrillthroughParameter {
115    /// Parameter name as written, e.g. `Param_Filter5`.
116    pub name: Option<NameKey>,
117    /// The bound field (PBIR `fieldExpr`).
118    pub target: FieldTarget,
119}
120
121/// One visual on a page.
122///
123/// Slicers are not a separate kind: a slicer is a visual with `visual_type`
124/// `"slicer"`, and its field wells carry the binding. Saved slicer *selections*
125/// are literal values, not references, and are not modeled.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Visual {
128    /// Visual object name — the PBIR folder name and bookmarks' key.
129    pub name: NameKey,
130    /// Visual type as written, e.g. `"donutChart"`, `"slicer"`, `"card"`.
131    pub visual_type: String,
132    /// Field wells (PBIR `query.queryState`): role → projections.
133    pub wells: Vec<FieldWell>,
134    /// Filters applied to this visual only.
135    pub filters: Vec<Filter>,
136    /// Sort-by fields (PBIR `sortDefinition`), in sort order.
137    pub sorts: Vec<FieldTarget>,
138    /// Fields driving conditional-formatting rules.
139    pub conditional_formatting: Vec<FieldTarget>,
140    /// Fields referenced by the visual's accessibility alt text
141    /// (`visualContainerObjects.general.altText`): a screen reader reads it,
142    /// so dropping the field breaks the visual.
143    pub alt_text: Vec<FieldTarget>,
144    /// Page used as this visual's tooltip, by page object name. A report-internal
145    /// reference: it keeps the page reachable, not a model object.
146    pub tooltip_page: Option<NameKey>,
147}
148
149/// One field well of a visual: everything projected into a single role.
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct FieldWell {
152    /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`, `"Values"`.
153    pub role: String,
154    /// Projections in the well, in source order.
155    pub projections: Vec<Projection>,
156}
157
158/// One field projected into a well.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Projection {
161    /// The projected field.
162    pub target: FieldTarget,
163    /// Written display form as it appears in the file (PBIR `queryRef`), e.g.
164    /// `Sales.Customers % of Total`. Diagnostics only — `target` is authoritative.
165    pub query_ref: Option<String>,
166    /// Whether the projection is active. Inactive projections still bind: they are
167    /// one toggle away from live, and dropping them would under-count roots.
168    pub active: bool,
169}
170
171/// A filter at report, page, visual, or bookmark level.
172///
173/// The filtered *values* (the condition tree's literals) are data, not references,
174/// and are not modeled — only the fields a filter touches can keep objects alive.
175#[derive(Debug, Clone, PartialEq, Eq, Default)]
176pub struct Filter {
177    /// Filter name within its scope, e.g. `Filter5`. Drillthrough parameters bind
178    /// to filters by this name (PBIR `boundFilter`).
179    pub name: Option<NameKey>,
180    /// The filtered field itself (PBIR `filterConfig.filters[].field`).
181    pub target: Option<FieldTarget>,
182    /// Further fields referenced by the filter's condition tree, with query aliases
183    /// (`SourceRef.Source`) already resolved to entities by the parser.
184    pub references: Vec<FieldTarget>,
185}
186
187/// A saved exploration state, restorable by a reader.
188///
189/// Bookmark bindings are enumerated like any other: applying a bookmark re-applies
190/// its saved filters and projections, so a field kept alive only by a bookmark is
191/// still alive.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Bookmark {
194    /// Bookmark object name — the PBIR file name, and the provenance key.
195    pub name: NameKey,
196    /// Author-facing name.
197    pub display_name: Option<String>,
198    /// Filters saved at report level (`explorationState.filters`).
199    pub filters: Vec<Filter>,
200    /// Captured state, per page it spans (usually one).
201    pub sections: Vec<BookmarkSection>,
202}
203
204/// The slice of a bookmark's state belonging to one page.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct BookmarkSection {
207    /// The captured page, by object name.
208    pub page: NameKey,
209    /// Saved filters (`byName` and `byExpr`).
210    pub filters: Vec<Filter>,
211    /// Saved per-visual state, in source order.
212    pub visuals: Vec<BookmarkVisual>,
213}
214
215/// A bookmark's saved state for one visual.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct BookmarkVisual {
218    /// The visual, by object name.
219    pub visual: NameKey,
220    /// Fields active when the bookmark was captured, as wells by role.
221    pub wells: Vec<FieldWell>,
222    /// Filters saved for this visual (`visualContainers.<id>.filters`).
223    pub filters: Vec<Filter>,
224}
225
226/// A DAX measure defined in the report (PBIR `reportExtensions.json`), not the
227/// model.
228///
229/// A report measure bridges usage in both directions: its body references model
230/// objects (so it is an expression source the graph must consume), and visuals
231/// reference it by name (so it is a reachability root of its own). Name lookup
232/// should try report measures before model measures — within its report, a report
233/// measure shadows a model measure of the same name.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ReportMeasure {
236    /// Measure name, unique within its report.
237    pub name: NameKey,
238    /// The measure's DAX expression.
239    pub expression: String,
240    /// Dynamic format string (DAX).
241    pub format_string: Option<String>,
242}
243
244/// A model-object reference as written in a report binding, before resolution.
245///
246/// PBIR bindings are structured JSON entity trees (`Column`, `Measure`,
247/// `HierarchyLevel`, `Aggregation`); legacy Layout binds written names. Both
248/// normalize here, so downstream code never branches on source format.
249///
250/// The column/measure discrimination is kept rather than collapsed into
251/// [`FieldRef`], because the binding states it outright and resolution would
252/// otherwise be guessing.
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub enum FieldTarget {
255    /// A column, by its table (`SourceRef.Entity`) and name (`Property`).
256    Column {
257        /// Owning table as written.
258        table: NameKey,
259        /// Column name as written.
260        column: NameKey,
261    },
262    /// A measure, by name. Measures are model-global; the entity PBIR writes
263    /// alongside them is the home table as displayed, carried for provenance only.
264    Measure {
265        /// Home table as written, if any.
266        home_table: Option<NameKey>,
267        /// Measure name as written.
268        measure: NameKey,
269    },
270    /// A hierarchy level: the level a visual drills to, which keeps the whole
271    /// hierarchy (and its level columns) alive.
272    HierarchyLevel {
273        /// Owning table as written. For a hierarchy reached over a column
274        /// variation this is the *varied* (base) table; the hierarchy itself
275        /// lives on the variation's target table.
276        table: NameKey,
277        /// Hierarchy name as written.
278        hierarchy: NameKey,
279        /// Level name as written.
280        level: NameKey,
281        /// The varied column (PBIR `PropertyVariationSource.Property`), when
282        /// the hierarchy is reached over a column variation — the key that
283        /// joins this binding to the model-side
284        /// [`crate::model::Column::variations`] declaration.
285        via_column: Option<NameKey>,
286        /// The variation's name (PBIR `PropertyVariationSource.Name`), which
287        /// picks between a column's variations when there are several.
288        via_variation: Option<NameKey>,
289    },
290    /// An aggregation over an inner reference, e.g. Sum of `'Sales'[Units]`.
291    /// The inner target is what stays alive; the function is diagnostics.
292    Aggregation {
293        /// Aggregation function as written, e.g. `"Sum"`.
294        function: Option<String>,
295        /// The aggregated field.
296        inner: Box<FieldTarget>,
297    },
298    /// A written name the parser could not structure — legacy Layout strings,
299    /// unresolved query aliases. Kept anyway: a binding we cannot read is still a
300    /// binding, and dropping it would under-count roots.
301    Written(FieldRef),
302}
303
304impl fmt::Display for FieldTarget {
305    /// Human-readable form for diagnostics, quoting names the way [`FieldRef`]
306    /// does. The hierarchy-level and aggregation forms are illustrative, not valid
307    /// DAX — level references have no DAX syntax.
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        match self {
310            FieldTarget::Column { table, column } => {
311                write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
312            }
313            FieldTarget::Measure {
314                home_table,
315                measure,
316            } => match home_table {
317                Some(table) => write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str()),
318                None => write!(f, "[{}]", measure.as_str()),
319            },
320            FieldTarget::HierarchyLevel {
321                table,
322                hierarchy,
323                level,
324                ..
325            } => write!(
326                f,
327                "hierarchy {}[{}] level {}",
328                Quoted(table.as_str()),
329                hierarchy.as_str(),
330                Quoted(level.as_str())
331            ),
332            FieldTarget::Aggregation { function, inner } => match function {
333                Some(function) => write!(f, "{function}({inner})"),
334                None => write!(f, "Aggregation({inner})"),
335            },
336            FieldTarget::Written(reference) => write!(f, "{reference}"),
337        }
338    }
339}
340
341/// What kind of report-side usage a binding represents.
342///
343/// The kind powers "used by" explanations (`'Sales'[Amount]` ← filter on
344/// *Overview* ← page 2) and groups bindings for reporting.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
346pub enum BindingKind<'a> {
347    /// A field projected into a visual's field well, with the well's role.
348    FieldWell {
349        /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`.
350        role: &'a str,
351    },
352    /// A filter at report, page, visual, or bookmark level.
353    Filter,
354    /// A visual's sort-by field.
355    Sort,
356    /// A drillthrough parameter's bound field.
357    Drillthrough,
358    /// A field driving a conditional-formatting rule.
359    ConditionalFormatting,
360    /// A visual's accessibility alt text (`general.altText`).
361    AltText,
362}
363
364/// Borrowed view of one report binding, with its provenance.
365///
366/// The page/visual/bookmark fields answer *where* the binding lives — the `None`s
367/// narrow it: a report-level filter has neither page nor visual, a page filter has
368/// no visual. Which *report* a binding came from is answered by the
369/// [`ReportModel`] it was enumerated from, so the report name is not repeated here.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
371pub struct BindingRef<'a> {
372    /// Page the binding lives on; `None` for report-level bindings.
373    pub page: Option<&'a NameKey>,
374    /// Visual the binding lives in; `None` outside a visual.
375    pub visual: Option<&'a NameKey>,
376    /// Bookmark whose saved state carries the binding; `None` for live bindings.
377    pub bookmark: Option<&'a NameKey>,
378    /// What kind of binding this is.
379    pub kind: BindingKind<'a>,
380    /// The model object referenced, as written.
381    pub target: &'a FieldTarget,
382}
383
384impl ReportModel {
385    /// Every model-object reference the report makes, with its provenance — the
386    /// reachability roots the graph's BFS starts from.
387    ///
388    /// Order follows report order (report filters, then per page: drillthrough
389    /// parameters, page filters, and each visual's wells, filters, sorts, and
390    /// conditional formatting; then per bookmark: report-level filters, and per
391    /// section: section filters and each visual's wells and filters), so the
392    /// result is deterministic for a given report and diffable across runs.
393    ///
394    /// Everything borrows from the report, so this allocates only the returned
395    /// `Vec`.
396    ///
397    /// # Examples
398    ///
399    /// ```
400    /// use ripbi_core::report::{BindingKind, FieldTarget, Filter, ReportModel};
401    /// use ripbi_core::NameKey;
402    ///
403    /// let report = ReportModel {
404    ///     filters: vec![Filter {
405    ///         target: Some(FieldTarget::Column {
406    ///             table: NameKey::new("Product"),
407    ///             column: NameKey::new("Category"),
408    ///         }),
409    ///         ..Default::default()
410    ///     }],
411    ///     ..Default::default()
412    /// };
413    ///
414    /// let bindings = report.bindings();
415    /// assert_eq!(bindings.len(), 1);
416    /// assert_eq!(bindings[0].kind, BindingKind::Filter);
417    /// // A report-level filter belongs to no page and no visual.
418    /// assert_eq!(bindings[0].page, None);
419    /// assert_eq!(bindings[0].visual, None);
420    /// ```
421    #[must_use]
422    pub fn bindings(&self) -> Vec<BindingRef<'_>> {
423        let mut out = Vec::new();
424
425        for filter in &self.filters {
426            extend_with_filter(&mut out, None, None, None, filter);
427        }
428
429        for page in &self.pages {
430            let page_id = Some(&page.name);
431
432            if let Some(binding) = &page.binding {
433                for parameter in &binding.parameters {
434                    out.push(BindingRef {
435                        page: page_id,
436                        visual: None,
437                        bookmark: None,
438                        kind: BindingKind::Drillthrough,
439                        target: &parameter.target,
440                    });
441                }
442            }
443
444            for filter in &page.filters {
445                extend_with_filter(&mut out, page_id, None, None, filter);
446            }
447
448            for visual in &page.visuals {
449                let visual_id = Some(&visual.name);
450                extend_with_wells(&mut out, page_id, visual_id, None, &visual.wells);
451
452                for filter in &visual.filters {
453                    extend_with_filter(&mut out, page_id, visual_id, None, filter);
454                }
455
456                for target in &visual.sorts {
457                    out.push(BindingRef {
458                        page: page_id,
459                        visual: visual_id,
460                        bookmark: None,
461                        kind: BindingKind::Sort,
462                        target,
463                    });
464                }
465
466                for target in &visual.conditional_formatting {
467                    out.push(BindingRef {
468                        page: page_id,
469                        visual: visual_id,
470                        bookmark: None,
471                        kind: BindingKind::ConditionalFormatting,
472                        target,
473                    });
474                }
475
476                for target in &visual.alt_text {
477                    out.push(BindingRef {
478                        page: page_id,
479                        visual: visual_id,
480                        bookmark: None,
481                        kind: BindingKind::AltText,
482                        target,
483                    });
484                }
485            }
486        }
487
488        for bookmark in &self.bookmarks {
489            let bookmark_id = Some(&bookmark.name);
490
491            for filter in &bookmark.filters {
492                extend_with_filter(&mut out, None, None, bookmark_id, filter);
493            }
494
495            for section in &bookmark.sections {
496                let page_id = Some(&section.page);
497
498                for filter in &section.filters {
499                    extend_with_filter(&mut out, page_id, None, bookmark_id, filter);
500                }
501
502                for visual in &section.visuals {
503                    let visual_id = Some(&visual.visual);
504                    extend_with_wells(&mut out, page_id, visual_id, bookmark_id, &visual.wells);
505
506                    for filter in &visual.filters {
507                        extend_with_filter(&mut out, page_id, visual_id, bookmark_id, filter);
508                    }
509                }
510            }
511        }
512
513        out
514    }
515
516    /// Every DAX expression defined report-side — report-level measures — on top of
517    /// [`TabularDatabase::dax_expressions`](crate::TabularDatabase::dax_expressions),
518    /// which covers the model side.
519    ///
520    /// A report measure has no home table, so `home_table` is always `None`:
521    /// unqualified `[Name]` references in its body can only be measures — of the
522    /// report first, then the model.
523    ///
524    /// Owners borrow their names, so this allocates only the returned `Vec`.
525    #[must_use]
526    pub fn dax_expressions(&self) -> Vec<DaxExpressionRef<'_>> {
527        let mut out = Vec::new();
528
529        for measure in &self.measures {
530            let owner = ExpressionOwner::ReportMeasure {
531                measure: measure.name.as_str(),
532            };
533            out.push(DaxExpressionRef {
534                owner,
535                kind: DaxExpressionKind::ReportMeasure,
536                home_table: None,
537                text: &measure.expression,
538            });
539            if let Some(text) = &measure.format_string {
540                out.push(DaxExpressionRef {
541                    owner,
542                    kind: DaxExpressionKind::ReportMeasureFormatString,
543                    home_table: None,
544                    text,
545                });
546            }
547        }
548
549        out
550    }
551}
552
553/// Appends one [`BindingRef`] per field a filter carries, all tagged
554/// [`BindingKind::Filter`]: the declared `target` first, then the condition tree's
555/// `references`, preserving file order for stable diffs.
556fn extend_with_filter<'a>(
557    out: &mut Vec<BindingRef<'a>>,
558    page: Option<&'a NameKey>,
559    visual: Option<&'a NameKey>,
560    bookmark: Option<&'a NameKey>,
561    filter: &'a Filter,
562) {
563    for target in filter.target.iter().chain(&filter.references) {
564        out.push(BindingRef {
565            page,
566            visual,
567            bookmark,
568            kind: BindingKind::Filter,
569            target,
570        });
571    }
572}
573
574/// Appends one [`BindingRef`] per projection in the wells, tagged with its well's
575/// role. Inactive projections bind too: they are one toggle away from live.
576fn extend_with_wells<'a>(
577    out: &mut Vec<BindingRef<'a>>,
578    page: Option<&'a NameKey>,
579    visual: Option<&'a NameKey>,
580    bookmark: Option<&'a NameKey>,
581    wells: &'a [FieldWell],
582) {
583    for well in wells {
584        for projection in &well.projections {
585            out.push(BindingRef {
586                page,
587                visual,
588                bookmark,
589                kind: BindingKind::FieldWell {
590                    role: well.role.as_str(),
591                },
592                target: &projection.target,
593            });
594        }
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use rstest::rstest;
602
603    fn column_target(table: &str, column: &str) -> FieldTarget {
604        FieldTarget::Column {
605            table: NameKey::new(table),
606            column: NameKey::new(column),
607        }
608    }
609
610    fn measure_target(home_table: Option<&str>, measure: &str) -> FieldTarget {
611        FieldTarget::Measure {
612            home_table: home_table.map(NameKey::new),
613            measure: NameKey::new(measure),
614        }
615    }
616
617    fn filter_on(target: FieldTarget) -> Filter {
618        Filter {
619            target: Some(target),
620            ..Default::default()
621        }
622    }
623
624    fn page(name: &str) -> Page {
625        Page {
626            name: NameKey::new(name),
627            display_name: None,
628            is_hidden: false,
629            filters: Vec::new(),
630            binding: None,
631            visuals: Vec::new(),
632        }
633    }
634
635    fn visual(name: &str, visual_type: &str) -> Visual {
636        Visual {
637            name: NameKey::new(name),
638            visual_type: visual_type.to_string(),
639            wells: Vec::new(),
640            filters: Vec::new(),
641            sorts: Vec::new(),
642            conditional_formatting: Vec::new(),
643            alt_text: Vec::new(),
644            tooltip_page: None,
645        }
646    }
647
648    fn well(role: &str, targets: &[FieldTarget]) -> FieldWell {
649        FieldWell {
650            role: role.to_string(),
651            projections: targets
652                .iter()
653                .cloned()
654                .map(|target| Projection {
655                    target,
656                    query_ref: None,
657                    active: true,
658                })
659                .collect(),
660        }
661    }
662
663    mod field_target {
664        use super::*;
665
666        #[rstest]
667        #[case::column(column_target("Product", "Category"), "'Product'[Category]")]
668        #[case::measure_with_home_table(measure_target(Some("Sales"), "Cost"), "'Sales'[Cost]")]
669        #[case::measure_without_home_table(measure_target(None, "Cost"), "[Cost]")]
670        #[case::hierarchy_level(
671            FieldTarget::HierarchyLevel {
672                table: NameKey::new("Accounts"),
673                hierarchy: NameKey::new("Street Hierarchy"),
674                level: NameKey::new("State or Province"),
675                via_column: None,
676                via_variation: None,
677            },
678            "hierarchy 'Accounts'[Street Hierarchy] level 'State or Province'"
679        )]
680        #[case::aggregation(
681            FieldTarget::Aggregation {
682                function: Some("Sum".to_string()),
683                inner: Box::new(column_target("Sales", "Units")),
684            },
685            "Sum('Sales'[Units])"
686        )]
687        #[case::aggregation_without_function(
688            FieldTarget::Aggregation {
689                function: None,
690                inner: Box::new(column_target("Sales", "Units")),
691            },
692            "Aggregation('Sales'[Units])"
693        )]
694        #[case::written(
695            FieldTarget::Written(FieldRef {
696                table: Some(NameKey::new("Sales")),
697                name: NameKey::new("Amount"),
698            }),
699            "'Sales'[Amount]"
700        )]
701        fn displays_for_diagnostics(#[case] target: FieldTarget, #[case] expected: &str) {
702            assert_eq!(target.to_string(), expected);
703        }
704
705        #[test]
706        fn compares_equal_ignoring_case() {
707            assert_eq!(
708                column_target("Product", "Category"),
709                column_target("PRODUCT", "CATEGORY")
710            );
711            assert_eq!(
712                measure_target(Some("Sales"), "Cost"),
713                measure_target(Some("sales"), "COST")
714            );
715        }
716
717        /// The binding states column-or-measure outright; losing it would make
718        /// resolution guess where it currently knows.
719        #[test]
720        fn distinguishes_a_column_from_a_measure_with_the_same_names() {
721            assert_ne!(
722                column_target("Sales", "Cost"),
723                measure_target(Some("Sales"), "Cost")
724            );
725        }
726    }
727
728    mod bindings {
729        use super::*;
730
731        /// The common report identity; each test adds the binding site it checks.
732        fn sample() -> ReportModel {
733            ReportModel {
734                name: Some("Sales overview".to_string()),
735                dataset: DatasetReference::ByPath {
736                    path: "../Sales.SemanticModel".to_string(),
737                },
738                ..Default::default()
739            }
740        }
741
742        fn sample_with_page(page: Page) -> ReportModel {
743            ReportModel {
744                pages: vec![page],
745                ..sample()
746            }
747        }
748
749        /// One binding's full provenance: page, visual, bookmark, kind, and the
750        /// target as `Display` — what resolution consumes.
751        type Provenance<'a> = (
752            Option<&'a str>,
753            Option<&'a str>,
754            Option<&'a str>,
755            BindingKind<'a>,
756            String,
757        );
758
759        /// `Provenance` per binding, in enumeration order — the full
760        /// specification `bindings()` must satisfy.
761        fn provenance(report: &ReportModel) -> Vec<Provenance<'_>> {
762            report
763                .bindings()
764                .into_iter()
765                .map(|binding| {
766                    (
767                        binding.page.map(NameKey::as_str),
768                        binding.visual.map(NameKey::as_str),
769                        binding.bookmark.map(NameKey::as_str),
770                        binding.kind,
771                        binding.target.to_string(),
772                    )
773                })
774                .collect()
775        }
776
777        #[test]
778        fn a_report_filter_has_no_page_visual_or_bookmark() {
779            let report = ReportModel {
780                filters: vec![filter_on(column_target("Product", "Category"))],
781                ..sample()
782            };
783            let bindings = report.bindings();
784
785            assert_eq!(bindings.len(), 1);
786            assert_eq!(bindings[0].page, None);
787            assert_eq!(bindings[0].visual, None);
788            assert_eq!(bindings[0].bookmark, None);
789            assert_eq!(bindings[0].kind, BindingKind::Filter);
790        }
791
792        #[test]
793        fn a_drillthrough_parameter_is_tagged_on_its_page() {
794            let report = sample_with_page(Page {
795                binding: Some(PageBinding {
796                    kind: PageBindingKind::Drillthrough,
797                    parameters: vec![DrillthroughParameter {
798                        name: Some(NameKey::new("Param_Filter5")),
799                        target: column_target("Industries", "Industry"),
800                    }],
801                }),
802                ..page("ReportSection1")
803            });
804
805            let bindings = report.bindings();
806            assert_eq!(bindings.len(), 1);
807            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
808            assert_eq!(bindings[0].visual, None);
809            assert_eq!(bindings[0].kind, BindingKind::Drillthrough);
810        }
811
812        #[test]
813        fn a_page_filter_carries_its_page_but_no_visual() {
814            let report = sample_with_page(Page {
815                filters: vec![filter_on(column_target("Owners", "Sales owner"))],
816                ..page("ReportSection1")
817            });
818
819            let bindings = report.bindings();
820            assert_eq!(bindings.len(), 1);
821            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
822            assert_eq!(bindings[0].visual, None);
823            assert_eq!(bindings[0].kind, BindingKind::Filter);
824        }
825
826        #[test]
827        fn a_visual_well_carries_role_page_and_visual() {
828            let report = sample_with_page(Page {
829                visuals: vec![Visual {
830                    wells: vec![well("Category", &[column_target("Product", "Category")])],
831                    ..visual("visual1", "donutChart")
832                }],
833                ..page("ReportSection1")
834            });
835
836            let bindings = report.bindings();
837            assert_eq!(bindings.len(), 1);
838            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
839            assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
840            assert_eq!(bindings[0].bookmark, None);
841            assert_eq!(
842                bindings[0].kind,
843                BindingKind::FieldWell { role: "Category" }
844            );
845        }
846
847        #[test]
848        fn sorts_and_conditional_formatting_are_tagged_as_such() {
849            let report = sample_with_page(Page {
850                visuals: vec![Visual {
851                    sorts: vec![column_target("Product", "Category")],
852                    conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
853                    ..visual("visual1", "tableEx")
854                }],
855                ..page("ReportSection1")
856            });
857
858            let bindings = report.bindings();
859            assert_eq!(
860                bindings.iter().map(|b| b.kind).collect::<Vec<_>>(),
861                vec![BindingKind::Sort, BindingKind::ConditionalFormatting]
862            );
863            // Both bindings belong to their visual, at page level.
864            for binding in &bindings {
865                assert_eq!(binding.page.unwrap().as_str(), "ReportSection1");
866                assert_eq!(binding.visual.unwrap().as_str(), "visual1");
867            }
868        }
869
870        #[test]
871        fn a_bookmark_filter_carries_bookmark_and_page() {
872            let report = ReportModel {
873                bookmarks: vec![Bookmark {
874                    name: NameKey::new("Bookmark1"),
875                    display_name: Some("FY24".to_string()),
876                    filters: Vec::new(),
877                    sections: vec![BookmarkSection {
878                        page: NameKey::new("ReportSection1"),
879                        filters: vec![filter_on(column_target("Products", "Product category"))],
880                        visuals: Vec::new(),
881                    }],
882                }],
883                ..sample()
884            };
885
886            let bindings = report.bindings();
887            assert_eq!(bindings.len(), 1);
888            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
889            assert_eq!(bindings[0].visual, None);
890            assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
891            assert_eq!(bindings[0].kind, BindingKind::Filter);
892        }
893
894        #[test]
895        fn bookmark_wells_carry_bookmark_page_and_visual() {
896            let report = ReportModel {
897                bookmarks: vec![Bookmark {
898                    name: NameKey::new("Bookmark1"),
899                    display_name: None,
900                    filters: Vec::new(),
901                    sections: vec![BookmarkSection {
902                        page: NameKey::new("ReportSection1"),
903                        filters: Vec::new(),
904                        visuals: vec![BookmarkVisual {
905                            visual: NameKey::new("visual1"),
906                            wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
907                            filters: Vec::new(),
908                        }],
909                    }],
910                }],
911                ..sample()
912            };
913
914            let bindings = report.bindings();
915            assert_eq!(bindings.len(), 1);
916            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
917            assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
918            assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
919            assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Rows" });
920        }
921
922        /// The declared target comes first, then the condition tree's references,
923        /// in file order.
924        #[test]
925        fn a_filter_yields_target_then_references_in_order() {
926            let report = sample_with_page(Page {
927                visuals: vec![Visual {
928                    filters: vec![Filter {
929                        name: Some(NameKey::new("Filter5")),
930                        target: Some(column_target("Product", "Category")),
931                        references: vec![
932                            column_target("Product", "Subcategory"),
933                            measure_target(None, "Units"),
934                        ],
935                    }],
936                    ..visual("visual1", "donutChart")
937                }],
938                ..page("ReportSection1")
939            });
940
941            let targets: Vec<&FieldTarget> =
942                report.bindings().into_iter().map(|b| b.target).collect();
943            assert_eq!(
944                targets,
945                vec![
946                    &column_target("Product", "Category"),
947                    &column_target("Product", "Subcategory"),
948                    &measure_target(None, "Units"),
949                ]
950            );
951        }
952
953        /// A filter the parser could not give a structured target still binds:
954        /// its references are roots even when `target` is `None`.
955        #[test]
956        fn a_filter_without_a_target_still_binds_its_references() {
957            let report = sample_with_page(Page {
958                visuals: vec![Visual {
959                    filters: vec![Filter {
960                        name: Some(NameKey::new("Filter5")),
961                        target: None,
962                        references: vec![
963                            column_target("Product", "Subcategory"),
964                            measure_target(None, "Units"),
965                        ],
966                    }],
967                    ..visual("visual1", "donutChart")
968                }],
969                ..page("ReportSection1")
970            });
971
972            let targets: Vec<&FieldTarget> =
973                report.bindings().into_iter().map(|b| b.target).collect();
974            assert_eq!(
975                targets,
976                vec![
977                    &column_target("Product", "Subcategory"),
978                    &measure_target(None, "Units"),
979                ]
980            );
981        }
982
983        /// An inactive projection is one toggle away from live; dropping it would
984        /// under-count roots and report live code as unused.
985        #[test]
986        fn an_inactive_projection_still_binds() {
987            let report = sample_with_page(Page {
988                visuals: vec![Visual {
989                    wells: vec![FieldWell {
990                        role: "Y".to_string(),
991                        projections: vec![Projection {
992                            target: column_target("Sales", "Units"),
993                            query_ref: None,
994                            active: false,
995                        }],
996                    }],
997                    ..visual("visual1", "lineChart")
998                }],
999                ..page("ReportSection1")
1000            });
1001
1002            assert_eq!(report.bindings().len(), 1);
1003        }
1004
1005        /// Hidden is not dead: a hidden page's visuals render on demand, so their
1006        /// wells bind like any other page's. Skipping hidden pages would
1007        /// under-count roots and report live code as unused.
1008        #[test]
1009        fn a_hidden_pages_visuals_still_bind() {
1010            let report = sample_with_page(Page {
1011                is_hidden: true,
1012                visuals: vec![Visual {
1013                    wells: vec![well("Values", &[column_target("Sales", "Units")])],
1014                    ..visual("visual1", "card")
1015                }],
1016                ..page("ReportSection1")
1017            });
1018
1019            let bindings = report.bindings();
1020            assert_eq!(bindings.len(), 1);
1021            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
1022            assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Values" });
1023        }
1024
1025        /// Enumeration walks report order — report filters, page (parameters,
1026        /// filters, visuals: wells, filters, sorts, conditional formatting), then
1027        /// bookmarks — so runs are diffable. Every binding's full provenance is
1028        /// pinned, not just its kind: a slipped page, visual, or bookmark on any
1029        /// site must fail here.
1030        #[test]
1031        fn order_follows_report_structure() {
1032            let report = ReportModel {
1033                filters: vec![filter_on(column_target("Product", "Category"))],
1034                pages: vec![
1035                    Page {
1036                        binding: Some(PageBinding {
1037                            kind: PageBindingKind::Drillthrough,
1038                            parameters: vec![DrillthroughParameter {
1039                                name: None,
1040                                target: column_target("Industries", "Industry"),
1041                            }],
1042                        }),
1043                        filters: vec![filter_on(column_target("Owners", "Sales owner"))],
1044                        visuals: vec![Visual {
1045                            wells: vec![well("Category", &[column_target("Product", "Category")])],
1046                            filters: vec![filter_on(column_target("Region", "Country"))],
1047                            sorts: vec![measure_target(Some("Sales"), "Sales")],
1048                            conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
1049                            ..visual("visual1", "donutChart")
1050                        }],
1051                        ..page("ReportSection1")
1052                    },
1053                    Page {
1054                        visuals: vec![Visual {
1055                            wells: vec![well(
1056                                "Tooltips",
1057                                &[measure_target(Some("Sales"), "Customers %")],
1058                            )],
1059                            ..visual("visual2", "slicer")
1060                        }],
1061                        ..page("ReportSection2")
1062                    },
1063                ],
1064                bookmarks: vec![Bookmark {
1065                    name: NameKey::new("Bookmark1"),
1066                    display_name: None,
1067                    filters: vec![filter_on(measure_target(None, "Total Units"))],
1068                    sections: vec![BookmarkSection {
1069                        page: NameKey::new("ReportSection1"),
1070                        filters: vec![filter_on(column_target("Products", "Product category"))],
1071                        visuals: vec![BookmarkVisual {
1072                            visual: NameKey::new("visual1"),
1073                            wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
1074                            filters: vec![filter_on(column_target("Product", "Color"))],
1075                        }],
1076                    }],
1077                }],
1078                measures: Vec::new(),
1079                ..sample()
1080            };
1081
1082            assert_eq!(
1083                provenance(&report),
1084                vec![
1085                    // Report filter.
1086                    (
1087                        None,
1088                        None,
1089                        None,
1090                        BindingKind::Filter,
1091                        "'Product'[Category]".to_string(),
1092                    ),
1093                    // Page 1 drillthrough parameter.
1094                    (
1095                        Some("ReportSection1"),
1096                        None,
1097                        None,
1098                        BindingKind::Drillthrough,
1099                        "'Industries'[Industry]".to_string(),
1100                    ),
1101                    // Page 1 filter.
1102                    (
1103                        Some("ReportSection1"),
1104                        None,
1105                        None,
1106                        BindingKind::Filter,
1107                        "'Owners'[Sales owner]".to_string(),
1108                    ),
1109                    // Visual 1 well.
1110                    (
1111                        Some("ReportSection1"),
1112                        Some("visual1"),
1113                        None,
1114                        BindingKind::FieldWell { role: "Category" },
1115                        "'Product'[Category]".to_string(),
1116                    ),
1117                    // Visual 1 filter.
1118                    (
1119                        Some("ReportSection1"),
1120                        Some("visual1"),
1121                        None,
1122                        BindingKind::Filter,
1123                        "'Region'[Country]".to_string(),
1124                    ),
1125                    // Visual 1 sort.
1126                    (
1127                        Some("ReportSection1"),
1128                        Some("visual1"),
1129                        None,
1130                        BindingKind::Sort,
1131                        "'Sales'[Sales]".to_string(),
1132                    ),
1133                    // Visual 1 conditional formatting.
1134                    (
1135                        Some("ReportSection1"),
1136                        Some("visual1"),
1137                        None,
1138                        BindingKind::ConditionalFormatting,
1139                        "'Sales'[Margin]".to_string(),
1140                    ),
1141                    // Visual 2 well.
1142                    (
1143                        Some("ReportSection2"),
1144                        Some("visual2"),
1145                        None,
1146                        BindingKind::FieldWell { role: "Tooltips" },
1147                        "'Sales'[Customers %]".to_string(),
1148                    ),
1149                    // Bookmark report-level filter: no page, no visual.
1150                    (
1151                        None,
1152                        None,
1153                        Some("Bookmark1"),
1154                        BindingKind::Filter,
1155                        "[Total Units]".to_string(),
1156                    ),
1157                    // Bookmark section filter.
1158                    (
1159                        Some("ReportSection1"),
1160                        None,
1161                        Some("Bookmark1"),
1162                        BindingKind::Filter,
1163                        "'Products'[Product category]".to_string(),
1164                    ),
1165                    // Bookmark well.
1166                    (
1167                        Some("ReportSection1"),
1168                        Some("visual1"),
1169                        Some("Bookmark1"),
1170                        BindingKind::FieldWell { role: "Rows" },
1171                        "'Product'[Subcategory]".to_string(),
1172                    ),
1173                    // Bookmark visual filter.
1174                    (
1175                        Some("ReportSection1"),
1176                        Some("visual1"),
1177                        Some("Bookmark1"),
1178                        BindingKind::Filter,
1179                        "'Product'[Color]".to_string(),
1180                    ),
1181                ]
1182            );
1183        }
1184    }
1185
1186    /// The same field, bound through PBIR's structured entities and through legacy
1187    /// Layout's written names, must enumerate to the same provenance: this
1188    /// equivalence is the AST's whole reason to exist.
1189    mod format_agnostic {
1190        use super::*;
1191
1192        fn report_with(well_target: FieldTarget) -> ReportModel {
1193            ReportModel {
1194                pages: vec![Page {
1195                    visuals: vec![Visual {
1196                        wells: vec![well("Y", &[well_target])],
1197                        ..visual("visual1", "clusteredColumnChart")
1198                    }],
1199                    ..page("ReportSection1")
1200                }],
1201                ..Default::default()
1202            }
1203        }
1204
1205        #[test]
1206        fn structured_and_written_targets_bind_alike() {
1207            let pbir = report_with(FieldTarget::Measure {
1208                home_table: Some(NameKey::new("Sales")),
1209                measure: NameKey::new("Cost"),
1210            });
1211            let legacy = report_with(FieldTarget::Written(FieldRef {
1212                table: Some(NameKey::new("Sales")),
1213                name: NameKey::new("Cost"),
1214            }));
1215
1216            let pbir_bindings = pbir.bindings();
1217            let legacy_bindings = legacy.bindings();
1218            assert_eq!(pbir_bindings.len(), 1);
1219            assert_eq!(legacy_bindings.len(), 1);
1220
1221            // Provenance and kind are identical; only the target's variant differs.
1222            assert_eq!(pbir_bindings[0].page, legacy_bindings[0].page);
1223            assert_eq!(pbir_bindings[0].visual, legacy_bindings[0].visual);
1224            assert_eq!(pbir_bindings[0].bookmark, legacy_bindings[0].bookmark);
1225            assert_eq!(pbir_bindings[0].kind, legacy_bindings[0].kind);
1226        }
1227    }
1228
1229    mod dax_expressions {
1230        use super::*;
1231
1232        #[test]
1233        fn enumerates_a_report_measures_body_and_format_string() {
1234            let report = ReportModel {
1235                measures: vec![ReportMeasure {
1236                    name: NameKey::new("Growth %"),
1237                    expression: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])".to_string(),
1238                    format_string: Some("0.0%;-0.0%;0.0%".to_string()),
1239                }],
1240                ..Default::default()
1241            };
1242
1243            let expressions = report.dax_expressions();
1244            assert_eq!(expressions.len(), 2);
1245
1246            assert_eq!(
1247                expressions[0],
1248                DaxExpressionRef {
1249                    owner: ExpressionOwner::ReportMeasure {
1250                        measure: "Growth %"
1251                    },
1252                    kind: DaxExpressionKind::ReportMeasure,
1253                    home_table: None,
1254                    text: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])",
1255                }
1256            );
1257            assert_eq!(
1258                expressions[1],
1259                DaxExpressionRef {
1260                    owner: ExpressionOwner::ReportMeasure {
1261                        measure: "Growth %"
1262                    },
1263                    kind: DaxExpressionKind::ReportMeasureFormatString,
1264                    home_table: None,
1265                    text: "0.0%;-0.0%;0.0%",
1266                }
1267            );
1268        }
1269
1270        /// Most report measures carry no dynamic format string; only the body is
1271        /// then an expression source.
1272        #[test]
1273        fn a_measure_without_a_format_string_enumerates_only_its_body() {
1274            let report = ReportModel {
1275                measures: vec![ReportMeasure {
1276                    name: NameKey::new("Total Units"),
1277                    expression: "SUM('Sales'[Units])".to_string(),
1278                    format_string: None,
1279                }],
1280                ..Default::default()
1281            };
1282
1283            let expressions = report.dax_expressions();
1284            assert_eq!(expressions.len(), 1);
1285            assert_eq!(expressions[0].kind, DaxExpressionKind::ReportMeasure);
1286        }
1287
1288        #[test]
1289        fn a_report_without_measures_has_none() {
1290            assert!(ReportModel::default().dax_expressions().is_empty());
1291        }
1292
1293        /// The owner is the measure's reachability identity: visuals reference it,
1294        /// its body references model objects, and the graph node must match both.
1295        /// Compared through `Display`: `ObjectId` equality ignores case, so it
1296        /// could never catch a lowercased or rewritten name.
1297        #[test]
1298        fn owner_materializes_a_report_measure_object_id() {
1299            let owner = ExpressionOwner::ReportMeasure {
1300                measure: "Growth %",
1301            };
1302            assert_eq!(
1303                owner.to_object_id().to_string(),
1304                "report measure 'Growth %'"
1305            );
1306        }
1307    }
1308
1309    mod expression_views {
1310        use super::*;
1311
1312        /// Mirrors the model-side guarantee: bindings enumerate without allocating
1313        /// beyond the returned `Vec`, which holds only while every field borrows.
1314        #[test]
1315        fn are_copy_so_enumeration_borrows_everything() {
1316            fn assert_copy<T: Copy>() {}
1317            assert_copy::<BindingRef<'_>>();
1318            assert_copy::<BindingKind<'_>>();
1319        }
1320    }
1321
1322    mod defaults {
1323        use super::*;
1324
1325        /// An unparsed dataset reference must never masquerade as a path or a
1326        /// connection, or a wrong report↔model pairing would reach the graph.
1327        #[test]
1328        fn a_dataset_reference_is_unresolved() {
1329            assert_eq!(DatasetReference::default(), DatasetReference::Unresolved);
1330            assert_eq!(ReportModel::default().dataset, DatasetReference::Unresolved);
1331        }
1332
1333        /// Most pages are plain pages; PBIR omits the binding for them entirely.
1334        #[test]
1335        fn a_page_binding_kind_is_default() {
1336            assert_eq!(PageBindingKind::default(), PageBindingKind::Default);
1337            assert_eq!(PageBinding::default().kind, PageBindingKind::Default);
1338        }
1339    }
1340}