use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::classify::taxonomy::SubcategoryDef;
use crate::core::errors::{Result, TgaError};
pub mod aliases;
pub mod azdo;
pub mod validator;
pub use aliases::{AliasFile, DeveloperAliasEntry};
pub use azdo::AzureDevOpsConfig;
pub use validator::{ConfigError, ConfigValidator};
#[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 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 analysis: Option<AnalysisConfig>,
#[serde(default)]
pub cache: Option<CacheConfig>,
#[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>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamConfig {
#[serde(default)]
pub members: Vec<TeamMember>,
#[serde(default)]
pub aliases: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamMember {
pub name: String,
pub email: String,
#[serde(default)]
pub aliases: Vec<String>,
}
#[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(Debug, Clone, Serialize, Deserialize)]
pub struct ClassificationConfig {
#[serde(default)]
pub rules_file: Option<PathBuf>,
#[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)]
pub llm_fallback_threshold: f64,
#[serde(default = "default_llm_fallback_concurrency")]
pub llm_fallback_concurrency: usize,
}
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
}
impl Default for ClassificationConfig {
fn default() -> Self {
Self {
rules_file: None,
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: 0.0,
llm_fallback_concurrency: default_llm_fallback_concurrency(),
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, 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, Default, Serialize, Deserialize)]
pub struct GithubConfig {
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub org: Option<String>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub fetch_prs: bool,
#[serde(default)]
pub ticket_regex: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, 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(Debug, Clone, Default, Serialize, Deserialize)]
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 jira_project_mappings: HashMap<String, String>,
#[serde(default)]
pub ticket_regex: Option<String>,
}
pub fn expand_path(path: &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) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
} else if s == "~" {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
}
path.to_path_buf()
}
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)?;
}
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(), m.aliases.clone()))
.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 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(())
}
}