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                ];
186                if !valid_types.contains(&provider.provider_type.as_str()) {
187                    result.add_warning(ConfigWarning {
188                        field: format!("{}.type", prefix),
189                        message: format!(
190                            "Unknown provider type: '{}'. Valid types: {:?}",
191                            provider.provider_type, valid_types
192                        ),
193                        code: ConfigWarningCode::UnusedSetting,
194                    });
195                }
196            }
197
198            if provider.token.is_empty() {
199                result.add_warning(ConfigWarning {
200                    field: format!("{}.token", prefix),
201                    message: format!(
202                        "Provider '{}' has no token configured. Will use secure storage lookup.",
203                        id
204                    ),
205                    code: ConfigWarningCode::NoTokenConfigured,
206                });
207            }
208
209            if provider.refresh_interval == 0 {
210                result.add_warning(ConfigWarning {
211                    field: format!("{}.refresh_interval", prefix),
212                    message: "Refresh interval of 0 means manual refresh only".to_string(),
213                    code: ConfigWarningCode::UnusedSetting,
214                });
215            }
216        }
217    }
218
219    fn validate_views(views: &IndexMap<String, ViewFileConfig>, result: &mut ValidationResult) {
220        for (key, view) in views {
221            let prefix = format!("views.{key}");
222
223            if key.is_empty() {
224                result.add_error(ConfigError {
225                    field: "views".to_string(),
226                    message: "View ID (table key) cannot be empty".to_string(),
227                    code: ConfigErrorCode::MissingRequired,
228                });
229            }
230
231            if view.name.trim().is_empty() {
232                result.add_error(ConfigError {
233                    field: format!("{prefix}.name"),
234                    message: "View name is required".to_string(),
235                    code: ConfigErrorCode::MissingRequired,
236                });
237            }
238
239            if view.providers.is_empty()
240                && view.repositories.is_empty()
241                && view
242                    .name_contains
243                    .as_deref()
244                    .is_none_or(|s| s.trim().is_empty())
245                && view.statuses.is_empty()
246            {
247                result.add_error(ConfigError {
248                    field: prefix.clone(),
249                    message: "Config view needs at least one of providers, repositories, name_contains or statuses".to_string(),
250                    code: ConfigErrorCode::MissingRequired,
251                });
252            }
253
254            for pattern in &view.repositories {
255                if let Err(e) = glob::Pattern::new(pattern.trim()) {
256                    result.add_error(ConfigError {
257                        field: format!("{prefix}.repositories"),
258                        message: format!("Invalid repository pattern \"{pattern}\": {e}"),
259                        code: ConfigErrorCode::InvalidValue,
260                    });
261                }
262            }
263        }
264    }
265}
266
267impl PipedashConfig {
268    pub fn validate(&self) -> ValidationResult {
269        ConfigValidator::validate(self)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::collections::HashMap;
276
277    use super::*;
278
279    #[test]
280    fn test_valid_config() {
281        let config = PipedashConfig::default();
282        let result = config.validate();
283        assert!(result.is_ok());
284    }
285
286    #[test]
287    fn test_postgres_missing_connection_string() {
288        let mut config = PipedashConfig::default();
289        config.storage.backend = StorageBackend::Postgres;
290
291        let result = config.validate();
292        assert!(!result.is_ok());
293        assert!(result
294            .errors
295            .iter()
296            .any(|e| e.field.contains("postgres.connection_string")));
297    }
298
299    #[test]
300    fn test_provider_missing_type() {
301        let mut config = PipedashConfig::default();
302        config.providers.insert(
303            "test".to_string(),
304            ProviderFileConfig {
305                name: None,
306                provider_type: String::new(),
307                token: String::new(),
308                refresh_interval: 30,
309                config: HashMap::new(),
310            },
311        );
312
313        let result = config.validate();
314        assert!(result
315            .errors
316            .iter()
317            .any(|e| e.field.contains(".type") && e.code == ConfigErrorCode::MissingRequired));
318    }
319
320    #[test]
321    fn validate_views_rejects_a_view_without_rules() {
322        let mut config = PipedashConfig::default();
323        config.views.insert(
324            "release".to_string(),
325            ViewFileConfig {
326                name: "Release".to_string(),
327                ..Default::default()
328            },
329        );
330        config.views.insert(
331            "broken".to_string(),
332            ViewFileConfig {
333                name: "Broken".to_string(),
334                repositories: vec!["acme/[".to_string()],
335                ..Default::default()
336            },
337        );
338
339        let result = config.validate();
340
341        assert!(result
342            .errors
343            .iter()
344            .any(|e| e.field == "views.release" && e.code == ConfigErrorCode::MissingRequired));
345        assert!(result
346            .errors
347            .iter()
348            .any(|e| e.field == "views.broken.repositories"
349                && e.code == ConfigErrorCode::InvalidValue));
350    }
351
352    #[test]
353    fn test_provider_any_token_format_allowed() {
354        let mut config = PipedashConfig::default();
355        config.providers.insert(
356            "test".to_string(),
357            ProviderFileConfig {
358                name: None,
359                provider_type: "github".to_string(),
360                token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
361                refresh_interval: 30,
362                config: HashMap::new(),
363            },
364        );
365
366        let result = config.validate();
367        assert!(!result
368            .errors
369            .iter()
370            .any(|e| e.code == ConfigErrorCode::PlainTextToken));
371    }
372
373    #[test]
374    fn test_provider_no_token_warning() {
375        let mut config = PipedashConfig::default();
376        config.providers.insert(
377            "test".to_string(),
378            ProviderFileConfig {
379                name: None,
380                provider_type: "github".to_string(),
381                token: String::new(),
382                refresh_interval: 30,
383                config: HashMap::new(),
384            },
385        );
386
387        let result = config.validate();
388        assert!(result
389            .warnings
390            .iter()
391            .any(|w| w.code == ConfigWarningCode::NoTokenConfigured));
392    }
393
394    #[test]
395    fn test_validation_summary() {
396        let result = ValidationResult::new();
397        assert_eq!(result.summary(), "Configuration is valid");
398
399        let mut result2 = ValidationResult::new();
400        result2.add_error(ConfigError {
401            field: "test".to_string(),
402            message: "test error".to_string(),
403            code: ConfigErrorCode::MissingRequired,
404        });
405        assert!(result2.summary().contains("1 error(s)"));
406    }
407}