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