use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TopLevelCategory {
Feature,
Bugfix,
Ktlo,
Integrations,
PlatformWork,
Content,
Maintenance,
Unknown,
}
impl TopLevelCategory {
pub fn display_name(&self) -> &'static str {
match self {
TopLevelCategory::Feature => "Feature",
TopLevelCategory::Bugfix => "Bugfix",
TopLevelCategory::Ktlo => "Keep The Lights On",
TopLevelCategory::Integrations => "Integrations",
TopLevelCategory::PlatformWork => "Platform Work",
TopLevelCategory::Content => "Content",
TopLevelCategory::Maintenance => "Maintenance",
TopLevelCategory::Unknown => "Unknown",
}
}
pub fn all() -> &'static [TopLevelCategory] {
&[
TopLevelCategory::Feature,
TopLevelCategory::Bugfix,
TopLevelCategory::Ktlo,
TopLevelCategory::Integrations,
TopLevelCategory::PlatformWork,
TopLevelCategory::Content,
TopLevelCategory::Maintenance,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubcategoryDef {
pub name: String,
pub parent: TopLevelCategory,
#[serde(default)]
pub display_name: Option<String>,
}
impl SubcategoryDef {
pub fn new(name: impl Into<String>, parent: TopLevelCategory) -> Self {
Self {
name: name.into(),
parent,
display_name: None,
}
}
}
#[derive(Debug, Clone)]
pub struct TaxonomyRegistry {
by_name: HashMap<String, SubcategoryDef>,
ordered: Vec<SubcategoryDef>,
}
impl TaxonomyRegistry {
pub fn new(user_defs: Vec<SubcategoryDef>) -> Self {
let mut by_name: HashMap<String, SubcategoryDef> = HashMap::new();
let mut ordered: Vec<SubcategoryDef> = Vec::new();
for def in Self::built_in_defs() {
by_name.insert(def.name.to_lowercase(), def.clone());
ordered.push(def);
}
for def in user_defs {
let key = def.name.to_lowercase();
if let Some(pos) = ordered.iter().position(|d| d.name.to_lowercase() == key) {
ordered[pos] = def.clone();
} else {
ordered.push(def.clone());
}
by_name.insert(key, def);
}
Self { by_name, ordered }
}
pub fn with_builtins() -> Self {
Self::new(Vec::new())
}
pub fn resolve(&self, subcategory: &str) -> Option<TopLevelCategory> {
self.by_name
.get(&subcategory.to_lowercase())
.map(|d| d.parent)
}
pub fn all(&self) -> &[SubcategoryDef] {
&self.ordered
}
pub fn built_in_defs() -> Vec<SubcategoryDef> {
use TopLevelCategory::*;
vec![
SubcategoryDef::new("feature", Feature),
SubcategoryDef::new("enhancement", Feature),
SubcategoryDef::new("new-feature", Feature),
SubcategoryDef::new("breaking", Feature),
SubcategoryDef::new("bugfix", Bugfix),
SubcategoryDef::new("bug", Bugfix),
SubcategoryDef::new("hotfix", Bugfix),
SubcategoryDef::new("security", Bugfix),
SubcategoryDef::new("ci", Ktlo),
SubcategoryDef::new("build", Ktlo),
SubcategoryDef::new("ops", Ktlo),
SubcategoryDef::new("release", Ktlo),
SubcategoryDef::new("integration", Integrations),
SubcategoryDef::new("integrations", Integrations),
SubcategoryDef::new("api", Integrations),
SubcategoryDef::new("webhook", Integrations),
SubcategoryDef::new("infra", PlatformWork),
SubcategoryDef::new("platform", PlatformWork),
SubcategoryDef::new("performance", PlatformWork),
SubcategoryDef::new("perf", PlatformWork),
SubcategoryDef::new("architecture", PlatformWork),
SubcategoryDef::new("devops", PlatformWork),
SubcategoryDef::new("docs", Content),
SubcategoryDef::new("documentation", Content),
SubcategoryDef::new("content", Content),
SubcategoryDef::new("localization", Content),
SubcategoryDef::new("refactor", Maintenance),
SubcategoryDef::new("test", Maintenance),
SubcategoryDef::new("tests", Maintenance),
SubcategoryDef::new("style", Maintenance),
SubcategoryDef::new("cleanup", Maintenance),
SubcategoryDef::new("maintenance", Maintenance),
SubcategoryDef::new("deps", Maintenance),
SubcategoryDef::new("dependencies", Maintenance),
SubcategoryDef::new("revert", Maintenance),
SubcategoryDef::new("merge", Maintenance),
SubcategoryDef::new("chore", Maintenance),
SubcategoryDef::new("cloud", PlatformWork),
SubcategoryDef::new("monitoring", PlatformWork),
SubcategoryDef::new("observability", PlatformWork),
SubcategoryDef::new("database", PlatformWork),
SubcategoryDef::new("messaging", PlatformWork),
SubcategoryDef::new("networking", PlatformWork),
SubcategoryDef::new("storage", PlatformWork),
SubcategoryDef::new("experiment", Feature),
SubcategoryDef::new("spike", Feature),
SubcategoryDef::new("prototype", Feature),
SubcategoryDef::new("rollback", Maintenance),
SubcategoryDef::new("config", Maintenance),
SubcategoryDef::new("tooling", Maintenance),
SubcategoryDef::new("content-docs", Content),
SubcategoryDef::new("translation", Content),
SubcategoryDef::new("assets", Content),
SubcategoryDef::new("wip", Unknown),
SubcategoryDef::new("uncategorized", Unknown),
]
}
}
impl Default for TaxonomyRegistry {
fn default() -> Self {
Self::with_builtins()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_resolves_builtin_subcategories() {
let reg = TaxonomyRegistry::with_builtins();
assert_eq!(reg.resolve("feature"), Some(TopLevelCategory::Feature));
assert_eq!(reg.resolve("bugfix"), Some(TopLevelCategory::Bugfix));
assert_eq!(reg.resolve("security"), Some(TopLevelCategory::Bugfix));
assert_eq!(reg.resolve("ci"), Some(TopLevelCategory::Ktlo));
assert_eq!(reg.resolve("build"), Some(TopLevelCategory::Ktlo));
assert_eq!(
reg.resolve("performance"),
Some(TopLevelCategory::PlatformWork)
);
assert_eq!(
reg.resolve("documentation"),
Some(TopLevelCategory::Content)
);
assert_eq!(reg.resolve("refactor"), Some(TopLevelCategory::Maintenance));
assert_eq!(reg.resolve("chore"), Some(TopLevelCategory::Maintenance));
}
#[test]
fn registry_lookup_is_case_insensitive() {
let reg = TaxonomyRegistry::with_builtins();
assert_eq!(reg.resolve("FEATURE"), Some(TopLevelCategory::Feature));
assert_eq!(reg.resolve("BugFix"), Some(TopLevelCategory::Bugfix));
}
#[test]
fn registry_merges_user_defined() {
let user = vec![
SubcategoryDef::new("payments", TopLevelCategory::Integrations),
SubcategoryDef::new("auth", TopLevelCategory::Feature),
];
let reg = TaxonomyRegistry::new(user);
assert_eq!(
reg.resolve("payments"),
Some(TopLevelCategory::Integrations)
);
assert_eq!(reg.resolve("auth"), Some(TopLevelCategory::Feature));
assert_eq!(reg.resolve("feature"), Some(TopLevelCategory::Feature));
}
#[test]
fn user_can_override_builtin_without_breaking_registry() {
let user = vec![SubcategoryDef::new(
"security",
TopLevelCategory::PlatformWork,
)];
let reg = TaxonomyRegistry::new(user);
assert_eq!(
reg.resolve("security"),
Some(TopLevelCategory::PlatformWork)
);
let count = reg
.all()
.iter()
.filter(|d| d.name.eq_ignore_ascii_case("security"))
.count();
assert_eq!(count, 1, "duplicate registration must be deduplicated");
}
#[test]
fn unknown_subcategory_returns_none() {
let reg = TaxonomyRegistry::with_builtins();
assert!(reg.resolve("totally-not-a-real-category").is_none());
}
#[test]
fn top_level_all_excludes_unknown() {
for top in TopLevelCategory::all() {
assert_ne!(*top, TopLevelCategory::Unknown);
}
assert_eq!(TopLevelCategory::all().len(), 7);
}
#[test]
fn top_level_display_names_are_stable() {
assert_eq!(TopLevelCategory::Feature.display_name(), "Feature");
assert_eq!(TopLevelCategory::Ktlo.display_name(), "Keep The Lights On");
assert_eq!(
TopLevelCategory::PlatformWork.display_name(),
"Platform Work"
);
}
}