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