ripbi_core/ingest.rs
1//! Format ingestion: turning Power BI source folders into the crate's ASTs.
2//!
3//! Entry points return [`Ingested`] — the parsed value plus every
4//! [`SkipNotice`] the parser recorded. A notice is a warning carried as data,
5//! not control flow: this crate never prints, so the CLI decides how notices
6//! are presented. They are collected on every run, not only in debug builds,
7//! because a silently skipped object can surface later as a false "unused"
8//! finding — the failure mode this tool exists to prevent.
9//!
10//! Skips come in two tiers. *Deliberately unmodeled* metadata (annotations,
11//! lineage tags, display folders, cultures, …) is skipped silently, per the
12//! exclusions in `docs/semantic-model.md`. *Unexpected drift* — an unknown
13//! object, a property that is neither modeled nor ignored, a value that fails
14//! to parse — is recorded as a notice. The full policy, including the curated
15//! ignore list, lives in `docs/formats.md`.
16
17mod pbir;
18mod tmdl;
19
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use crate::model::TabularDatabase;
24use crate::report::ReportModel;
25use crate::{Error, Result};
26
27/// One thing a parser skipped, and why.
28///
29/// Notices are warnings as data: this crate records them and moves on, never
30/// printing and never failing, so a single unexpected property cannot abort an
31/// analysis run. Presentation is the CLI's decision.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SkipNotice {
34 /// The file the skip was found in, as ingest saw it.
35 pub path: PathBuf,
36 /// Where in the file the skip lives — a TMDL line number (`line 12`) or a
37 /// JSON pointer — when the parser can name one.
38 pub location: Option<String>,
39 /// What kind of skip this is.
40 pub kind: SkipKind,
41 /// What was skipped and why, in one sentence.
42 pub detail: String,
43}
44
45/// Why a parser skipped something.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SkipKind {
48 /// An object the AST does not model and the ignore list does not cover.
49 UnknownObject,
50 /// A property the AST does not model and the ignore list does not cover.
51 UnknownProperty,
52 /// A modeled property whose value could not be parsed.
53 MalformedValue,
54 /// A query alias that could not be resolved (PBIR `SourceRef.Source`).
55 UnresolvedAlias,
56}
57
58/// A parsed value plus everything unexpected the parser skipped on the way.
59///
60/// A named wrapper rather than a tuple, so it can grow fields additively —
61/// a parse count, for instance — without breaking every call site.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Ingested<T> {
64 /// The parsed value.
65 pub value: T,
66 /// Everything the parser skipped, in file order.
67 pub skips: Vec<SkipNotice>,
68}
69
70/// Parses a TMDL semantic model into a [`TabularDatabase`].
71///
72/// `path` is a `.SemanticModel` folder (its `definition/` subfolder is located
73/// automatically) or a `definition/` folder itself. Unknown-but-harmless TMDL
74/// drift is reported in [`Ingested::skips`]; only a file that cannot be parsed
75/// into a tree at all fails with [`Error::Tmdl`].
76///
77/// Table order follows `model.tmdl`'s `ref table` directives; tables present as
78/// files but never referenced are appended in file-name order. The `cultures/`
79/// folder is deliberately not read.
80pub fn semantic_model(path: &Path) -> Result<Ingested<TabularDatabase>> {
81 let definition = locate_definition(path)?;
82 // `.platform` (which carries the display name) sits beside `definition/`.
83 let item_root = definition.parent().unwrap_or(path);
84 let name = platform_display_name(item_root);
85 let mut skips = Vec::new();
86 let value = tmdl::load_database(&definition, name, &mut skips)?;
87 Ok(Ingested { value, skips })
88}
89
90/// Parses a PBIR report folder into a [`ReportModel`].
91///
92/// `path` is a `.Report` folder (its `definition/` subfolder is located
93/// automatically) or a `definition/` folder itself. A report is parsed
94/// standalone: the semantic model it connects to need not sit beside it, so
95/// one model can be scanned against several reports. Unexpected drift is
96/// reported in [`Ingested::skips`]; only an unreadable or malformed
97/// `report.json` — the file that makes the folder a report — fails.
98pub fn report(path: &Path) -> Result<Ingested<ReportModel>> {
99 let definition = locate_report_definition(path)?;
100 // `.platform` (which carries the display name) sits beside `definition/`.
101 let item_root = definition.parent().unwrap_or(path);
102 let name = platform_display_name(item_root);
103 let mut skips = Vec::new();
104 let value = pbir::load_report(&definition, name, &mut skips)?;
105 Ok(Ingested { value, skips })
106}
107
108/// Resolves the `definition/` folder of a semantic-model item.
109///
110/// Accepts the `.SemanticModel` folder itself, its `definition/` subfolder, or
111/// any directory that directly contains a `model.tmdl`.
112fn locate_definition(path: &Path) -> Result<PathBuf> {
113 if !path.is_dir() {
114 return Err(Error::UnsupportedFormat(format!(
115 "not a semantic model: {} is not a directory",
116 path.display()
117 )));
118 }
119 let nested = path.join("definition");
120 if nested.is_dir() {
121 return Ok(nested);
122 }
123 let looks_like_definition = path.join("model.tmdl").is_file()
124 || path
125 .file_name()
126 .is_some_and(|name| name.eq_ignore_ascii_case("definition"));
127 if looks_like_definition {
128 return Ok(path.to_path_buf());
129 }
130 Err(Error::UnsupportedFormat(format!(
131 "not a semantic model: no definition/ or model.tmdl under {}",
132 path.display()
133 )))
134}
135
136/// Resolves the `definition/` folder of a PBIR report item.
137///
138/// Accepts the `.Report` folder itself, its `definition/` subfolder, or any
139/// directory that directly contains a `report.json`.
140fn locate_report_definition(path: &Path) -> Result<PathBuf> {
141 if !path.is_dir() {
142 return Err(Error::UnsupportedFormat(format!(
143 "not a report: {} is not a directory",
144 path.display()
145 )));
146 }
147 let nested = path.join("definition");
148 if nested.join("report.json").is_file() {
149 return Ok(nested);
150 }
151 if path.join("report.json").is_file() {
152 return Ok(path.to_path_buf());
153 }
154 Err(Error::UnsupportedFormat(format!(
155 "not a report: no definition/report.json or report.json under {}",
156 path.display()
157 )))
158}
159
160/// Reads the item's display name from `.platform`, best-effort.
161///
162/// TMDL itself records no usable model name (`model.tmdl` names its root
163/// object `Model`), so the Fabric item metadata is the only source. Any
164/// absence or drift yields `None` — a name is provenance, never liveness.
165fn platform_display_name(item_root: &Path) -> Option<String> {
166 let text = fs::read_to_string(item_root.join(".platform")).ok()?;
167 let platform: serde_json::Value = serde_json::from_str(&text).ok()?;
168 platform
169 .get("metadata")?
170 .get("displayName")?
171 .as_str()
172 .map(str::to_string)
173}