use std::path::Path;
use super::{expand_path, BitbucketConfig, ClassificationConfig, Config, GithubConfig};
#[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,
},
}
fn github_token_missing(gh: &GithubConfig, env_token: Option<&str>) -> bool {
if !gh.fetch_prs {
return false;
}
let non_empty = |s: &str| !s.trim().is_empty();
!gh.token.as_deref().is_some_and(non_empty) && !env_token.is_some_and(non_empty)
}
fn present(s: Option<&str>) -> bool {
s.is_some_and(|v| !v.trim().is_empty())
}
fn bitbucket_auth_missing(
bb: &BitbucketConfig,
token_env: Option<&str>,
pwd_env: Option<&str>,
) -> bool {
if !bb.fetch_prs {
return false;
}
let has_token = present(bb.token.as_deref()) || present(token_env);
let has_basic = present(bb.username.as_deref())
&& (present(bb.app_password.as_deref()) || present(pwd_env));
!has_token && !has_basic
}
fn llm_env_keys(provider: &str) -> Option<&'static [&'static str]> {
match provider {
"openrouter" => Some(&["OPENROUTER_API_KEY"]),
"openai" => Some(&["OPENAI_API_KEY"]),
"bedrock" => None,
_ => Some(&["OPENROUTER_API_KEY", "OPENAI_API_KEY"]),
}
}
fn llm_key_missing(
cls: &ClassificationConfig,
env_lookup: impl Fn(&str) -> Option<String>,
) -> Option<String> {
if !cls.use_llm {
return None;
}
let provider = cls.llm_provider.as_str();
let env_keys = llm_env_keys(provider)?;
let config_key = match provider {
"openai" => None,
_ => cls.openrouter_api_key.as_deref(),
};
if present(config_key) {
return None;
}
if env_keys.iter().any(|k| present(env_lookup(k).as_deref())) {
return None;
}
Some(provider.to_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;
};
let env_token = std::env::var(trusty_common::env_vars::ENV_GITHUB_TOKEN).ok();
if github_token_missing(gh, env_token.as_deref()) {
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;
}
let discovers = !crate::collect::bitbucket::effective_workspaces(&bb.workspaces).is_empty();
let named = |v: &Option<String>| v.as_deref().is_some_and(|s| !s.trim().is_empty());
if !discovers && !named(&bb.workspace) {
errors.push(ConfigError::IncompleteBitbucketConfig {
field: "workspace".into(),
});
}
if !discovers && !named(&bb.repo_slug) {
errors.push(ConfigError::IncompleteBitbucketConfig {
field: "repo_slug".into(),
});
}
let token_env = std::env::var("BITBUCKET_TOKEN").ok();
let pwd_env = std::env::var("BITBUCKET_APP_PASSWORD").ok();
if bitbucket_auth_missing(bb, token_env.as_deref(), pwd_env.as_deref()) {
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 let Some(provider) = llm_key_missing(cls, |k| std::env::var(k).ok()) {
errors.push(ConfigError::MissingLlmKey { provider });
}
}
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;