use anyhow::{Context, Result};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_SECTIONS: &[&str] = &["summary", "workstreams", "coverage", "receipts"];
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamConfig {
pub members: Vec<String>,
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub sections: Vec<String>,
#[serde(default)]
pub template: Option<PathBuf>,
#[serde(default)]
pub since: Option<NaiveDate>,
#[serde(default)]
pub until: Option<NaiveDate>,
#[serde(default)]
pub required_schema_version: Option<String>,
}
impl TeamConfig {
pub fn load(path: &Path) -> Result<Self> {
let text =
fs::read_to_string(path).with_context(|| format!("read team config {path:?}"))?;
let cfg: Self =
serde_yaml::from_str(&text).with_context(|| format!("parse team config {path:?}"))?;
Ok(cfg)
}
pub fn normalized_sections(&self) -> Vec<String> {
if self.sections.is_empty() {
DEFAULT_SECTIONS.iter().map(|s| s.to_string()).collect()
} else {
let mut seen = HashSet::new();
let mut out = Vec::new();
for section in &self.sections {
let section = section.trim().to_ascii_lowercase();
if section.is_empty() {
continue;
}
if seen.insert(section.clone()) {
out.push(section);
}
}
if out.is_empty() {
DEFAULT_SECTIONS.iter().map(|s| s.to_string()).collect()
} else {
out
}
}
}
pub fn section_enabled(&self, section: &str) -> bool {
self.normalized_sections()
.iter()
.any(|value| value == section)
}
}
pub fn parse_csv_list(raw: &str) -> Vec<String> {
let mut values = Vec::new();
let mut seen = HashSet::new();
for raw_value in raw.split(',') {
let value = raw_value.trim();
if value.is_empty() {
continue;
}
if seen.insert(value.to_string()) {
values.push(value.to_string());
}
}
values
}
pub fn parse_alias_list(alias_args: &[String]) -> Result<HashMap<String, String>> {
let mut aliases = HashMap::new();
for entry in alias_args {
let raw = entry.trim();
let mut parts = raw.splitn(2, '=');
let member = parts.next().unwrap_or_default().trim().to_string();
let display = parts.next().unwrap_or_default().trim().to_string();
if member.is_empty() {
anyhow::bail!("Invalid alias '{raw}': expected member=Display Name");
}
if display.is_empty() {
anyhow::bail!("Invalid alias '{raw}': display name cannot be empty");
}
aliases.insert(member, display);
}
Ok(aliases)
}
pub fn resolve_team_config(
config: Option<PathBuf>,
members: Option<String>,
since: Option<NaiveDate>,
until: Option<NaiveDate>,
sections: Option<String>,
template: Option<PathBuf>,
required_schema_version: Option<String>,
alias: Vec<String>,
) -> Result<TeamConfig> {
let mut cfg = match config {
Some(path) => TeamConfig::load(&path)?,
None => TeamConfig::default(),
};
if let Some(raw_members) = members {
let parsed_members = parse_csv_list(&raw_members);
if !parsed_members.is_empty() {
cfg.members = parsed_members;
}
}
if let Some(raw_sections) = sections {
cfg.sections = parse_csv_list(&raw_sections);
}
if let Some(template) = template {
cfg.template = Some(template);
}
if let Some(since) = since {
cfg.since = Some(since);
}
if let Some(until) = until {
cfg.until = Some(until);
}
if let Some(version) = required_schema_version {
cfg.required_schema_version = Some(version);
}
let alias_entries = parse_alias_list(&alias)?;
if !alias_entries.is_empty() {
cfg.aliases.extend(alias_entries);
}
if let (Some(since), Some(until)) = (cfg.since, cfg.until)
&& until <= since
{
anyhow::bail!("Invalid date range: until ({until}) must be after since ({since})");
}
Ok(cfg)
}