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