pipedash_core/infrastructure/config/
validation.rs1use 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}