Skip to main content

pipedash_core/infrastructure/config/
validation.rs

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