Skip to main content

pipedash_core/infrastructure/config/
validation.rs

1use indexmap::IndexMap;
2
3use super::schema::{
4    PipedashConfig,
5    ProviderFileConfig,
6    StorageBackend,
7};
8
9#[derive(Debug, Default)]
10pub struct ValidationResult {
11    pub errors: Vec<ConfigError>,
12    pub warnings: Vec<ConfigWarning>,
13}
14
15impl ValidationResult {
16    pub fn new() -> Self {
17        Self::default()
18    }
19
20    pub fn is_ok(&self) -> bool {
21        self.errors.is_empty()
22    }
23
24    pub fn has_issues(&self) -> bool {
25        !self.errors.is_empty() || !self.warnings.is_empty()
26    }
27
28    pub fn add_error(&mut self, error: ConfigError) {
29        self.errors.push(error);
30    }
31
32    pub fn add_warning(&mut self, warning: ConfigWarning) {
33        self.warnings.push(warning);
34    }
35
36    pub fn summary(&self) -> String {
37        if self.errors.is_empty() && self.warnings.is_empty() {
38            "Configuration is valid".to_string()
39        } else {
40            format!(
41                "{} error(s), {} warning(s)",
42                self.errors.len(),
43                self.warnings.len()
44            )
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct ConfigError {
51    pub field: String,
52    pub message: String,
53    pub code: ConfigErrorCode,
54}
55
56impl std::fmt::Display for ConfigError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}: {} ({})", self.field, self.message, self.code)
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ConfigErrorCode {
64    MissingRequired,
65    InvalidValue,
66    PlainTextToken,
67    BackendMismatch,
68    FeatureNotEnabled,
69}
70
71impl std::fmt::Display for ConfigErrorCode {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::MissingRequired => write!(f, "MISSING_REQUIRED"),
75            Self::InvalidValue => write!(f, "INVALID_VALUE"),
76            Self::PlainTextToken => write!(f, "PLAIN_TEXT_TOKEN"),
77            Self::BackendMismatch => write!(f, "BACKEND_MISMATCH"),
78            Self::FeatureNotEnabled => write!(f, "FEATURE_NOT_ENABLED"),
79        }
80    }
81}
82
83#[derive(Debug, Clone)]
84pub struct ConfigWarning {
85    pub field: String,
86    pub message: String,
87    pub code: ConfigWarningCode,
88}
89
90impl std::fmt::Display for ConfigWarning {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}: {} ({})", self.field, self.message, self.code)
93    }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ConfigWarningCode {
98    NoTokenConfigured,
99    InsecureDefault,
100    Deprecated,
101    UnusedSetting,
102}
103
104impl std::fmt::Display for ConfigWarningCode {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::NoTokenConfigured => write!(f, "NO_TOKEN"),
108            Self::InsecureDefault => write!(f, "INSECURE_DEFAULT"),
109            Self::Deprecated => write!(f, "DEPRECATED"),
110            Self::UnusedSetting => write!(f, "UNUSED"),
111        }
112    }
113}
114
115pub struct ConfigValidator;
116
117impl ConfigValidator {
118    pub fn validate(config: &PipedashConfig) -> ValidationResult {
119        let mut result = ValidationResult::new();
120
121        Self::validate_storage(&config.storage, &mut result);
122        Self::validate_providers(&config.providers, &mut result);
123
124        result
125    }
126
127    fn validate_storage(storage: &super::schema::StorageConfig, result: &mut ValidationResult) {
128        if storage.backend == StorageBackend::Postgres
129            && storage.postgres.connection_string.is_empty()
130        {
131            result.add_error(ConfigError {
132                field: "storage.postgres.connection_string".to_string(),
133                message: "PostgreSQL connection string is required when backend = 'postgres'"
134                    .to_string(),
135                code: ConfigErrorCode::MissingRequired,
136            });
137        }
138
139        #[cfg(not(feature = "postgres"))]
140        if storage.backend == StorageBackend::Postgres {
141            result.add_error(ConfigError {
142                field: "storage.backend".to_string(),
143                message: "PostgreSQL feature is not enabled. Compile with --features postgres"
144                    .to_string(),
145                code: ConfigErrorCode::FeatureNotEnabled,
146            });
147        }
148    }
149
150    fn validate_providers(
151        providers: &IndexMap<String, ProviderFileConfig>, result: &mut ValidationResult,
152    ) {
153        for (id, provider) in providers {
154            let prefix = format!("providers.{}", id);
155
156            if id.is_empty() {
157                result.add_error(ConfigError {
158                    field: "providers".to_string(),
159                    message: "Provider ID (table key) cannot be empty".to_string(),
160                    code: ConfigErrorCode::MissingRequired,
161                });
162            }
163
164            if provider.provider_type.is_empty() {
165                result.add_error(ConfigError {
166                    field: format!("{}.type", prefix),
167                    message: "Provider type is required".to_string(),
168                    code: ConfigErrorCode::MissingRequired,
169                });
170            } else {
171                let valid_types = [
172                    "github",
173                    "gitea",
174                    "gitlab",
175                    "bitbucket",
176                    "buildkite",
177                    "jenkins",
178                    "tekton",
179                    "argocd",
180                    "fluxcd",
181                ];
182                if !valid_types.contains(&provider.provider_type.as_str()) {
183                    result.add_warning(ConfigWarning {
184                        field: format!("{}.type", prefix),
185                        message: format!(
186                            "Unknown provider type: '{}'. Valid types: {:?}",
187                            provider.provider_type, valid_types
188                        ),
189                        code: ConfigWarningCode::UnusedSetting,
190                    });
191                }
192            }
193
194            if provider.token.is_empty() {
195                result.add_warning(ConfigWarning {
196                    field: format!("{}.token", prefix),
197                    message: format!(
198                        "Provider '{}' has no token configured. Will use secure storage lookup.",
199                        id
200                    ),
201                    code: ConfigWarningCode::NoTokenConfigured,
202                });
203            }
204
205            if provider.refresh_interval == 0 {
206                result.add_warning(ConfigWarning {
207                    field: format!("{}.refresh_interval", prefix),
208                    message: "Refresh interval of 0 means manual refresh only".to_string(),
209                    code: ConfigWarningCode::UnusedSetting,
210                });
211            }
212        }
213    }
214}
215
216impl PipedashConfig {
217    pub fn validate(&self) -> ValidationResult {
218        ConfigValidator::validate(self)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use std::collections::HashMap;
225
226    use super::*;
227
228    #[test]
229    fn test_valid_config() {
230        let config = PipedashConfig::default();
231        let result = config.validate();
232        assert!(result.is_ok());
233    }
234
235    #[test]
236    fn test_postgres_missing_connection_string() {
237        let mut config = PipedashConfig::default();
238        config.storage.backend = StorageBackend::Postgres;
239
240        let result = config.validate();
241        assert!(!result.is_ok());
242        assert!(result
243            .errors
244            .iter()
245            .any(|e| e.field.contains("postgres.connection_string")));
246    }
247
248    #[test]
249    fn test_provider_missing_type() {
250        let mut config = PipedashConfig::default();
251        config.providers.insert(
252            "test".to_string(),
253            ProviderFileConfig {
254                name: None,
255                provider_type: String::new(),
256                token: String::new(),
257                refresh_interval: 30,
258                config: HashMap::new(),
259            },
260        );
261
262        let result = config.validate();
263        assert!(result
264            .errors
265            .iter()
266            .any(|e| e.field.contains(".type") && e.code == ConfigErrorCode::MissingRequired));
267    }
268
269    #[test]
270    fn test_provider_any_token_format_allowed() {
271        let mut config = PipedashConfig::default();
272        config.providers.insert(
273            "test".to_string(),
274            ProviderFileConfig {
275                name: None,
276                provider_type: "github".to_string(),
277                token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
278                refresh_interval: 30,
279                config: HashMap::new(),
280            },
281        );
282
283        let result = config.validate();
284        assert!(!result
285            .errors
286            .iter()
287            .any(|e| e.code == ConfigErrorCode::PlainTextToken));
288    }
289
290    #[test]
291    fn test_provider_no_token_warning() {
292        let mut config = PipedashConfig::default();
293        config.providers.insert(
294            "test".to_string(),
295            ProviderFileConfig {
296                name: None,
297                provider_type: "github".to_string(),
298                token: String::new(),
299                refresh_interval: 30,
300                config: HashMap::new(),
301            },
302        );
303
304        let result = config.validate();
305        assert!(result
306            .warnings
307            .iter()
308            .any(|w| w.code == ConfigWarningCode::NoTokenConfigured));
309    }
310
311    #[test]
312    fn test_validation_summary() {
313        let result = ValidationResult::new();
314        assert_eq!(result.summary(), "Configuration is valid");
315
316        let mut result2 = ValidationResult::new();
317        result2.add_error(ConfigError {
318            field: "test".to_string(),
319            message: "test error".to_string(),
320            code: ConfigErrorCode::MissingRequired,
321        });
322        assert!(result2.summary().contains("1 error(s)"));
323    }
324}