use serde::{Deserialize, Serialize};
use crate::models::IndustrySector;
const PACK_MANUFACTURING: &str = include_str!("industry_packs/manufacturing.yaml");
const PACK_RETAIL: &str = include_str!("industry_packs/retail.yaml");
const PACK_FINANCIAL_SERVICES: &str = include_str!("industry_packs/financial_services.yaml");
const PACK_HEALTHCARE: &str = include_str!("industry_packs/healthcare.yaml");
const PACK_TECHNOLOGY: &str = include_str!("industry_packs/technology.yaml");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubAccountSpec {
pub suffix: String,
pub name: String,
pub weight: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountExpansion {
pub parent_account: String,
pub parent_name: String,
pub sub_accounts: Vec<SubAccountSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndustryPack {
pub industry: String,
pub description: String,
#[serde(default = "default_version")]
pub version: u32,
pub expansions: Vec<AccountExpansion>,
}
fn default_version() -> u32 {
1
}
impl IndustryPack {
pub fn parse(yaml: &str) -> Result<Self, String> {
serde_yaml::from_str(yaml).map_err(|e| format!("industry pack parse error: {e}"))
}
pub fn expansion_for(&self, parent_account: &str) -> Option<&AccountExpansion> {
self.expansions
.iter()
.find(|e| e.parent_account == parent_account)
}
}
pub fn raw_pack_for(sector: IndustrySector) -> Option<&'static str> {
match sector {
IndustrySector::Manufacturing => Some(PACK_MANUFACTURING),
IndustrySector::Retail => Some(PACK_RETAIL),
IndustrySector::FinancialServices => Some(PACK_FINANCIAL_SERVICES),
IndustrySector::Healthcare => Some(PACK_HEALTHCARE),
IndustrySector::Technology => Some(PACK_TECHNOLOGY),
IndustrySector::ProfessionalServices
| IndustrySector::Energy
| IndustrySector::Transportation
| IndustrySector::RealEstate
| IndustrySector::Telecommunications => None,
}
}
pub fn load_pack(sector: IndustrySector) -> Result<Option<IndustryPack>, String> {
match raw_pack_for(sector) {
Some(yaml) => IndustryPack::parse(yaml).map(Some),
None => Ok(None),
}
}
pub fn render_sub_account_number(parent: &str, suffix: &str) -> String {
let parent4: String = parent.chars().take(4).collect();
let parent4 = format!("{parent4:0>4}");
let suffix2 = format!("{suffix:0>2}");
format!("{parent4}{suffix2}")
}
pub fn render_sub_account_name(parent_name: &str, sub_name: &str) -> String {
format!("{parent_name} — {sub_name}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_shipped_packs_parse_and_are_well_formed() {
for sector in [
IndustrySector::Manufacturing,
IndustrySector::Retail,
IndustrySector::FinancialServices,
IndustrySector::Healthcare,
IndustrySector::Technology,
] {
let pack = load_pack(sector)
.unwrap_or_else(|e| panic!("{:?} pack failed to load: {e}", sector))
.unwrap_or_else(|| panic!("{:?} pack should be shipped", sector));
assert!(!pack.industry.is_empty(), "{:?}: industry empty", sector);
assert!(
!pack.expansions.is_empty(),
"{:?}: at least one expansion required",
sector
);
for exp in &pack.expansions {
assert_eq!(
exp.parent_account.len(),
4,
"{:?} parent {:?} should be 4 digits",
sector,
exp.parent_account
);
assert!(
exp.parent_account.chars().all(|c| c.is_ascii_digit()),
"{:?} parent {:?} should be all digits",
sector,
exp.parent_account
);
assert!(
exp.sub_accounts.len() >= 2,
"{:?} parent {} should have ≥2 sub-accounts",
sector,
exp.parent_account
);
for sub in &exp.sub_accounts {
assert_eq!(
sub.suffix.len(),
2,
"{:?} sub suffix {:?} should be 2 chars",
sector,
sub.suffix
);
assert!(
sub.suffix.chars().all(|c| c.is_ascii_digit()),
"{:?} sub suffix {:?} should be digits",
sector,
sub.suffix
);
assert!(
sub.weight > 0.0,
"{:?} sub {:?} weight must be positive",
sector,
sub.name
);
assert!(!sub.name.is_empty(), "{:?} sub name empty", sector);
}
let mut suffixes: Vec<&str> =
exp.sub_accounts.iter().map(|s| s.suffix.as_str()).collect();
suffixes.sort();
let before = suffixes.len();
suffixes.dedup();
assert_eq!(
before,
suffixes.len(),
"{:?} parent {} has duplicate suffixes",
sector,
exp.parent_account
);
}
}
}
#[test]
fn unsupported_sectors_return_none() {
assert!(matches!(load_pack(IndustrySector::Energy), Ok(None)));
assert!(matches!(
load_pack(IndustrySector::ProfessionalServices),
Ok(None)
));
}
#[test]
fn render_sub_account_number_concatenates_padded() {
assert_eq!(render_sub_account_number("4000", "10"), "400010");
assert_eq!(render_sub_account_number("4000", "5"), "400005");
assert_eq!(render_sub_account_number("60", "10"), "006010");
}
#[test]
fn render_sub_account_name_uses_em_dash() {
assert_eq!(
render_sub_account_name("Product Revenue", "Steel Products"),
"Product Revenue — Steel Products"
);
}
#[test]
fn manufacturing_has_expected_revenue_split() {
let pack = load_pack(IndustrySector::Manufacturing).unwrap().unwrap();
let rev = pack
.expansion_for("4000")
.expect("manufacturing must expand 4000");
let names: Vec<&str> = rev.sub_accounts.iter().map(|s| s.name.as_str()).collect();
assert!(names.iter().any(|n| n.contains("Steel")));
assert!(names.iter().any(|n| n.contains("Aluminum")));
}
}