Skip to main content

onetaskgraph_core/config/
routes.rs

1//! A source's `routes`: where an item written to it goes instead, chosen by the item's
2//! repositories.
3//!
4//! Engine-owned rather than plugin-owned: `routes` sits beside `plugin` and `config` in a
5//! source's entry, and a plugin's own block never sees it. Every writer reaches the store,
6//! so the rule is held here once rather than restated, and drifting, in each of them.
7//!
8//! An entry matches an item when **every** one of the item's repositories matches at least
9//! one of its patterns; an item with no repositories matches none. The first entry that
10//! matches wins, and no match leaves the item in the source itself.
11
12use 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/// One pattern over a normalized repository origin, `host/owner/name`, where a `*` segment
22/// matches exactly one whole segment of the origin.
23///
24/// Kept as it was written, because that is how `config show` and every refusal spell it.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RepositoryPattern(String);
27
28impl RepositoryPattern {
29    /// One pattern, once it is established it is one.
30    ///
31    /// # Errors
32    ///
33    /// Returns why when the pattern is not itself spelled as a normalized origin — the
34    /// shape [`Repository`] holds every origin to, so a pattern no origin could match is
35    /// refused by the one rule that defines an origin rather than by a restatement of it —
36    /// or when a segment holds a `*` beside other characters.
37    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    /// Whether `origin` is one this pattern names.
53    #[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    /// The pattern as it was written.
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71/// One entry of a source's `routes`.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Route {
74    repositories: Vec<RepositoryPattern>,
75    to: SourceName,
76}
77
78impl Route {
79    /// The patterns, in the order written. Never empty.
80    #[must_use]
81    pub fn repositories(&self) -> &[RepositoryPattern] {
82        &self.repositories
83    }
84
85    /// The configured source an item this entry matches goes to.
86    #[must_use]
87    pub fn to(&self) -> &SourceName {
88        &self.to
89    }
90
91    /// Whether every one of `repositories` matches one of this entry's patterns — `false`
92    /// for an item naming none.
93    #[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/// Where one item written to a source lands, and which of its routes put it there.
105///
106/// `route` is always written, `null` included: a reader of a routed copy's report is told
107/// that no entry matched rather than left to infer it from an absent key.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
109pub struct Placement {
110    /// The source the item lands in: the routed one, or the source itself.
111    pub destination: SourceName,
112    /// The index of the route entry that matched, or `None` when none did and the item
113    /// stays in the source itself.
114    pub route: Option<u32>,
115}
116
117/// What `sources route` answers: where an item with the repositories asked about, written
118/// to `source`, would land — read from configuration alone, never from a source.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct SourceRoute {
121    /// The source the item would be written to.
122    pub source: SourceName,
123    /// Where it would land, and the route entry that would send it there.
124    #[serde(flatten)]
125    pub placement: Placement,
126}
127
128/// Every configured source's routes, by source name. A source with none has no entry.
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct Routes(BTreeMap<SourceName, Vec<Route>>);
131
132impl Routes {
133    /// Where an item with `repositories` written to `source` lands.
134    #[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    /// Whether `source` declares any route.
157    #[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    /// Every source an item written to `source` could land in: the source itself first,
163    /// then each distinct route target in the order its entries are written.
164    #[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    /// One source's routes, in order.
176    #[must_use]
177    pub fn of(&self, source: &SourceName) -> &[Route] {
178        self.0.get(source).map_or(&[], Vec::as_slice)
179    }
180
181    /// Routes built in code, for a caller holding sources it did not resolve from a
182    /// configuration document. Checked by the same rules a document's are.
183    ///
184    /// # Errors
185    ///
186    /// As [`check`].
187    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
219/// Read one source's `routes` as a document, an environment variable or `--set` spells it.
220///
221/// A list is the document's spelling. A mapping keyed by each entry's index is what the
222/// environment and `--set` produce — `--set sources.plans.routes.0.to=linear` — because
223/// neither can spell a list of objects, and both are read the same way. One pattern where
224/// a list of them is expected is read as that one-pattern list, for the reason
225/// `default_sources` accepts one name.
226pub(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
275/// One entry, refused naming its key when it is not `{repositories, to}`.
276fn 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
346/// The patterns of one entry, refused naming it when there are none or one is malformed.
347fn 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
371/// Refuse an entry whose `to` names no configured source, the source itself, or a source
372/// that routes on its own.
373///
374/// No chains: a route's target places what it is given where it is, so where an item lands
375/// is always one lookup away from where it was sent, and no cycle can be written down.
376///
377/// # Errors
378///
379/// [`ConfigError::Setting`] naming the source and the entry.
380pub(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}