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