use std::collections::BTreeSet;
use std::fmt;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::core::errors::{Result, TgaError};
pub const DEFAULT_BUCKETS: [(&str, &[&str]); 4] = [
(
"Maintenance",
&["bug_fix", "devops", "security", "qa", "upkeep"],
),
(
"Value Creation",
&["new_feature", "integration", "content_design"],
),
(
"Foundational Investment",
&["platform_infrastructure", "data_science"],
),
("Internal Tooling", &["internal_tooling"]),
];
pub const MAP_ONLY_CATEGORIES: [&str; 1] = ["content_design"];
pub const NO_BUCKET_LABELS: [&str; 4] = ["unclear", "mixed", "release_merge", "uncategorized"];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Bucket {
pub name: String,
pub categories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BucketMap {
buckets: Vec<Bucket>,
}
impl Default for BucketMap {
fn default() -> Self {
Self {
buckets: DEFAULT_BUCKETS
.iter()
.map(|(name, cats)| Bucket {
name: (*name).to_string(),
categories: cats.iter().map(|c| (*c).to_string()).collect(),
})
.collect(),
}
}
}
impl BucketMap {
pub fn new(entries: Vec<(String, Vec<String>)>) -> Result<Self> {
let err = |m: String| Err(TgaError::ConfigError(format!("bucket map: {m}")));
if entries.is_empty() {
return err("names no bucket".into());
}
let (mut names, mut seen) = (BTreeSet::new(), BTreeSet::new());
let mut buckets = Vec::with_capacity(entries.len());
for (name, cats) in entries {
let name = name.trim().to_string();
if name.is_empty() {
return err("a bucket name is blank".into());
}
if !names.insert(name.to_lowercase()) {
return err(format!("bucket {name:?} is listed twice"));
}
if cats.is_empty() {
return err(format!("bucket {name:?} has no categories"));
}
let mut categories = Vec::with_capacity(cats.len());
for c in cats {
let c = c.trim().to_lowercase();
if c.is_empty() {
return err(format!("bucket {name:?} has a blank category"));
}
if NO_BUCKET_LABELS.contains(&c.as_str()) {
return err(format!("{c:?} is a no-answer label and has no bucket"));
}
if !seen.insert(c.clone()) {
return err(format!("category {c:?} is in more than one bucket"));
}
categories.push(c);
}
buckets.push(Bucket { name, categories });
}
Ok(Self { buckets })
}
pub fn buckets(&self) -> &[Bucket] {
&self.buckets
}
pub fn bucket_of(&self, category: &str) -> Option<&str> {
let c = category.trim();
self.buckets
.iter()
.find(|b| b.categories.iter().any(|x| x.eq_ignore_ascii_case(c)))
.map(|b| b.name.as_str())
}
pub fn has_secondary(&self, bucket: &str) -> bool {
self.buckets
.iter()
.any(|b| b.name == bucket && b.categories.len() > 1)
}
pub fn fine_categories(&self) -> impl Iterator<Item = &str> {
self.buckets
.iter()
.flat_map(|b| b.categories.iter().map(String::as_str))
}
pub fn check_known(&self, known: &[String]) -> Result<()> {
if *self == Self::default() {
return Ok(());
}
let known: BTreeSet<String> = known.iter().map(|k| k.to_lowercase()).collect();
let unknown: Vec<&str> = self
.fine_categories()
.filter(|c| !known.contains(*c) && !MAP_ONLY_CATEGORIES.contains(c))
.collect();
if unknown.is_empty() {
Ok(())
} else {
Err(TgaError::ConfigError(format!(
"the bucket map names categories this config does not know: {}",
unknown.join(", ")
)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BucketSource {
Config,
RulesFile,
Fallback,
}
impl BucketSource {
pub fn is_consumer_supplied(self) -> bool {
!matches!(self, Self::Fallback)
}
pub fn describe(self) -> &'static str {
match self {
Self::Config => "classification.buckets",
Self::RulesFile => "the rules file",
Self::Fallback => "tga's built-in fallback",
}
}
}
impl super::Config {
pub fn bucket_map(&self) -> BucketMap {
self.classification
.as_ref()
.and_then(|c| c.buckets.clone())
.unwrap_or_default()
}
}
impl Serialize for BucketMap {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.buckets.len()))?;
for b in &self.buckets {
map.serialize_entry(&b.name, &b.categories)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for BucketMap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct Ordered;
impl<'de> Visitor<'de> for Ordered {
type Value = Vec<(String, Vec<String>)>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a map of bucket name to a list of categories")
}
fn visit_map<A: MapAccess<'de>>(
self,
mut access: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some(entry) = access.next_entry()? {
out.push(entry);
}
Ok(out)
}
}
let entries = deserializer.deserialize_map(Ordered)?;
Self::new(entries).map_err(de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> std::result::Result<BucketMap, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
#[test]
fn default_map_matches_the_ruling() {
let m = BucketMap::default();
let names: Vec<&str> = m.buckets().iter().map(|b| b.name.as_str()).collect();
assert_eq!(
names,
[
"Maintenance",
"Value Creation",
"Foundational Investment",
"Internal Tooling"
]
);
assert_eq!(m.bucket_of("Upkeep"), Some("Maintenance"));
assert_eq!(m.bucket_of("content_design"), Some("Value Creation"));
assert_eq!(m.bucket_of("data_science"), Some("Foundational Investment"));
for label in NO_BUCKET_LABELS {
assert_eq!(m.bucket_of(label), None, "{label}");
}
assert!(m.has_secondary("Maintenance"));
assert!(!m.has_secondary("Internal Tooling"));
assert_eq!(m.fine_categories().count(), 11);
}
#[test]
fn structural_errors_are_rejected() {
for (yaml, needle) in [
("{}", "no bucket"),
("A: []", "no categories"),
("A: [qa]\nB: [QA]", "more than one bucket"),
("A: [unclear]", "no-answer"),
("A: [qa]\na: [devops]", "listed twice"),
] {
let e = parse(yaml).expect_err(yaml).to_string();
assert!(e.contains(needle), "{yaml}: {e}");
}
let m = parse("Zeta: [qa]\nAlpha: [devops, upkeep]").expect("ordered");
assert_eq!(m.buckets()[0].name, "Zeta");
let back = serde_yaml::to_string(&m).expect("serialize");
assert_eq!(parse(&back).expect("round trip"), m);
}
#[test]
fn an_override_naming_an_unknown_category_is_rejected() {
let known: Vec<String> = ["qa", "upkeep", "devops"].map(String::from).to_vec();
let bad = parse("Maintenance: [upkep]\nOther: [qa]").expect("parse");
let e = bad
.check_known(&known)
.expect_err("typo accepted")
.to_string();
assert!(e.contains("upkep") && !e.contains("qa"), "{e}");
let good = parse("Maintenance: [upkeep, devops]\nOther: [qa, content_design]").expect("p");
good.check_known(&known).expect("known");
BucketMap::default().check_known(&[]).expect("default");
}
}