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                    "gitlab",
174                    "bitbucket",
175                    "buildkite",
176                    "jenkins",
177                    "tekton",
178                    "argocd",
179                ];
180                if !valid_types.contains(&provider.provider_type.as_str()) {
181                    result.add_warning(ConfigWarning {
182                        field: format!("{}.type", prefix),
183                        message: format!(
184                            "Unknown provider type: '{}'. Valid types: {:?}",
185                            provider.provider_type, valid_types
186                        ),
187                        code: ConfigWarningCode::UnusedSetting,
188                    });
189                }
190            }
191
192            if provider.token.is_empty() {
193                result.add_warning(ConfigWarning {
194                    field: format!("{}.token", prefix),
195                    message: format!(
196                        "Provider '{}' has no token configured. Will use secure storage lookup.",
197                        id
198                    ),
199                    code: ConfigWarningCode::NoTokenConfigured,
200                });
201            }
202
203            if provider.refresh_interval == 0 {
204                result.add_warning(ConfigWarning {
205                    field: format!("{}.refresh_interval", prefix),
206                    message: "Refresh interval of 0 means manual refresh only".to_string(),
207                    code: ConfigWarningCode::UnusedSetting,
208                });
209            }
210        }
211    }
212}
213
214impl PipedashConfig {
215    pub fn validate(&self) -> ValidationResult {
216        ConfigValidator::validate(self)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::collections::HashMap;
223
224    use super::*;
225
226    #[test]
227    fn test_valid_config() {
228        let config = PipedashConfig::default();
229        let result = config.validate();
230        assert!(result.is_ok());
231    }
232
233    #[test]
234    fn test_postgres_missing_connection_string() {
235        let mut config = PipedashConfig::default();
236        config.storage.backend = StorageBackend::Postgres;
237
238        let result = config.validate();
239        assert!(!result.is_ok());
240        assert!(result
241            .errors
242            .iter()
243            .any(|e| e.field.contains("postgres.connection_string")));
244    }
245
246    #[test]
247    fn test_provider_missing_type() {
248        let mut config = PipedashConfig::default();
249        config.providers.insert(
250            "test".to_string(),
251            ProviderFileConfig {
252                name: None,
253                provider_type: String::new(),
254                token: String::new(),
255                refresh_interval: 30,
256                config: HashMap::new(),
257            },
258        );
259
260        let result = config.validate();
261        assert!(result
262            .errors
263            .iter()
264            .any(|e| e.field.contains(".type") && e.code == ConfigErrorCode::MissingRequired));
265    }
266
267    #[test]
268    fn test_provider_any_token_format_allowed() {
269        let mut config = PipedashConfig::default();
270        config.providers.insert(
271            "test".to_string(),
272            ProviderFileConfig {
273                name: None,
274                provider_type: "github".to_string(),
275                token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
276                refresh_interval: 30,
277                config: HashMap::new(),
278            },
279        );
280
281        let result = config.validate();
282        assert!(!result
283            .errors
284            .iter()
285            .any(|e| e.code == ConfigErrorCode::PlainTextToken));
286    }
287
288    #[test]
289    fn test_provider_no_token_warning() {
290        let mut config = PipedashConfig::default();
291        config.providers.insert(
292            "test".to_string(),
293            ProviderFileConfig {
294                name: None,
295                provider_type: "github".to_string(),
296                token: String::new(),
297                refresh_interval: 30,
298                config: HashMap::new(),
299            },
300        );
301
302        let result = config.validate();
303        assert!(result
304            .warnings
305            .iter()
306            .any(|w| w.code == ConfigWarningCode::NoTokenConfigured));
307    }
308
309    #[test]
310    fn test_validation_summary() {
311        let result = ValidationResult::new();
312        assert_eq!(result.summary(), "Configuration is valid");
313
314        let mut result2 = ValidationResult::new();
315        result2.add_error(ConfigError {
316            field: "test".to_string(),
317            message: "test error".to_string(),
318            code: ConfigErrorCode::MissingRequired,
319        });
320        assert!(result2.summary().contains("1 error(s)"));
321    }
322}