1use 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 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
66pub 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
138fn 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}