use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
use crate::classify::taxonomy::SubcategoryDef;
use crate::core::errors::{Result, TgaError};
pub mod aliases;
pub mod azdo;
mod credential_debug;
mod credential_serialize;
pub mod database_path;
pub mod validator;
pub use aliases::{AliasFile, DeveloperAliasEntry};
pub use azdo::AzureDevOpsConfig;
pub use validator::{ConfigError, ConfigValidator};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LlmSource {
#[default]
Openrouter,
Bedrock,
#[serde(rename = "anthropic-api")]
AnthropicApi,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
#[serde(default)]
pub source: LlmSource,
#[serde(default = "default_api_key_env")]
pub api_key_env: String,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
fn default_api_key_env() -> String {
"OPENROUTER_API_KEY".to_string()
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
source: LlmSource::default(),
api_key_env: default_api_key_env(),
region: None,
model: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub repositories: Vec<RepositoryConfig>,
#[serde(default)]
pub team: Option<TeamConfig>,
#[serde(default)]
pub output: Option<OutputConfig>,
#[serde(default)]
pub classification: Option<ClassificationConfig>,
#[serde(default)]
pub github: Option<GithubConfig>,
#[serde(default)]
pub bitbucket: Option<BitbucketConfig>,
#[serde(default)]
pub jira: Option<JiraConfig>,
#[serde(default)]
pub linear: Option<LinearConfig>,
#[serde(default)]
pub pm: Option<PmConfig>,
#[serde(default)]
pub dora: Option<DoraConfig>,
#[serde(default)]
pub reachability: ReachabilityConfig,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub developer_aliases: HashMap<String, Vec<String>>,
#[serde(default)]
pub aliases_file: Option<String>,
#[serde(default)]
pub fuzzy_identity_fallback: Option<bool>,
#[serde(default)]
pub analysis: Option<AnalysisConfig>,
#[serde(default)]
pub cache: Option<CacheConfig>,
#[serde(default)]
pub database: Option<PathBuf>,
#[serde(default)]
pub llm: Option<LlmConfig>,
#[serde(skip)]
pub source_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnalysisConfig {
#[serde(default)]
pub ml_categorization: Option<MlCategorizationConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MlCategorizationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default)]
pub directory: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RepositoryConfig {
pub path: PathBuf,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub since_date: Option<String>,
#[serde(default)]
pub until_date: Option<String>,
#[serde(default, alias = "owner")]
pub org: Option<String>,
#[serde(default)]
pub head_only: bool,
#[serde(default)]
pub fetch_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamConfig {
#[serde(default)]
pub members: Vec<TeamMember>,
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub canonical_domain: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamMember {
pub name: String,
pub email: String,
#[serde(default)]
pub aliases: Vec<String>,
}
fn team_member_alias_list(member: &TeamMember) -> Vec<String> {
let mut combined: Vec<String> = Vec::with_capacity(member.aliases.len() + 1);
if !member.email.is_empty() {
combined.push(member.email.clone());
}
combined.extend(member.aliases.iter().cloned());
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
combined.retain(|s| seen.insert(s.to_lowercase()));
combined
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutputConfig {
#[serde(default)]
pub format: Option<String>,
#[serde(default, alias = "output_path")]
pub directory: Option<PathBuf>,
#[serde(default)]
pub formats: Vec<String>,
#[serde(default)]
pub include_unclassified: bool,
#[serde(default)]
pub include_merges: bool,
#[serde(default)]
pub include_files: bool,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClassificationConfig {
#[serde(
default,
alias = "rules_file",
deserialize_with = "deserialize_rules_files"
)]
pub rules_files: Vec<PathBuf>,
#[serde(default)]
pub repo_categories: HashMap<String, String>,
#[serde(default)]
pub use_llm: bool,
#[serde(default)]
pub llm_model: Option<String>,
#[serde(default = "default_llm_provider")]
pub llm_provider: String,
#[serde(default)]
pub openrouter_api_key: Option<String>,
#[serde(default = "default_confidence_threshold")]
pub confidence_threshold: f64,
#[serde(default)]
pub custom_categories: Vec<SubcategoryDef>,
#[serde(default = "default_min_coverage_pct")]
pub min_coverage_pct: f64,
#[serde(default = "default_llm_fallback_threshold")]
pub llm_fallback_threshold: f64,
#[serde(default)]
pub weighted_sum: crate::classify::tiers::weighted_sum::WeightedSumConfig,
#[serde(default = "default_llm_fallback_concurrency")]
pub llm_fallback_concurrency: usize,
#[serde(default)]
pub no_external: bool,
#[serde(default)]
pub checkpoint_every: usize,
#[serde(default)]
pub sources: Vec<crate::classify::sources::SourceConfig>,
}
fn deserialize_rules_files<'de, D>(deserializer: D) -> std::result::Result<Vec<PathBuf>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(PathBuf),
Many(Vec<PathBuf>),
Null,
}
match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(p) => Ok(vec![p]),
OneOrMany::Many(v) => Ok(v),
OneOrMany::Null => Ok(vec![]),
}
}
fn default_confidence_threshold() -> f64 {
0.7
}
fn default_min_coverage_pct() -> f64 {
20.0
}
fn default_llm_provider() -> String {
"auto".to_string()
}
fn default_llm_fallback_concurrency() -> usize {
8
}
fn default_llm_fallback_threshold() -> f64 {
0.65
}
impl Default for ClassificationConfig {
fn default() -> Self {
Self {
rules_files: Vec::new(),
repo_categories: HashMap::new(),
use_llm: false,
llm_model: None,
llm_provider: default_llm_provider(),
openrouter_api_key: None,
confidence_threshold: default_confidence_threshold(),
custom_categories: Vec::new(),
min_coverage_pct: default_min_coverage_pct(),
llm_fallback_threshold: default_llm_fallback_threshold(),
llm_fallback_concurrency: default_llm_fallback_concurrency(),
no_external: false,
sources: Vec::new(),
weighted_sum: crate::classify::tiers::weighted_sum::WeightedSumConfig::default(),
checkpoint_every: 0,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReachabilityConfig {
#[serde(default = "default_true")]
pub track_tags: bool,
#[serde(default = "default_true")]
pub track_release_branches: bool,
#[serde(default = "default_release_branch_patterns")]
pub release_branch_patterns: Vec<String>,
}
fn default_release_branch_patterns() -> Vec<String> {
vec![
"release/*".to_string(),
"hotfix/*".to_string(),
"chore/release-*".to_string(),
"v*".to_string(),
]
}
impl Default for ReachabilityConfig {
fn default() -> Self {
Self {
track_tags: true,
track_release_branches: true,
release_branch_patterns: default_release_branch_patterns(),
}
}
}
#[derive(Clone, Default, Deserialize)]
pub struct LinearConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub team_keys: Vec<String>,
#[serde(default = "default_true")]
pub fetch_on_reference: bool,
#[serde(default)]
pub ticket_regex: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PmConfig {
#[serde(default)]
pub azure_devops: Option<AzureDevOpsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoraConfig {
#[serde(default = "default_deployment_source")]
pub deployment_source: String,
#[serde(default = "default_deployment_tag_pattern")]
pub deployment_tag_pattern: String,
#[serde(default = "default_production_branch")]
pub production_branch: String,
#[serde(default)]
pub deployment_workflow: Option<String>,
#[serde(default)]
pub failure_signals: Vec<FailureSignal>,
#[serde(default)]
pub datadog_dir: Option<PathBuf>,
}
impl Default for DoraConfig {
fn default() -> Self {
Self {
deployment_source: default_deployment_source(),
deployment_tag_pattern: default_deployment_tag_pattern(),
production_branch: default_production_branch(),
deployment_workflow: None,
failure_signals: Vec::new(),
datadog_dir: None,
}
}
}
fn default_deployment_source() -> String {
"git_tags".to_string()
}
fn default_deployment_tag_pattern() -> String {
r"^v?[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.\-]+)?$".to_string()
}
fn default_production_branch() -> String {
"main".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FailureSignal {
#[serde(default)]
pub work_type: Option<String>,
#[serde(default)]
pub on_branch: Option<String>,
#[serde(default)]
pub commit_message_pattern: Option<String>,
#[serde(default = "default_failure_window_hours")]
pub within_hours: u32,
}
fn default_failure_window_hours() -> u32 {
48
}
#[derive(Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct GithubConfig {
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub org: Option<String>,
#[serde(default)]
pub orgs: Vec<String>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub fetch_prs: bool,
#[serde(default = "default_fetch_pr_reviews")]
pub fetch_pr_reviews: bool,
#[serde(default = "default_review_fetch_concurrency")]
pub review_fetch_concurrency: u32,
#[serde(default)]
pub ticket_regex: Option<String>,
#[serde(default = "default_true")]
pub fetch_on_reference: bool,
#[serde(default)]
pub work_items_unavailable: Option<String>,
}
impl GithubConfig {
pub fn work_items_declared_absent(&self) -> Option<&str> {
self.work_items_unavailable
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty())
}
}
fn default_fetch_pr_reviews() -> bool {
true
}
fn default_review_fetch_concurrency() -> u32 {
1
}
#[derive(Clone, Default, Deserialize)]
pub struct BitbucketConfig {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub app_password: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub workspace: Option<String>,
#[serde(default)]
pub repo_slug: Option<String>,
#[serde(default)]
pub fetch_prs: bool,
#[serde(default)]
pub api_base_url: Option<String>,
}
#[derive(Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct JiraConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub project_key: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default, alias = "jira_project_mapping")]
pub jira_project_mappings: HashMap<String, String>,
#[serde(default)]
pub jira_project_mapping_confidence: Option<f64>,
#[serde(default)]
pub ticket_regex: Option<String>,
#[serde(default = "default_true")]
pub fetch_on_reference: bool,
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
pub fn expand_path(path: &Path) -> PathBuf {
expand_path_with(path, home_dir().as_deref())
}
pub(crate) fn expand_path_with(path: &Path, home: Option<&Path>) -> PathBuf {
let s = match path.to_str() {
Some(s) => s,
None => return path.to_path_buf(),
};
if let Some(rest) = s.strip_prefix("~/") {
if let Some(home) = home {
return home.join(rest);
}
} else if s == "~" {
if let Some(home) = home {
return home.to_path_buf();
}
}
path.to_path_buf()
}
#[cfg(test)]
mod expand_path_tests {
use super::expand_path_with;
use std::path::{Path, PathBuf};
#[test]
fn expand_path_with_home_tilde_slash() {
assert_eq!(
expand_path_with(
Path::new("~/data/tga.db"),
Some(Path::new("/home/testuser"))
),
PathBuf::from("/home/testuser/data/tga.db")
);
}
#[test]
fn expand_path_with_home_bare_tilde() {
assert_eq!(
expand_path_with(Path::new("~"), Some(Path::new("/home/testuser"))),
PathBuf::from("/home/testuser")
);
}
#[test]
fn expand_path_with_home_passthrough() {
let home = Some(Path::new("/home/testuser"));
assert_eq!(
expand_path_with(Path::new("/var/data/tga.db"), home),
PathBuf::from("/var/data/tga.db")
);
assert_eq!(
expand_path_with(Path::new("data/tga.db"), home),
PathBuf::from("data/tga.db")
);
assert_eq!(
expand_path_with(Path::new("~user/data"), home),
PathBuf::from("~user/data"),
"only `~/` and a bare `~` expand; `~user` is not a home reference"
);
}
#[test]
fn expand_path_with_home_none() {
assert_eq!(
expand_path_with(Path::new("~/data/tga.db"), None),
PathBuf::from("~/data/tga.db")
);
assert_eq!(expand_path_with(Path::new("~"), None), PathBuf::from("~"));
}
}
impl Config {
pub fn load(path: &Path) -> Result<Config> {
let resolved = expand_path(path);
tracing::debug!(path = %resolved.display(), "loading config");
let text = std::fs::read_to_string(&resolved)?;
let mut cfg: Config = serde_yaml::from_str(&text)?;
cfg.source_path = Some(resolved);
cfg.validate_ticket_regexes()?;
Ok(cfg)
}
fn validate_ticket_regexes(&self) -> Result<()> {
fn check(section: &str, pat: &Option<String>) -> Result<()> {
if let Some(p) = pat {
regex::Regex::new(p).map_err(|e| {
TgaError::ConfigError(format!(
"{section}.ticket_regex is not a valid regular expression: {e}"
))
})?;
}
Ok(())
}
if let Some(jira) = &self.jira {
check("jira", &jira.ticket_regex)?;
}
if let Some(gh) = &self.github {
check("github", &gh.ticket_regex)?;
}
if let Some(linear) = &self.linear {
check("linear", &linear.ticket_regex)?;
}
if let Some(adc) = self.azure_devops_config() {
check("pm.azure_devops", &Some(adc.ticket_regex.clone()))?;
}
if let Some(dora) = &self.dora {
check(
"dora.deployment_tag_pattern",
&Some(dora.deployment_tag_pattern.clone()),
)?;
for (i, sig) in dora.failure_signals.iter().enumerate() {
let label = format!("dora.failure_signals[{i}].commit_message_pattern");
check(&label, &sig.commit_message_pattern)?;
}
}
Ok(())
}
pub fn config_dir(&self) -> Option<&Path> {
self.source_path.as_deref().and_then(|p| p.parent())
}
pub fn resolved_aliases(&self) -> HashMap<String, Vec<String>> {
match self.resolved_alias_map(self.config_dir()) {
Ok(map) if !map.is_empty() => map,
_ => {
if let Some(team) = &self.team {
team.members
.iter()
.map(|m| (m.name.clone(), team_member_alias_list(m)))
.collect()
} else {
HashMap::new()
}
}
}
}
pub fn resolved_alias_map(
&self,
config_dir: Option<&Path>,
) -> Result<HashMap<String, Vec<String>>> {
let mut merged = self.developer_aliases.clone();
if let Some(rel) = &self.aliases_file {
let expanded = expand_path(Path::new(rel));
let resolved = if expanded.is_absolute() {
expanded
} else if let Some(dir) = config_dir {
dir.join(expanded)
} else {
expanded
};
let external = AliasFile::load(&resolved).map_err(|e| {
TgaError::ConfigError(format!(
"failed to load aliases_file {}: {e}",
resolved.display()
))
})?;
for (name, list) in external.to_alias_map() {
merged.insert(name, list);
}
}
Ok(merged)
}
pub fn azure_devops_config(&self) -> Option<&AzureDevOpsConfig> {
self.pm.as_ref().and_then(|p| p.azure_devops.as_ref())
}
pub fn resolved_database_path(&self) -> Option<PathBuf> {
database_path::resolve(self.database.as_deref(), self.config_dir())
}
pub fn validate(&self) -> Result<()> {
if self.repositories.is_empty() {
return Err(TgaError::ValidationError(
"at least one repository must be configured".into(),
));
}
for r in &self.repositories {
if r.path.as_os_str().is_empty() {
return Err(TgaError::ValidationError(
"repository.path must not be empty".into(),
));
}
}
if let Some(adzo_config) = self.azure_devops_config() {
adzo_config.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn azdo_cfg_with_regex(pat: &str) -> AzureDevOpsConfig {
AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "secret".into(),
project: Some("MyProject".into()),
projects: vec![],
ticket_regex: pat.into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}
}
fn cfg_with_ado_regex(pat: &str) -> Config {
Config {
pm: Some(PmConfig {
azure_devops: Some(azdo_cfg_with_regex(pat)),
}),
..Config::default()
}
}
#[test]
fn validate_ticket_regexes_accepts_valid_ado_pattern() {
cfg_with_ado_regex(r"#(\d{4,8})\b")
.validate_ticket_regexes()
.expect("valid ADO regex accepted");
}
#[test]
fn validate_ticket_regexes_rejects_bad_ado_pattern() {
let err = cfg_with_ado_regex("[unclosed")
.validate_ticket_regexes()
.expect_err("malformed ADO ticket_regex must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("pm.azure_devops"),
"error should name the section: {msg}"
);
assert!(
msg.contains("ticket_regex"),
"error should name the field: {msg}"
);
}
#[test]
fn classification_config_unknown_field_is_rejected() {
let yaml = "rules_path: ./my-rules.yaml\nuse_llm: false\n";
let result: std::result::Result<ClassificationConfig, serde_yaml::Error> =
serde_yaml::from_str(yaml);
assert!(
result.is_err(),
"ClassificationConfig with unknown `rules_path:` must be rejected"
);
}
#[test]
fn llm_config_parses_from_yaml() {
let yaml = "llm:\n source: openrouter\n api_key_env: MY_KEY\n model: gpt-4o-mini\n";
let cfg: Config = serde_yaml::from_str(yaml).expect("parse config");
let llm = cfg.llm.expect("llm section");
assert_eq!(llm.source, LlmSource::Openrouter);
assert_eq!(llm.api_key_env, "MY_KEY");
assert_eq!(llm.model.as_deref(), Some("gpt-4o-mini"));
assert!(llm.region.is_none());
}
#[test]
fn llm_source_bedrock_parses() {
let yaml = "source: bedrock\napi_key_env: IGNORED\nregion: us-west-2\n";
let llm: LlmConfig = serde_yaml::from_str(yaml).expect("parse llm config");
assert_eq!(llm.source, LlmSource::Bedrock);
assert_eq!(llm.region.as_deref(), Some("us-west-2"));
}
#[test]
fn llm_source_defaults_to_openrouter() {
let llm = LlmConfig::default();
assert_eq!(llm.source, LlmSource::Openrouter);
assert_eq!(llm.api_key_env, "OPENROUTER_API_KEY");
}
#[test]
fn dora_default_matches_empty_dora_block() {
let yaml = "version: \"1.0\"\nrepositories: []\ndora: {}\n";
let cfg: Config = serde_yaml::from_str(yaml).expect("parse config");
let parsed = cfg.dora.expect("dora block present");
let defaulted = DoraConfig::default();
assert_eq!(parsed.deployment_source, "git_tags");
assert_eq!(defaulted.deployment_source, parsed.deployment_source);
assert_eq!(
defaulted.deployment_tag_pattern,
parsed.deployment_tag_pattern
);
assert_eq!(defaulted.production_branch, parsed.production_branch);
assert_eq!(defaulted.deployment_workflow, parsed.deployment_workflow);
assert_eq!(
defaulted.failure_signals.len(),
parsed.failure_signals.len()
);
assert_eq!(defaulted.datadog_dir, parsed.datadog_dir);
}
#[test]
fn llm_source_anthropic_api_parses() {
let yaml = "source: anthropic-api\n";
let llm: LlmConfig = serde_yaml::from_str(yaml).expect("parse llm config");
assert_eq!(llm.source, LlmSource::AnthropicApi);
}
#[test]
fn rules_files_single_string_back_compat() {
let yaml = "rules_file: ./my-rules.yaml\nuse_llm: false\n";
let cfg: ClassificationConfig =
serde_yaml::from_str(yaml).expect("parse classification config");
assert_eq!(cfg.rules_files.len(), 1);
assert_eq!(
cfg.rules_files[0],
std::path::PathBuf::from("./my-rules.yaml")
);
}
#[test]
fn rules_files_list_parses() {
let yaml = "rules_files:\n - ~/base-rules.yaml\n - ./project.yaml\n";
let cfg: ClassificationConfig =
serde_yaml::from_str(yaml).expect("parse classification config");
assert_eq!(cfg.rules_files.len(), 2);
assert_eq!(
cfg.rules_files[0],
std::path::PathBuf::from("~/base-rules.yaml")
);
assert_eq!(
cfg.rules_files[1],
std::path::PathBuf::from("./project.yaml")
);
}
#[test]
fn repo_categories_parses() {
let yaml =
"repo_categories:\n infra-api: platform_infrastructure\n data-pipeline: data_engineering\n";
let cfg: ClassificationConfig =
serde_yaml::from_str(yaml).expect("parse classification config");
assert_eq!(
cfg.repo_categories.get("infra-api").map(|s| s.as_str()),
Some("platform_infrastructure")
);
assert_eq!(
cfg.repo_categories.get("data-pipeline").map(|s| s.as_str()),
Some("data_engineering")
);
}
#[test]
fn classification_config_default_has_empty_new_fields() {
let cfg = ClassificationConfig::default();
assert!(
cfg.rules_files.is_empty(),
"rules_files defaults to empty vec"
);
assert!(
cfg.repo_categories.is_empty(),
"repo_categories defaults to empty map"
);
}
#[test]
fn github_config_serde_orgs_list() {
let yaml = "token: ghp_test\norgs:\n - duettoresearch\n - hotstats\nfetch_prs: true\n";
let cfg: GithubConfig = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(
cfg.orgs,
vec!["duettoresearch".to_string(), "hotstats".to_string()]
);
assert!(cfg.org.is_none(), "org (singular) must be None");
}
#[test]
fn github_config_serde_back_compat_singular_org() {
let yaml = "token: ghp_x\norg: acme\nfetch_prs: true\n";
let cfg: GithubConfig = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(cfg.org.as_deref(), Some("acme"));
assert!(cfg.orgs.is_empty(), "orgs must default to empty");
}
#[test]
fn github_config_fetch_pr_reviews_defaults_to_true() {
let yaml = "fetch_prs: true\n";
let cfg: GithubConfig = serde_yaml::from_str(yaml).expect("parse");
assert!(
cfg.fetch_pr_reviews,
"fetch_pr_reviews must default to true"
);
assert_eq!(cfg.review_fetch_concurrency, 1);
}
#[test]
fn github_config_fetch_pr_reviews_can_be_disabled() {
let yaml = "fetch_prs: true\nfetch_pr_reviews: false\n";
let cfg: GithubConfig = serde_yaml::from_str(yaml).expect("parse");
assert!(!cfg.fetch_pr_reviews);
}
#[test]
fn github_config_review_fetch_concurrency_default() {
let cfg: GithubConfig = serde_yaml::from_str("fetch_prs: true\n").expect("parse");
assert_eq!(cfg.review_fetch_concurrency, 1);
}
#[test]
fn resolved_aliases_seeds_primary_email_for_non_aliased_members() {
let cfg = Config {
team: Some(TeamConfig {
members: vec![
TeamMember {
name: "Alice Alpha".into(),
email: "alice@example.com".into(),
aliases: vec![],
},
TeamMember {
name: "Bob Beta".into(),
email: "bob@example.com".into(),
aliases: vec![],
},
],
aliases: HashMap::new(),
canonical_domain: None,
}),
..Config::default()
};
let resolved = cfg.resolved_aliases();
let alice_aliases = resolved
.get("Alice Alpha")
.expect("Alice Alpha present in resolved aliases");
let bob_aliases = resolved
.get("Bob Beta")
.expect("Bob Beta present in resolved aliases");
assert!(
alice_aliases.contains(&"alice@example.com".to_string()),
"Alice's primary email must be seeded as an alias: {alice_aliases:?}"
);
assert!(
bob_aliases.contains(&"bob@example.com".to_string()),
"Bob's primary email must be seeded as an alias: {bob_aliases:?}"
);
let resolver =
crate::collect::identity::resolver::IdentityResolver::from_alias_map(&resolved);
let (alice_name, alice_email) = resolver.resolve("whoever", "alice@example.com");
let (bob_name, bob_email) = resolver.resolve("whoever", "bob@example.com");
assert_eq!(alice_name, "Alice Alpha");
assert_eq!(bob_name, "Bob Beta");
assert!(
!alice_email.is_empty(),
"Alice's canonical email must not be empty"
);
assert!(
!bob_email.is_empty(),
"Bob's canonical email must not be empty"
);
assert_ne!(
alice_email, bob_email,
"distinct members must not collide on the same canonical_email"
);
}
}