use indexmap::IndexMap; use serde::{Deserialize, Serialize}; pub type CategoryId = usize; pub type ItemId = usize; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Item { pub id: ItemId, pub name: String, /// Parent group name, if any pub group: Option, } impl Item { pub fn new(id: ItemId, name: impl Into) -> Self { Self { id, name: name.into(), group: None } } pub fn with_group(mut self, group: impl Into) -> Self { self.group = Some(group.into()); self } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Group { pub name: String, /// Parent group name for nested hierarchies pub parent: Option, } impl Group { pub fn new(name: impl Into) -> Self { Self { name: name.into(), parent: None } } pub fn with_parent(mut self, parent: impl Into) -> Self { self.parent = Some(parent.into()); self } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Category { pub id: CategoryId, pub name: String, /// Items in insertion order pub items: IndexMap, /// Named groups (hierarchy nodes) pub groups: Vec, /// Next item id counter next_item_id: ItemId, } impl Category { pub fn new(id: CategoryId, name: impl Into) -> Self { Self { id, name: name.into(), items: IndexMap::new(), groups: Vec::new(), next_item_id: 0, } } pub fn add_item(&mut self, name: impl Into) -> ItemId { let name = name.into(); if let Some(item) = self.items.get(&name) { return item.id; } let id = self.next_item_id; self.next_item_id += 1; self.items.insert(name.clone(), Item::new(id, name)); id } pub fn add_item_in_group(&mut self, name: impl Into, group: impl Into) -> ItemId { let name = name.into(); let group = group.into(); if let Some(item) = self.items.get(&name) { return item.id; } let id = self.next_item_id; self.next_item_id += 1; self.items.insert(name.clone(), Item::new(id, name).with_group(group)); id } pub fn add_group(&mut self, group: Group) { if !self.groups.iter().any(|g| g.name == group.name) { self.groups.push(group); } } pub fn item_by_name(&self, name: &str) -> Option<&Item> { self.items.get(name) } pub fn item_index(&self, name: &str) -> Option { self.items.get_index_of(name) } /// Returns item names in order, grouped hierarchically pub fn ordered_item_names(&self) -> Vec<&str> { self.items.keys().map(|s| s.as_str()).collect() } /// Returns unique group names at the top level pub fn top_level_groups(&self) -> Vec<&str> { let mut seen = Vec::new(); for item in self.items.values() { if let Some(g) = &item.group { if !seen.contains(&g.as_str()) { seen.push(g.as_str()); } } } seen } } #[cfg(test)] mod tests { use super::{Category, Group}; fn cat() -> Category { Category::new(0, "Region") } #[test] fn add_item_returns_sequential_ids() { let mut c = cat(); let id0 = c.add_item("East"); let id1 = c.add_item("West"); assert_eq!(id0, 0); assert_eq!(id1, 1); } #[test] fn add_item_duplicate_returns_same_id() { let mut c = cat(); let id1 = c.add_item("East"); let id2 = c.add_item("East"); assert_eq!(id1, id2); assert_eq!(c.items.len(), 1); } #[test] fn add_item_in_group_sets_group() { let mut c = cat(); c.add_item_in_group("Jan", "Q1"); let item = c.item_by_name("Jan").unwrap(); assert_eq!(item.group.as_deref(), Some("Q1")); } #[test] fn add_item_in_group_duplicate_returns_same_id() { let mut c = cat(); let id1 = c.add_item_in_group("Jan", "Q1"); let id2 = c.add_item_in_group("Jan", "Q1"); assert_eq!(id1, id2); assert_eq!(c.items.len(), 1); } #[test] fn add_group_deduplicates() { let mut c = cat(); c.add_group(Group::new("Q1")); c.add_group(Group::new("Q1")); assert_eq!(c.groups.len(), 1); } #[test] fn top_level_groups_returns_unique_groups_in_insertion_order() { let mut c = cat(); c.add_item_in_group("Jan", "Q1"); c.add_item_in_group("Feb", "Q1"); c.add_item_in_group("Apr", "Q2"); let groups = c.top_level_groups(); assert_eq!(groups, vec!["Q1", "Q2"]); } #[test] fn item_index_reflects_insertion_order() { let mut c = cat(); c.add_item("East"); c.add_item("West"); c.add_item("North"); assert_eq!(c.item_index("East"), Some(0)); assert_eq!(c.item_index("West"), Some(1)); assert_eq!(c.item_index("North"), Some(2)); } }