use indexmap::IndexMap;
use super::schema::{
PipedashConfig,
ProviderFileConfig,
StorageBackend,
ViewFileConfig,
};
#[derive(Debug, Default)]
pub struct ValidationResult {
pub errors: Vec<ConfigError>,
pub warnings: Vec<ConfigWarning>,
}
impl ValidationResult {
pub fn new() -> Self {
Self::default()
}
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
pub fn has_issues(&self) -> bool {
!self.errors.is_empty() || !self.warnings.is_empty()
}
pub fn add_error(&mut self, error: ConfigError) {
self.errors.push(error);
}
pub fn add_warning(&mut self, warning: ConfigWarning) {
self.warnings.push(warning);
}
pub fn summary(&self) -> String {
if self.errors.is_empty() && self.warnings.is_empty() {
"Configuration is valid".to_string()
} else {
format!(
"{} error(s), {} warning(s)",
self.errors.len(),
self.warnings.len()
)
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub field: String,
pub message: String,
pub code: ConfigErrorCode,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {} ({})", self.field, self.message, self.code)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigErrorCode {
MissingRequired,
InvalidValue,
PlainTextToken,
BackendMismatch,
FeatureNotEnabled,
}
impl std::fmt::Display for ConfigErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingRequired => write!(f, "MISSING_REQUIRED"),
Self::InvalidValue => write!(f, "INVALID_VALUE"),
Self::PlainTextToken => write!(f, "PLAIN_TEXT_TOKEN"),
Self::BackendMismatch => write!(f, "BACKEND_MISMATCH"),
Self::FeatureNotEnabled => write!(f, "FEATURE_NOT_ENABLED"),
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigWarning {
pub field: String,
pub message: String,
pub code: ConfigWarningCode,
}
impl std::fmt::Display for ConfigWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {} ({})", self.field, self.message, self.code)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigWarningCode {
NoTokenConfigured,
InsecureDefault,
Deprecated,
UnusedSetting,
}
impl std::fmt::Display for ConfigWarningCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoTokenConfigured => write!(f, "NO_TOKEN"),
Self::InsecureDefault => write!(f, "INSECURE_DEFAULT"),
Self::Deprecated => write!(f, "DEPRECATED"),
Self::UnusedSetting => write!(f, "UNUSED"),
}
}
}
pub struct ConfigValidator;
impl ConfigValidator {
pub fn validate(config: &PipedashConfig) -> ValidationResult {
let mut result = ValidationResult::new();
Self::validate_storage(&config.storage, &mut result);
Self::validate_providers(&config.providers, &mut result);
Self::validate_views(&config.views, &mut result);
result
}
fn validate_storage(storage: &super::schema::StorageConfig, result: &mut ValidationResult) {
if storage.backend == StorageBackend::Postgres
&& storage.postgres.connection_string.is_empty()
{
result.add_error(ConfigError {
field: "storage.postgres.connection_string".to_string(),
message: "PostgreSQL connection string is required when backend = 'postgres'"
.to_string(),
code: ConfigErrorCode::MissingRequired,
});
}
#[cfg(not(feature = "postgres"))]
if storage.backend == StorageBackend::Postgres {
result.add_error(ConfigError {
field: "storage.backend".to_string(),
message: "PostgreSQL feature is not enabled. Compile with --features postgres"
.to_string(),
code: ConfigErrorCode::FeatureNotEnabled,
});
}
}
fn validate_providers(
providers: &IndexMap<String, ProviderFileConfig>, result: &mut ValidationResult,
) {
for (id, provider) in providers {
let prefix = format!("providers.{}", id);
if id.is_empty() {
result.add_error(ConfigError {
field: "providers".to_string(),
message: "Provider ID (table key) cannot be empty".to_string(),
code: ConfigErrorCode::MissingRequired,
});
}
if provider.provider_type.is_empty() {
result.add_error(ConfigError {
field: format!("{}.type", prefix),
message: "Provider type is required".to_string(),
code: ConfigErrorCode::MissingRequired,
});
} else {
let valid_types = [
"github",
"gitea",
"gitlab",
"bitbucket",
"buildkite",
"azure-devops",
"jenkins",
"tekton",
"argocd",
"fluxcd",
"komodo",
];
if !valid_types.contains(&provider.provider_type.as_str()) {
result.add_warning(ConfigWarning {
field: format!("{}.type", prefix),
message: format!(
"Unknown provider type: '{}'. Valid types: {:?}",
provider.provider_type, valid_types
),
code: ConfigWarningCode::UnusedSetting,
});
}
}
if provider.token.is_empty() {
result.add_warning(ConfigWarning {
field: format!("{}.token", prefix),
message: format!(
"Provider '{}' has no token configured. Will use secure storage lookup.",
id
),
code: ConfigWarningCode::NoTokenConfigured,
});
}
if provider.refresh_interval == 0 {
result.add_warning(ConfigWarning {
field: format!("{}.refresh_interval", prefix),
message: "Refresh interval of 0 means manual refresh only".to_string(),
code: ConfigWarningCode::UnusedSetting,
});
}
}
}
fn validate_views(views: &IndexMap<String, ViewFileConfig>, result: &mut ValidationResult) {
for (key, view) in views {
let prefix = format!("views.{key}");
if key.is_empty() {
result.add_error(ConfigError {
field: "views".to_string(),
message: "View ID (table key) cannot be empty".to_string(),
code: ConfigErrorCode::MissingRequired,
});
}
if view.name.trim().is_empty() {
result.add_error(ConfigError {
field: format!("{prefix}.name"),
message: "View name is required".to_string(),
code: ConfigErrorCode::MissingRequired,
});
}
if view.providers.is_empty()
&& view.repositories.is_empty()
&& view
.name_contains
.as_deref()
.is_none_or(|s| s.trim().is_empty())
&& view.statuses.is_empty()
{
result.add_error(ConfigError {
field: prefix.clone(),
message: "Config view needs at least one of providers, repositories, name_contains or statuses".to_string(),
code: ConfigErrorCode::MissingRequired,
});
}
for pattern in &view.repositories {
if let Err(e) = glob::Pattern::new(pattern.trim()) {
result.add_error(ConfigError {
field: format!("{prefix}.repositories"),
message: format!("Invalid repository pattern \"{pattern}\": {e}"),
code: ConfigErrorCode::InvalidValue,
});
}
}
}
}
}
impl PipedashConfig {
pub fn validate(&self) -> ValidationResult {
ConfigValidator::validate(self)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn test_valid_config() {
let config = PipedashConfig::default();
let result = config.validate();
assert!(result.is_ok());
}
#[test]
fn test_postgres_missing_connection_string() {
let mut config = PipedashConfig::default();
config.storage.backend = StorageBackend::Postgres;
let result = config.validate();
assert!(!result.is_ok());
assert!(result
.errors
.iter()
.any(|e| e.field.contains("postgres.connection_string")));
}
#[test]
fn test_provider_missing_type() {
let mut config = PipedashConfig::default();
config.providers.insert(
"test".to_string(),
ProviderFileConfig {
name: None,
provider_type: String::new(),
token: String::new(),
refresh_interval: 30,
config: HashMap::new(),
},
);
let result = config.validate();
assert!(result
.errors
.iter()
.any(|e| e.field.contains(".type") && e.code == ConfigErrorCode::MissingRequired));
}
#[test]
fn validate_views_rejects_a_view_without_rules() {
let mut config = PipedashConfig::default();
config.views.insert(
"release".to_string(),
ViewFileConfig {
name: "Release".to_string(),
..Default::default()
},
);
config.views.insert(
"broken".to_string(),
ViewFileConfig {
name: "Broken".to_string(),
repositories: vec!["acme/[".to_string()],
..Default::default()
},
);
let result = config.validate();
assert!(result
.errors
.iter()
.any(|e| e.field == "views.release" && e.code == ConfigErrorCode::MissingRequired));
assert!(result
.errors
.iter()
.any(|e| e.field == "views.broken.repositories"
&& e.code == ConfigErrorCode::InvalidValue));
}
#[test]
fn test_provider_any_token_format_allowed() {
let mut config = PipedashConfig::default();
config.providers.insert(
"test".to_string(),
ProviderFileConfig {
name: None,
provider_type: "github".to_string(),
token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
refresh_interval: 30,
config: HashMap::new(),
},
);
let result = config.validate();
assert!(!result
.errors
.iter()
.any(|e| e.code == ConfigErrorCode::PlainTextToken));
}
#[test]
fn test_provider_no_token_warning() {
let mut config = PipedashConfig::default();
config.providers.insert(
"test".to_string(),
ProviderFileConfig {
name: None,
provider_type: "github".to_string(),
token: String::new(),
refresh_interval: 30,
config: HashMap::new(),
},
);
let result = config.validate();
assert!(result
.warnings
.iter()
.any(|w| w.code == ConfigWarningCode::NoTokenConfigured));
}
#[test]
fn test_validation_summary() {
let result = ValidationResult::new();
assert_eq!(result.summary(), "Configuration is valid");
let mut result2 = ValidationResult::new();
result2.add_error(ConfigError {
field: "test".to_string(),
message: "test error".to_string(),
code: ConfigErrorCode::MissingRequired,
});
assert!(result2.summary().contains("1 error(s)"));
}
}