use std::path::Path;
use super::{expand_path, Config};
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Repository path does not exist: {path}")]
RepoNotFound {
path: String,
},
#[error("Output directory is not writable: {path}")]
OutputNotWritable {
path: String,
},
#[error("GitHub token required when fetch_prs = true")]
MissingGitHubToken,
#[error("Bitbucket config incomplete: {field} is required when fetch_prs = true")]
IncompleteBitbucketConfig {
field: String,
},
#[error(
"Bitbucket auth required when fetch_prs = true: \
supply either `token` (or BITBUCKET_TOKEN) or `username` + `app_password` \
(or BITBUCKET_APP_PASSWORD)"
)]
MissingBitbucketAuth,
#[error("JIRA config incomplete: {field} is required")]
IncompleteJiraConfig {
field: String,
},
#[error("LLM API key missing for provider '{provider}'")]
MissingLlmKey {
provider: String,
},
#[error("Conflicting config: {message}")]
Conflict {
message: String,
},
#[error("Invalid Azure DevOps config: {message}")]
InvalidAzureDevOpsConfig {
message: String,
},
}
pub struct ConfigValidator<'a> {
config: &'a Config,
}
impl<'a> ConfigValidator<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
pub fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
self.check_repositories(&mut errors);
self.check_output_dir(&mut errors);
self.check_github_token(&mut errors);
self.check_bitbucket_config(&mut errors);
self.check_jira_config(&mut errors);
self.check_azure_devops(&mut errors);
self.check_llm_config(&mut errors);
self.check_conflicting_flags(&mut errors);
errors
}
fn check_azure_devops(&self, errors: &mut Vec<ConfigError>) {
let Some(ado) = self.config.azure_devops_config() else {
return;
};
if let Err(e) = ado.validate() {
errors.push(ConfigError::InvalidAzureDevOpsConfig {
message: e.to_string(),
});
}
}
fn check_repositories(&self, errors: &mut Vec<ConfigError>) {
if self.config.repositories.is_empty() {
tracing::warn!("no repositories configured — `tga collect` will be a no-op");
return;
}
for repo in &self.config.repositories {
let expanded = expand_path(&repo.path);
if !expanded.exists() {
errors.push(ConfigError::RepoNotFound {
path: expanded.display().to_string(),
});
}
}
}
fn check_output_dir(&self, errors: &mut Vec<ConfigError>) {
let Some(output) = self.config.output.as_ref() else {
return;
};
let Some(dir) = output.directory.as_ref() else {
return;
};
let expanded = expand_path(dir);
if !is_dir_writable(&expanded) {
errors.push(ConfigError::OutputNotWritable {
path: expanded.display().to_string(),
});
}
}
fn check_github_token(&self, errors: &mut Vec<ConfigError>) {
let Some(gh) = self.config.github.as_ref() else {
return;
};
if gh.fetch_prs {
let token_present = gh
.token
.as_deref()
.map(|t| !t.trim().is_empty())
.unwrap_or(false);
let env_present = std::env::var("GITHUB_TOKEN")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if !token_present && !env_present {
errors.push(ConfigError::MissingGitHubToken);
}
}
}
fn check_bitbucket_config(&self, errors: &mut Vec<ConfigError>) {
let Some(bb) = self.config.bitbucket.as_ref() else {
return;
};
if !bb.fetch_prs {
return;
}
if bb
.workspace
.as_deref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
errors.push(ConfigError::IncompleteBitbucketConfig {
field: "workspace".into(),
});
}
if bb
.repo_slug
.as_deref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
errors.push(ConfigError::IncompleteBitbucketConfig {
field: "repo_slug".into(),
});
}
let nonempty = |o: Option<&str>| o.map(|s| !s.trim().is_empty()).unwrap_or(false);
let token_in_cfg = nonempty(bb.token.as_deref());
let token_in_env = std::env::var("BITBUCKET_TOKEN")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let user = nonempty(bb.username.as_deref());
let pwd_in_cfg = nonempty(bb.app_password.as_deref());
let pwd_in_env = std::env::var("BITBUCKET_APP_PASSWORD")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let has_token = token_in_cfg || token_in_env;
let has_basic = user && (pwd_in_cfg || pwd_in_env);
if !has_token && !has_basic {
errors.push(ConfigError::MissingBitbucketAuth);
}
}
fn check_jira_config(&self, errors: &mut Vec<ConfigError>) {
let Some(jira) = self.config.jira.as_ref() else {
return;
};
let url = jira.url.as_deref().unwrap_or("").trim();
let username = jira.username.as_deref().unwrap_or("").trim();
let token = jira.token.as_deref().unwrap_or("").trim();
let any = !url.is_empty() || !username.is_empty() || !token.is_empty();
if !any {
return;
}
if url.is_empty() {
errors.push(ConfigError::IncompleteJiraConfig {
field: "url".into(),
});
}
if username.is_empty() {
errors.push(ConfigError::IncompleteJiraConfig {
field: "username".into(),
});
}
if token.is_empty() {
errors.push(ConfigError::IncompleteJiraConfig {
field: "token".into(),
});
}
}
fn check_llm_config(&self, errors: &mut Vec<ConfigError>) {
let Some(cls) = self.config.classification.as_ref() else {
return;
};
if !cls.use_llm {
return;
}
let provider = cls.llm_provider.as_str();
let (config_key, env_keys): (Option<&str>, &[&str]) = match provider {
"openrouter" => (cls.openrouter_api_key.as_deref(), &["OPENROUTER_API_KEY"]),
"openai" => (None, &["OPENAI_API_KEY"]),
"bedrock" => return,
_ => (
cls.openrouter_api_key.as_deref(),
&["OPENROUTER_API_KEY", "OPENAI_API_KEY"],
),
};
let cfg_present = config_key.map(|k| !k.trim().is_empty()).unwrap_or(false);
let env_present = env_keys.iter().any(|k| {
std::env::var(k)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
});
if !cfg_present && !env_present {
errors.push(ConfigError::MissingLlmKey {
provider: provider.to_string(),
});
}
}
fn check_conflicting_flags(&self, errors: &mut Vec<ConfigError>) {
if let Some(cls) = self.config.classification.as_ref() {
if !(0.0..=1.0).contains(&cls.confidence_threshold) {
errors.push(ConfigError::Conflict {
message: format!(
"classification.confidence_threshold ({}) must be in [0.0, 1.0]",
cls.confidence_threshold
),
});
}
if !(0.0..=100.0).contains(&cls.min_coverage_pct) {
errors.push(ConfigError::Conflict {
message: format!(
"classification.min_coverage_pct ({}) must be in [0.0, 100.0]",
cls.min_coverage_pct
),
});
}
}
}
}
fn is_dir_writable(path: &Path) -> bool {
if !path.exists() {
return std::fs::create_dir_all(path).is_ok();
}
if !path.is_dir() {
return false;
}
let probe = path.join(".tga-write-probe");
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
#[cfg(test)]
#[path = "validator_tests.rs"]
mod tests;