1use std::collections::BTreeMap;
13
14use onetaskgraph_plugin_api::{Repository, SourceName};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use super::ConfigError;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RepositoryPattern(String);
27
28impl RepositoryPattern {
29 pub fn new(pattern: impl Into<String>) -> Result<Self, String> {
38 let pattern = pattern.into();
39 let valid = Repository::try_from(pattern.clone()).is_ok()
40 && pattern
41 .split('/')
42 .all(|segment| segment == "*" || !segment.contains('*'));
43 valid.then_some(Self(pattern.clone())).ok_or_else(|| {
44 format!(
45 "{pattern:?} is not a repository pattern: a pattern is host/owner/name, each \
46 segment either a literal or a lone `*` matching one whole segment, with no \
47 scheme and no .git suffix"
48 )
49 })
50 }
51
52 #[must_use]
54 pub fn matches(&self, origin: &Repository) -> bool {
55 let pattern: Vec<&str> = self.0.split('/').collect();
56 let origin: Vec<&str> = origin.as_str().split('/').collect();
57 pattern.len() == origin.len()
58 && pattern
59 .iter()
60 .zip(&origin)
61 .all(|(pattern, origin)| *pattern == "*" || pattern == origin)
62 }
63
64 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Route {
74 repositories: Vec<RepositoryPattern>,
75 to: SourceName,
76}
77
78impl Route {
79 #[must_use]
81 pub fn repositories(&self) -> &[RepositoryPattern] {
82 &self.repositories
83 }
84
85 #[must_use]
87 pub fn to(&self) -> &SourceName {
88 &self.to
89 }
90
91 #[must_use]
94 pub fn matches(&self, repositories: &[Repository]) -> bool {
95 !repositories.is_empty()
96 && repositories.iter().all(|origin| {
97 self.repositories
98 .iter()
99 .any(|pattern| pattern.matches(origin))
100 })
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
109pub struct Placement {
110 pub destination: SourceName,
112 pub route: Option<u32>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct SourceRoute {
121 pub source: SourceName,
123 #[serde(flatten)]
125 pub placement: Placement,
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct Routes(BTreeMap<SourceName, Vec<Route>>);
131
132impl Routes {
133 #[must_use]
135 pub fn place(&self, source: &SourceName, repositories: &[Repository]) -> Placement {
136 self.0
137 .get(source)
138 .and_then(|routes| {
139 routes
140 .iter()
141 .enumerate()
142 .find(|(_, route)| route.matches(repositories))
143 })
144 .map_or_else(
145 || Placement {
146 destination: source.clone(),
147 route: None,
148 },
149 |(index, route)| Placement {
150 destination: route.to.clone(),
151 route: Some(u32::try_from(index).unwrap_or(u32::MAX)),
152 },
153 )
154 }
155
156 #[must_use]
158 pub fn routes(&self, source: &SourceName) -> bool {
159 self.0.get(source).is_some_and(|routes| !routes.is_empty())
160 }
161
162 #[must_use]
165 pub fn reachable(&self, source: &SourceName) -> Vec<SourceName> {
166 let mut reachable = vec![source.clone()];
167 for route in self.0.get(source).into_iter().flatten() {
168 if !reachable.contains(&route.to) {
169 reachable.push(route.to.clone());
170 }
171 }
172 reachable
173 }
174
175 #[must_use]
177 pub fn of(&self, source: &SourceName) -> &[Route] {
178 self.0.get(source).map_or(&[], Vec::as_slice)
179 }
180
181 pub fn new(
188 routes: BTreeMap<SourceName, Vec<(Vec<String>, SourceName)>>,
189 configured: &[SourceName],
190 ) -> Result<Self, ConfigError> {
191 let mut built = BTreeMap::new();
192 for (source, entries) in routes {
193 let mut parsed = Vec::new();
194 for (index, (patterns, to)) in entries.into_iter().enumerate() {
195 let key = entry_key(&source, index);
196 parsed.push(Route {
197 repositories: patterns_of(&key, patterns)?,
198 to,
199 });
200 }
201 built.insert(source, parsed);
202 }
203 let routes = Self(built);
204 check(&routes, configured)?;
205 Ok(routes)
206 }
207
208 pub(super) fn insert(&mut self, source: SourceName, routes: Vec<Route>) {
209 if !routes.is_empty() {
210 self.0.insert(source, routes);
211 }
212 }
213}
214
215fn entry_key(source: &SourceName, index: usize) -> String {
216 format!("sources.{source}.routes.{index}")
217}
218
219pub(super) fn parse(source: &SourceName, value: &Value) -> Result<Vec<Route>, ConfigError> {
227 let base = format!("sources.{source}.routes");
228 let entries: Vec<(usize, &Value)> = match value {
229 Value::Null => return Ok(Vec::new()),
230 Value::Array(entries) => entries.iter().enumerate().collect(),
231 Value::Object(entries) => {
232 let mut indexed = Vec::new();
233 for (key, entry) in entries {
234 let index = key.parse::<usize>().map_err(|_| {
235 ConfigError::setting(
236 format!("{base}.{key}"),
237 "a route is addressed by its index in the list, and this is not one",
238 format!(
239 "write the routes as a list, or address each entry by its index — \
240 `--set {base}.0.to=<source>`."
241 ),
242 )
243 })?;
244 indexed.push((index, entry));
245 }
246 indexed.sort_by_key(|(index, _)| *index);
247 for (position, (index, _)) in indexed.iter().enumerate() {
248 if *index != position {
249 return Err(ConfigError::setting(
250 format!("{base}.{index}"),
251 format!(
252 "the routes are numbered from 0 with no gaps, and entry {position} \
253 is missing"
254 ),
255 format!("set {base}.{position} as well, or renumber the entries."),
256 ));
257 }
258 }
259 indexed
260 }
261 _ => {
262 return Err(ConfigError::setting(
263 base,
264 "routes is a list of entries, each naming `repositories` and `to`",
265 "write it as a list — see the README's section on routes.",
266 ));
267 }
268 };
269 entries
270 .into_iter()
271 .map(|(index, entry)| parse_entry(&entry_key(source, index), entry))
272 .collect()
273}
274
275fn parse_entry(key: &str, entry: &Value) -> Result<Route, ConfigError> {
277 let Value::Object(fields) = entry else {
278 return Err(ConfigError::setting(
279 key,
280 "a route is a mapping holding `repositories` and `to`",
281 "write the entry as `{repositories: [host/owner/*], to: <source>}`.",
282 ));
283 };
284 if let Some(unknown) = fields
285 .keys()
286 .find(|field| !["repositories", "to"].contains(&field.as_str()))
287 {
288 return Err(ConfigError::setting(
289 format!("{key}.{unknown}"),
290 "unknown field; a route holds `repositories` and `to` and nothing else",
291 "remove it.",
292 ));
293 }
294 let patterns = match fields.get("repositories") {
295 None => {
296 return Err(ConfigError::setting(
297 format!("{key}.repositories"),
298 "a route names the repositories it matches, and this one names none",
299 "list at least one pattern, such as `github.com/petsinc/*`.",
300 ));
301 }
302 Some(Value::String(one)) => vec![one.clone()],
303 Some(Value::Array(many)) => many
304 .iter()
305 .map(|pattern| {
306 pattern.as_str().map(str::to_owned).ok_or_else(|| {
307 ConfigError::setting(
308 format!("{key}.repositories"),
309 format!("{pattern} is not a pattern; each is a string"),
310 "write each pattern as host/owner/name, quoted if need be.",
311 )
312 })
313 })
314 .collect::<Result<_, _>>()?,
315 Some(other) => {
316 return Err(ConfigError::setting(
317 format!("{key}.repositories"),
318 format!("{other} is not a list of patterns"),
319 "write it as a list of host/owner/name patterns.",
320 ));
321 }
322 };
323 let to = match fields.get("to") {
324 Some(Value::String(to)) => SourceName::new(to.clone()).map_err(|error| {
325 ConfigError::setting(
326 format!("{key}.to"),
327 error.to_string(),
328 "name a configured source; `onetaskgraph config show` lists them.",
329 )
330 })?,
331 _ => {
332 return Err(ConfigError::setting(
333 format!("{key}.to"),
334 "a route names the configured source it sends an item to, and this one names \
335 none",
336 "set `to` to a configured source's name.",
337 ));
338 }
339 };
340 Ok(Route {
341 repositories: patterns_of(key, patterns)?,
342 to,
343 })
344}
345
346fn patterns_of(key: &str, patterns: Vec<String>) -> Result<Vec<RepositoryPattern>, ConfigError> {
348 if patterns.is_empty() {
349 return Err(ConfigError::setting(
350 format!("{key}.repositories"),
351 "a route names the repositories it matches, and this one's list is empty — an \
352 entry matching nothing is a mistake rather than a rule",
353 "list at least one pattern, such as `github.com/petsinc/*`, or remove the entry.",
354 ));
355 }
356 patterns
357 .into_iter()
358 .map(|pattern| {
359 RepositoryPattern::new(pattern).map_err(|problem| {
360 ConfigError::setting(
361 format!("{key}.repositories"),
362 problem,
363 "write the pattern as host/owner/name, using `*` for one whole segment — \
364 `github.com/petsinc/*`.",
365 )
366 })
367 })
368 .collect()
369}
370
371pub(super) fn check(routes: &Routes, configured: &[SourceName]) -> Result<(), ConfigError> {
381 for (source, entries) in &routes.0 {
382 for (index, route) in entries.iter().enumerate() {
383 let key = format!("{}.to", entry_key(source, index));
384 if !configured.contains(&route.to) {
385 return Err(ConfigError::setting(
386 key,
387 format!(
388 "route {index} of source {source} sends to {:?}, which no source is \
389 configured as",
390 route.to.as_str()
391 ),
392 format!(
393 "name one of the configured sources ({}), or configure {:?}.",
394 configured
395 .iter()
396 .map(SourceName::as_str)
397 .collect::<Vec<_>>()
398 .join(", "),
399 route.to.as_str()
400 ),
401 ));
402 }
403 if &route.to == source {
404 return Err(ConfigError::setting(
405 key,
406 format!(
407 "route {index} of source {source} sends to {source} itself, which is \
408 where an item no route matches already stays"
409 ),
410 "name another source, or remove the entry.",
411 ));
412 }
413 if routes.routes(&route.to) {
414 return Err(ConfigError::setting(
415 key,
416 format!(
417 "route {index} of source {source} sends to {}, which has routes of its \
418 own; a route never chains",
419 route.to
420 ),
421 format!(
422 "send to the source {} would route to directly, or remove {}'s routes.",
423 route.to, route.to
424 ),
425 ));
426 }
427 }
428 }
429 Ok(())
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn origin(text: &str) -> Repository {
437 Repository::try_from(text.to_owned()).expect("an origin")
438 }
439
440 #[test]
441 fn a_star_matches_exactly_one_whole_segment() {
442 let pattern = RepositoryPattern::new("github.com/petsinc/*").expect("a pattern");
443 assert!(pattern.matches(&origin("github.com/petsinc/api")));
444 assert!(!pattern.matches(&origin("github.com/petsinc/api/sub")));
445 assert!(!pattern.matches(&origin("github.com/petsincx/api")));
446 assert!(!pattern.matches(&origin("gitlab.com/petsinc/api")));
447 }
448
449 #[test]
450 fn a_malformed_pattern_is_refused() {
451 for bad in [
452 "github.com/petsinc",
453 "github.com/pets*/api",
454 "https://github.com/a/b",
455 "github.com//b",
456 "github.com/a/b.git",
457 ] {
458 assert!(RepositoryPattern::new(bad).is_err(), "{bad} is refused");
459 }
460 }
461
462 #[test]
463 fn an_entry_matches_only_when_every_repository_does() {
464 let route = Route {
465 repositories: vec![RepositoryPattern::new("github.com/petsinc/*").unwrap()],
466 to: SourceName::new("linear").unwrap(),
467 };
468 assert!(route.matches(&[origin("github.com/petsinc/a")]));
469 assert!(!route.matches(&[
470 origin("github.com/petsinc/a"),
471 origin("github.com/nickderobertis/b")
472 ]));
473 assert!(!route.matches(&[]));
474 }
475}