use std::collections::BTreeMap;
use onetaskgraph_plugin_api::{Repository, SourceName};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::ConfigError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryPattern(String);
impl RepositoryPattern {
pub fn new(pattern: impl Into<String>) -> Result<Self, String> {
let pattern = pattern.into();
let valid = Repository::try_from(pattern.clone()).is_ok()
&& pattern
.split('/')
.all(|segment| segment == "*" || !segment.contains('*'));
valid.then_some(Self(pattern.clone())).ok_or_else(|| {
format!(
"{pattern:?} is not a repository pattern: a pattern is host/owner/name, each \
segment either a literal or a lone `*` matching one whole segment, with no \
scheme and no .git suffix"
)
})
}
#[must_use]
pub fn matches(&self, origin: &Repository) -> bool {
let pattern: Vec<&str> = self.0.split('/').collect();
let origin: Vec<&str> = origin.as_str().split('/').collect();
pattern.len() == origin.len()
&& pattern
.iter()
.zip(&origin)
.all(|(pattern, origin)| *pattern == "*" || pattern == origin)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Route {
repositories: Vec<RepositoryPattern>,
to: SourceName,
}
impl Route {
#[must_use]
pub fn repositories(&self) -> &[RepositoryPattern] {
&self.repositories
}
#[must_use]
pub fn to(&self) -> &SourceName {
&self.to
}
#[must_use]
pub fn matches(&self, repositories: &[Repository]) -> bool {
!repositories.is_empty()
&& repositories.iter().all(|origin| {
self.repositories
.iter()
.any(|pattern| pattern.matches(origin))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Placement {
pub destination: SourceName,
pub route: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SourceRoute {
pub source: SourceName,
#[serde(flatten)]
pub placement: Placement,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Routes(BTreeMap<SourceName, Vec<Route>>);
impl Routes {
#[must_use]
pub fn place(&self, source: &SourceName, repositories: &[Repository]) -> Placement {
self.0
.get(source)
.and_then(|routes| {
routes
.iter()
.enumerate()
.find(|(_, route)| route.matches(repositories))
})
.map_or_else(
|| Placement {
destination: source.clone(),
route: None,
},
|(index, route)| Placement {
destination: route.to.clone(),
route: Some(u32::try_from(index).unwrap_or(u32::MAX)),
},
)
}
#[must_use]
pub fn routes(&self, source: &SourceName) -> bool {
self.0.get(source).is_some_and(|routes| !routes.is_empty())
}
#[must_use]
pub fn reachable(&self, source: &SourceName) -> Vec<SourceName> {
let mut reachable = vec![source.clone()];
for route in self.0.get(source).into_iter().flatten() {
if !reachable.contains(&route.to) {
reachable.push(route.to.clone());
}
}
reachable
}
#[must_use]
pub fn of(&self, source: &SourceName) -> &[Route] {
self.0.get(source).map_or(&[], Vec::as_slice)
}
pub fn new(
routes: BTreeMap<SourceName, Vec<(Vec<String>, SourceName)>>,
configured: &[SourceName],
) -> Result<Self, ConfigError> {
let mut built = BTreeMap::new();
for (source, entries) in routes {
let mut parsed = Vec::new();
for (index, (patterns, to)) in entries.into_iter().enumerate() {
let key = entry_key(&source, index);
parsed.push(Route {
repositories: patterns_of(&key, patterns)?,
to,
});
}
built.insert(source, parsed);
}
let routes = Self(built);
check(&routes, configured)?;
Ok(routes)
}
pub(super) fn insert(&mut self, source: SourceName, routes: Vec<Route>) {
if !routes.is_empty() {
self.0.insert(source, routes);
}
}
}
fn entry_key(source: &SourceName, index: usize) -> String {
format!("sources.{source}.routes.{index}")
}
pub(super) fn parse(source: &SourceName, value: &Value) -> Result<Vec<Route>, ConfigError> {
let base = format!("sources.{source}.routes");
let entries: Vec<(usize, &Value)> = match value {
Value::Null => return Ok(Vec::new()),
Value::Array(entries) => entries.iter().enumerate().collect(),
Value::Object(entries) => {
let mut indexed = Vec::new();
for (key, entry) in entries {
let index = key.parse::<usize>().map_err(|_| {
ConfigError::setting(
format!("{base}.{key}"),
"a route is addressed by its index in the list, and this is not one",
format!(
"write the routes as a list, or address each entry by its index — \
`--set {base}.0.to=<source>`."
),
)
})?;
indexed.push((index, entry));
}
indexed.sort_by_key(|(index, _)| *index);
for (position, (index, _)) in indexed.iter().enumerate() {
if *index != position {
return Err(ConfigError::setting(
format!("{base}.{index}"),
format!(
"the routes are numbered from 0 with no gaps, and entry {position} \
is missing"
),
format!("set {base}.{position} as well, or renumber the entries."),
));
}
}
indexed
}
_ => {
return Err(ConfigError::setting(
base,
"routes is a list of entries, each naming `repositories` and `to`",
"write it as a list — see the README's section on routes.",
));
}
};
entries
.into_iter()
.map(|(index, entry)| parse_entry(&entry_key(source, index), entry))
.collect()
}
fn parse_entry(key: &str, entry: &Value) -> Result<Route, ConfigError> {
let Value::Object(fields) = entry else {
return Err(ConfigError::setting(
key,
"a route is a mapping holding `repositories` and `to`",
"write the entry as `{repositories: [host/owner/*], to: <source>}`.",
));
};
if let Some(unknown) = fields
.keys()
.find(|field| !["repositories", "to"].contains(&field.as_str()))
{
return Err(ConfigError::setting(
format!("{key}.{unknown}"),
"unknown field; a route holds `repositories` and `to` and nothing else",
"remove it.",
));
}
let patterns = match fields.get("repositories") {
None => {
return Err(ConfigError::setting(
format!("{key}.repositories"),
"a route names the repositories it matches, and this one names none",
"list at least one pattern, such as `github.com/petsinc/*`.",
));
}
Some(Value::String(one)) => vec![one.clone()],
Some(Value::Array(many)) => many
.iter()
.map(|pattern| {
pattern.as_str().map(str::to_owned).ok_or_else(|| {
ConfigError::setting(
format!("{key}.repositories"),
format!("{pattern} is not a pattern; each is a string"),
"write each pattern as host/owner/name, quoted if need be.",
)
})
})
.collect::<Result<_, _>>()?,
Some(other) => {
return Err(ConfigError::setting(
format!("{key}.repositories"),
format!("{other} is not a list of patterns"),
"write it as a list of host/owner/name patterns.",
));
}
};
let to = match fields.get("to") {
Some(Value::String(to)) => SourceName::new(to.clone()).map_err(|error| {
ConfigError::setting(
format!("{key}.to"),
error.to_string(),
"name a configured source; `onetaskgraph config show` lists them.",
)
})?,
_ => {
return Err(ConfigError::setting(
format!("{key}.to"),
"a route names the configured source it sends an item to, and this one names \
none",
"set `to` to a configured source's name.",
));
}
};
Ok(Route {
repositories: patterns_of(key, patterns)?,
to,
})
}
fn patterns_of(key: &str, patterns: Vec<String>) -> Result<Vec<RepositoryPattern>, ConfigError> {
if patterns.is_empty() {
return Err(ConfigError::setting(
format!("{key}.repositories"),
"a route names the repositories it matches, and this one's list is empty — an \
entry matching nothing is a mistake rather than a rule",
"list at least one pattern, such as `github.com/petsinc/*`, or remove the entry.",
));
}
patterns
.into_iter()
.map(|pattern| {
RepositoryPattern::new(pattern).map_err(|problem| {
ConfigError::setting(
format!("{key}.repositories"),
problem,
"write the pattern as host/owner/name, using `*` for one whole segment — \
`github.com/petsinc/*`.",
)
})
})
.collect()
}
pub(super) fn check(routes: &Routes, configured: &[SourceName]) -> Result<(), ConfigError> {
for (source, entries) in &routes.0 {
for (index, route) in entries.iter().enumerate() {
let key = format!("{}.to", entry_key(source, index));
if !configured.contains(&route.to) {
return Err(ConfigError::setting(
key,
format!(
"route {index} of source {source} sends to {:?}, which no source is \
configured as",
route.to.as_str()
),
format!(
"name one of the configured sources ({}), or configure {:?}.",
configured
.iter()
.map(SourceName::as_str)
.collect::<Vec<_>>()
.join(", "),
route.to.as_str()
),
));
}
if &route.to == source {
return Err(ConfigError::setting(
key,
format!(
"route {index} of source {source} sends to {source} itself, which is \
where an item no route matches already stays"
),
"name another source, or remove the entry.",
));
}
if routes.routes(&route.to) {
return Err(ConfigError::setting(
key,
format!(
"route {index} of source {source} sends to {}, which has routes of its \
own; a route never chains",
route.to
),
format!(
"send to the source {} would route to directly, or remove {}'s routes.",
route.to, route.to
),
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn origin(text: &str) -> Repository {
Repository::try_from(text.to_owned()).expect("an origin")
}
#[test]
fn a_star_matches_exactly_one_whole_segment() {
let pattern = RepositoryPattern::new("github.com/petsinc/*").expect("a pattern");
assert!(pattern.matches(&origin("github.com/petsinc/api")));
assert!(!pattern.matches(&origin("github.com/petsinc/api/sub")));
assert!(!pattern.matches(&origin("github.com/petsincx/api")));
assert!(!pattern.matches(&origin("gitlab.com/petsinc/api")));
}
#[test]
fn a_malformed_pattern_is_refused() {
for bad in [
"github.com/petsinc",
"github.com/pets*/api",
"https://github.com/a/b",
"github.com//b",
"github.com/a/b.git",
] {
assert!(RepositoryPattern::new(bad).is_err(), "{bad} is refused");
}
}
#[test]
fn an_entry_matches_only_when_every_repository_does() {
let route = Route {
repositories: vec![RepositoryPattern::new("github.com/petsinc/*").unwrap()],
to: SourceName::new("linear").unwrap(),
};
assert!(route.matches(&[origin("github.com/petsinc/a")]));
assert!(!route.matches(&[
origin("github.com/petsinc/a"),
origin("github.com/nickderobertis/b")
]));
assert!(!route.matches(&[]));
}
}