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}