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}