1use 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 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
67pub 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
139fn 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}