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
6use std::fmt;
7
8use crate::project::{Project, REPORTS_DIR, Report, dotted};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SelectorError {
12    Ambiguous {
13        token: String,
14        candidates: Vec<Vec<String>>,
15    },
16    NoMatch {
17        token: String,
18    },
19}
20
21impl SelectorError {
22    pub fn code(&self) -> &'static str {
23        match self {
24            SelectorError::Ambiguous { .. } => "ambiguous-selector",
25            SelectorError::NoMatch { .. } => "selector-matches-nothing",
26        }
27    }
28}
29
30impl fmt::Display for SelectorError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            SelectorError::Ambiguous { token, candidates } => {
34                writeln!(
35                    f,
36                    "\"{token}\" matches more than one location — use the dotted form to disambiguate:"
37                )?;
38                let width = candidates.iter().map(|c| dotted(c).len()).max().unwrap_or(0);
39                for (i, c) in candidates.iter().enumerate() {
40                    let d = dotted(c);
41                    write!(f, "  {d:<width$}   ({REPORTS_DIR}/{}/)", c.join("/"))?;
42                    if i + 1 < candidates.len() {
43                        writeln!(f)?;
44                    }
45                }
46                Ok(())
47            }
48            SelectorError::NoMatch { token } => {
49                write!(f, "selector `{token}` matches no report name, tag or folder")
50            }
51        }
52    }
53}
54
55impl std::error::Error for SelectorError {}
56
57/// Resolve one selector token to reports, in project order. An explicit `tag:` or dotted path
58/// that matches nothing returns an empty list; an unknown bare token is `NoMatch`.
59/// Resolve a selection: one or more selectors separated by spaces, commas or semicolons
60/// (`daily tag:regulatory`, `daily,monthly`, `daily;monthly`), matching any of them. Reports come
61/// back in project order, each once.
62pub fn resolve<'p>(project: &'p Project, selection: &str) -> Result<Vec<&'p Report>, SelectorError> {
63    let mut picked: Vec<&'p Report> = Vec::new();
64    for term in selection.split(|c: char| c.is_whitespace() || c == ',' || c == ';') {
65        if term.is_empty() {
66            continue;
67        }
68        for r in resolve_one(project, term)? {
69            if !picked.iter().any(|x| x.name == r.name) {
70                picked.push(r);
71            }
72        }
73    }
74    picked.sort_by_key(|r| project.reports.iter().position(|x| x.name == r.name));
75    Ok(picked)
76}
77
78fn resolve_one<'p>(project: &'p Project, token: &str) -> Result<Vec<&'p Report>, SelectorError> {
79    let token = token.trim();
80    let under = |folder: &[String]| -> Vec<&'p Report> {
81        project
82            .reports
83            .iter()
84            .filter(|r| r.folder.starts_with(folder))
85            .collect()
86    };
87    if let Some(tag) = token.strip_prefix("tag:") {
88        return Ok(project
89            .reports
90            .iter()
91            .filter(|r| r.tags.iter().any(|t| t == tag))
92            .collect());
93    }
94    if let Some(r) = project.report(token) {
95        return Ok(vec![r]);
96    }
97    if token.contains('.') {
98        let path: Vec<String> = token.split('.').map(str::to_string).collect();
99        return Ok(under(&path));
100    }
101    let tagged: Vec<&Report> = project
102        .reports
103        .iter()
104        .filter(|r| r.tags.iter().any(|t| t == token))
105        .collect();
106    if !tagged.is_empty() {
107        return Ok(tagged);
108    }
109    let folders: Vec<&Vec<String>> = project
110        .folders
111        .iter()
112        .filter(|f| f.last().is_some_and(|l| l == token))
113        .collect();
114    match folders.len() {
115        0 => Err(SelectorError::NoMatch {
116            token: token.to_string(),
117        }),
118        1 => Ok(under(folders[0])),
119        _ => Err(SelectorError::Ambiguous {
120            token: token.to_string(),
121            candidates: folders.into_iter().cloned().collect(),
122        }),
123    }
124}