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