Skip to main content

dre_core/
selector.rs

1//! Selector resolution, shared by `dre validate` (schedules.yml) and `dre run`.
2//!
3//! A bare token is tried as (1) an exact report name, (2) a tag, (3) a folder leaf name anywhere
4//! under `reports/`. `tag:x` is an explicit tag; a dotted token is a folder path from `reports/`.
5//! `source:sales` picks every report a query of which reads a table of source `sales`, and
6//! `source:sales.orders` those reading that table (as the parse pass found them).
7
8use std::fmt;
9
10use crate::project::{Project, REPORTS_DIR, Report, dotted};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SelectorError {
14    Ambiguous {
15        token: String,
16        candidates: Vec<Vec<String>>,
17    },
18    NoMatch {
19        token: String,
20    },
21    /// `source:` names a source or table that isn't declared.
22    UnknownSource {
23        token: String,
24        message: String,
25    },
26}
27
28impl SelectorError {
29    pub fn code(&self) -> &'static str {
30        match self {
31            SelectorError::Ambiguous { .. } => "ambiguous-selector",
32            SelectorError::NoMatch { .. } => "selector-matches-nothing",
33            SelectorError::UnknownSource { .. } => "unknown-source",
34        }
35    }
36}
37
38impl fmt::Display for SelectorError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            SelectorError::Ambiguous { token, candidates } => {
42                writeln!(
43                    f,
44                    "\"{token}\" matches more than one location — use the dotted form to disambiguate:"
45                )?;
46                let width = candidates.iter().map(|c| dotted(c).len()).max().unwrap_or(0);
47                for (i, c) in candidates.iter().enumerate() {
48                    let d = dotted(c);
49                    write!(f, "  {d:<width$}   ({REPORTS_DIR}/{}/)", c.join("/"))?;
50                    if i + 1 < candidates.len() {
51                        writeln!(f)?;
52                    }
53                }
54                Ok(())
55            }
56            SelectorError::NoMatch { token } => {
57                write!(f, "selector `{token}` matches no report name, tag or folder")
58            }
59            SelectorError::UnknownSource { token, message } => write!(f, "selector `{token}`: {message}"),
60        }
61    }
62}
63
64impl std::error::Error for SelectorError {}
65
66/// Resolve one selector token to reports, in project order. An explicit `tag:` or dotted path
67/// that matches nothing returns an empty list; an unknown bare token is `NoMatch`.
68/// Resolve a selection: one or more selectors separated by spaces, commas or semicolons
69/// (`daily tag:regulatory`, `daily,monthly`, `daily;monthly`), matching any of them. Reports come
70/// back in project order, each once.
71pub fn resolve<'p>(project: &'p Project, selection: &str) -> Result<Vec<&'p Report>, SelectorError> {
72    let mut picked: Vec<&'p Report> = Vec::new();
73    for term in selection.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {
74        if term.is_empty() {
75            continue;
76        }
77        for r in resolve_one(project, term)? {
78            if !picked.iter().any(|x| x.name == r.name) {
79                picked.push(r);
80            }
81        }
82    }
83    picked.sort_by_key(|r| project.reports.iter().position(|x| x.name == r.name));
84    Ok(picked)
85}
86
87fn resolve_one<'p>(project: &'p Project, token: &str) -> Result<Vec<&'p Report>, SelectorError> {
88    let token = token.trim();
89    let under = |folder: &[String]| -> Vec<&'p Report> {
90        project
91            .reports
92            .iter()
93            .filter(|r| r.folder.starts_with(folder))
94            .collect()
95    };
96    if let Some(sel) = token.strip_prefix("source:") {
97        return by_source(project, token, sel);
98    }
99    if let Some(tag) = token.strip_prefix("tag:") {
100        return Ok(project
101            .reports
102            .iter()
103            .filter(|r| r.tags.iter().any(|t| t == tag))
104            .collect());
105    }
106    if let Some(r) = project.report(token) {
107        return Ok(vec![r]);
108    }
109    if token.contains('.') {
110        let path: Vec<String> = token.split('.').map(str::to_string).collect();
111        return Ok(under(&path));
112    }
113    let tagged: Vec<&Report> = project
114        .reports
115        .iter()
116        .filter(|r| r.tags.iter().any(|t| t == token))
117        .collect();
118    if !tagged.is_empty() {
119        return Ok(tagged);
120    }
121    let folders: Vec<&Vec<String>> = project
122        .folders
123        .iter()
124        .filter(|f| f.last().is_some_and(|l| l == token))
125        .collect();
126    match folders.len() {
127        0 => Err(SelectorError::NoMatch {
128            token: token.to_string(),
129        }),
130        1 => Ok(under(folders[0])),
131        _ => Err(SelectorError::Ambiguous {
132            token: token.to_string(),
133            candidates: folders.into_iter().cloned().collect(),
134        }),
135    }
136}
137
138/// `source:<source>` or `source:<source>.<table>`: the reports some query of which reads it.
139fn by_source<'p>(project: &'p Project, token: &str, sel: &str) -> Result<Vec<&'p Report>, SelectorError> {
140    let unknown = |message: String| SelectorError::UnknownSource {
141        token: token.to_string(),
142        message,
143    };
144    let (source, table) = match sel.split_once('.') {
145        Some((s, t)) => (s, Some(t)),
146        None => (sel, None),
147    };
148    let Some(def) = project.sources.get(source) else {
149        let known: Vec<&str> = project.sources.keys().map(String::as_str).collect();
150        return Err(unknown(if known.is_empty() {
151            format!("no source `{source}`: the project declares no `sources:`")
152        } else {
153            format!("no source `{source}` (declared: {})", known.join(", "))
154        }));
155    };
156    if let Some(t) = table
157        && def.table(t).is_none()
158    {
159        return Err(unknown(format!(
160            "source `{source}` has no table `{t}` (it has: {})",
161            def.tables
162                .iter()
163                .map(|t| t.name.as_str())
164                .collect::<Vec<_>>()
165                .join(", ")
166        )));
167    }
168    let reads = |key: &str| match table {
169        Some(t) => key == format!("{source}.{t}"),
170        None => key.split_once('.').is_some_and(|(s, _)| s == source),
171    };
172    Ok(project
173        .reports
174        .iter()
175        .filter(|r| {
176            r.bindings
177                .iter()
178                .filter_map(|b| b.parsed.as_ref())
179                .any(|p| p.source_keys().into_iter().any(reads))
180        })
181        .collect())
182}