1use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use crate::circuit_breaker::CircuitBreakerConfig;
20use crate::error::AppError;
21
22pub struct DbConfig {
27 pub max_connections: u32,
28}
29
30impl Default for DbConfig {
31 fn default() -> Self {
32 Self { max_connections: 5 }
34 }
35}
36
37pub struct HttpConfig {
39 pub timeout: Duration,
40 pub max_retries: u32,
41 pub retry_base_delay: Duration,
42}
43
44impl Default for HttpConfig {
45 fn default() -> Self {
46 Self {
47 timeout: Duration::from_secs(30),
48 max_retries: 3,
49 retry_base_delay: Duration::from_millis(500),
50 }
51 }
52}
53
54#[derive(Clone)]
60pub struct SyncConfig {
61 pub concurrency: usize,
63 pub force_full_sync: bool,
65 pub circuit_breaker: CircuitBreakerConfig,
67}
68
69impl Default for SyncConfig {
70 fn default() -> Self {
71 Self {
73 concurrency: 10,
74 force_full_sync: false,
75 circuit_breaker: CircuitBreakerConfig::default(),
76 }
77 }
78}
79
80impl SyncConfig {
81 pub fn with_full_sync(mut self) -> Self {
83 self.force_full_sync = true;
84 self
85 }
86
87 pub fn with_circuit_breaker(mut self, config: CircuitBreakerConfig) -> Self {
89 self.circuit_breaker = config;
90 self
91 }
92}
93
94fn default_portal_type() -> String {
100 "ckan".to_string()
101}
102
103fn default_enabled() -> bool {
105 true
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PortalsConfig {
129 pub portals: Vec<PortalEntry>,
131}
132
133impl PortalsConfig {
134 pub fn enabled_portals(&self) -> Vec<&PortalEntry> {
138 self.portals.iter().filter(|p| p.enabled).collect()
139 }
140
141 pub fn find_by_name(&self, name: &str) -> Option<&PortalEntry> {
149 self.portals
150 .iter()
151 .find(|p| p.name.eq_ignore_ascii_case(name))
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct PortalEntry {
161 pub name: String,
165
166 pub url: String,
170
171 #[serde(rename = "type", default = "default_portal_type")]
175 pub portal_type: String,
176
177 #[serde(default = "default_enabled")]
181 pub enabled: bool,
182
183 pub description: Option<String>,
185}
186
187pub const CONFIG_FILE_NAME: &str = "portals.toml";
189
190pub fn default_config_dir() -> Option<PathBuf> {
194 dirs::config_dir().map(|p| p.join("ceres"))
195}
196
197pub fn default_config_path() -> Option<PathBuf> {
201 default_config_dir().map(|p| p.join(CONFIG_FILE_NAME))
202}
203
204const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Ceres Portal Configuration
209#
210# Usage:
211# ceres harvest # Harvest all enabled portals
212# ceres harvest --portal milano # Harvest specific portal by name
213# ceres harvest https://... # Harvest single URL (ignores this file)
214#
215# Set enabled = false to skip a portal during batch harvest.
216
217# City of Milan open data
218[[portals]]
219name = "milano"
220url = "https://dati.comune.milano.it"
221type = "ckan"
222description = "Open data del Comune di Milano"
223
224# Sicily Region open data
225[[portals]]
226name = "sicilia"
227url = "https://dati.regione.sicilia.it"
228type = "ckan"
229description = "Open data della Regione Siciliana"
230"#;
231
232pub fn load_portals_config(path: Option<PathBuf>) -> Result<Option<PortalsConfig>, AppError> {
246 let using_default_path = path.is_none();
247 let config_path = match path {
248 Some(p) => p,
249 None => match default_config_path() {
250 Some(p) => p,
251 None => return Ok(None),
252 },
253 };
254
255 if !config_path.exists() {
256 if using_default_path {
258 match create_default_config(&config_path) {
259 Ok(()) => {
260 tracing::info!(
263 "Config file created at {}. Starting harvest with default portals...",
264 config_path.display()
265 );
266 }
268 Err(e) => {
269 tracing::warn!("Could not create default config template: {}", e);
271 return Ok(None);
272 }
273 }
274 } else {
275 return Err(AppError::ConfigError(format!(
277 "Config file not found: {}",
278 config_path.display()
279 )));
280 }
281 }
282
283 let content = std::fs::read_to_string(&config_path).map_err(|e| {
284 AppError::ConfigError(format!(
285 "Failed to read config file '{}': {}",
286 config_path.display(),
287 e
288 ))
289 })?;
290
291 let config: PortalsConfig = toml::from_str(&content).map_err(|e| {
292 AppError::ConfigError(format!(
293 "Invalid TOML in '{}': {}",
294 config_path.display(),
295 e
296 ))
297 })?;
298
299 Ok(Some(config))
300}
301
302fn create_default_config(path: &Path) -> std::io::Result<()> {
309 if let Some(parent) = path.parent() {
311 std::fs::create_dir_all(parent)?;
312 }
313
314 std::fs::write(path, DEFAULT_CONFIG_TEMPLATE)?;
315 tracing::info!("Created default config template at: {}", path.display());
316
317 Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_db_config_defaults() {
326 let config = DbConfig::default();
327 assert_eq!(config.max_connections, 5);
328 }
329
330 #[test]
331 fn test_http_config_defaults() {
332 let config = HttpConfig::default();
333 assert_eq!(config.timeout, Duration::from_secs(30));
334 assert_eq!(config.max_retries, 3);
335 assert_eq!(config.retry_base_delay, Duration::from_millis(500));
336 }
337
338 #[test]
339 fn test_sync_config_defaults() {
340 let config = SyncConfig::default();
341 assert_eq!(config.concurrency, 10);
342 }
343
344 #[test]
349 fn test_portals_config_deserialize() {
350 let toml = r#"
351[[portals]]
352name = "test-portal"
353url = "https://example.com"
354type = "ckan"
355"#;
356 let config: PortalsConfig = toml::from_str(toml).unwrap();
357 assert_eq!(config.portals.len(), 1);
358 assert_eq!(config.portals[0].name, "test-portal");
359 assert_eq!(config.portals[0].url, "https://example.com");
360 assert_eq!(config.portals[0].portal_type, "ckan");
361 assert!(config.portals[0].enabled); assert!(config.portals[0].description.is_none());
363 }
364
365 #[test]
366 fn test_portals_config_defaults() {
367 let toml = r#"
368[[portals]]
369name = "minimal"
370url = "https://example.com"
371"#;
372 let config: PortalsConfig = toml::from_str(toml).unwrap();
373 assert_eq!(config.portals[0].portal_type, "ckan"); assert!(config.portals[0].enabled); }
376
377 #[test]
378 fn test_portals_config_enabled_filter() {
379 let toml = r#"
380[[portals]]
381name = "enabled-portal"
382url = "https://a.com"
383
384[[portals]]
385name = "disabled-portal"
386url = "https://b.com"
387enabled = false
388"#;
389 let config: PortalsConfig = toml::from_str(toml).unwrap();
390 let enabled = config.enabled_portals();
391 assert_eq!(enabled.len(), 1);
392 assert_eq!(enabled[0].name, "enabled-portal");
393 }
394
395 #[test]
396 fn test_portals_config_find_by_name() {
397 let toml = r#"
398[[portals]]
399name = "Milano"
400url = "https://dati.comune.milano.it"
401"#;
402 let config: PortalsConfig = toml::from_str(toml).unwrap();
403
404 assert!(config.find_by_name("milano").is_some());
406 assert!(config.find_by_name("MILANO").is_some());
407 assert!(config.find_by_name("Milano").is_some());
408
409 assert!(config.find_by_name("roma").is_none());
411 }
412
413 #[test]
414 fn test_portals_config_with_description() {
415 let toml = r#"
416[[portals]]
417name = "test"
418url = "https://example.com"
419description = "A test portal"
420"#;
421 let config: PortalsConfig = toml::from_str(toml).unwrap();
422 assert_eq!(
423 config.portals[0].description,
424 Some("A test portal".to_string())
425 );
426 }
427
428 #[test]
429 fn test_portals_config_multiple_portals() {
430 let toml = r#"
431[[portals]]
432name = "portal-1"
433url = "https://a.com"
434
435[[portals]]
436name = "portal-2"
437url = "https://b.com"
438
439[[portals]]
440name = "portal-3"
441url = "https://c.com"
442enabled = false
443"#;
444 let config: PortalsConfig = toml::from_str(toml).unwrap();
445 assert_eq!(config.portals.len(), 3);
446 assert_eq!(config.enabled_portals().len(), 2);
447 }
448
449 #[test]
450 fn test_default_config_path() {
451 let path = default_config_path();
454 if let Some(p) = path {
455 assert!(p.ends_with("portals.toml"));
456 }
457 }
458
459 use std::io::Write;
464 use tempfile::NamedTempFile;
465
466 #[test]
467 fn test_load_portals_config_valid_file() {
468 let mut file = NamedTempFile::new().unwrap();
469 writeln!(
470 file,
471 r#"
472[[portals]]
473name = "test"
474url = "https://test.com"
475"#
476 )
477 .unwrap();
478
479 let config = load_portals_config(Some(file.path().to_path_buf()))
480 .unwrap()
481 .unwrap();
482
483 assert_eq!(config.portals.len(), 1);
484 assert_eq!(config.portals[0].name, "test");
485 assert_eq!(config.portals[0].url, "https://test.com");
486 }
487
488 #[test]
489 fn test_load_portals_config_custom_path_not_found() {
490 let result = load_portals_config(Some("/nonexistent/path/to/config.toml".into()));
491 assert!(result.is_err());
492 let err = result.unwrap_err();
493 assert!(matches!(err, AppError::ConfigError(_)));
494 }
495
496 #[test]
497 fn test_load_portals_config_invalid_toml() {
498 let mut file = NamedTempFile::new().unwrap();
499 writeln!(file, "this is not valid toml {{{{").unwrap();
500
501 let result = load_portals_config(Some(file.path().to_path_buf()));
502 assert!(result.is_err());
503 let err = result.unwrap_err();
504 assert!(matches!(err, AppError::ConfigError(_)));
505 }
506
507 #[test]
508 fn test_load_portals_config_multiple_portals_with_enabled_filter() {
509 let mut file = NamedTempFile::new().unwrap();
510 writeln!(
511 file,
512 r#"
513[[portals]]
514name = "enabled-portal"
515url = "https://a.com"
516
517[[portals]]
518name = "disabled-portal"
519url = "https://b.com"
520enabled = false
521
522[[portals]]
523name = "another-enabled"
524url = "https://c.com"
525enabled = true
526"#
527 )
528 .unwrap();
529
530 let config = load_portals_config(Some(file.path().to_path_buf()))
531 .unwrap()
532 .unwrap();
533
534 assert_eq!(config.portals.len(), 3);
535 assert_eq!(config.enabled_portals().len(), 2);
536 }
537
538 #[test]
539 fn test_load_portals_config_with_all_fields() {
540 let mut file = NamedTempFile::new().unwrap();
541 writeln!(
542 file,
543 r#"
544[[portals]]
545name = "full-config"
546url = "https://example.com"
547type = "ckan"
548enabled = true
549description = "A fully configured portal"
550"#
551 )
552 .unwrap();
553
554 let config = load_portals_config(Some(file.path().to_path_buf()))
555 .unwrap()
556 .unwrap();
557
558 let portal = &config.portals[0];
559 assert_eq!(portal.name, "full-config");
560 assert_eq!(portal.url, "https://example.com");
561 assert_eq!(portal.portal_type, "ckan");
562 assert!(portal.enabled);
563 assert_eq!(
564 portal.description,
565 Some("A fully configured portal".to_string())
566 );
567 }
568
569 #[test]
570 fn test_load_portals_config_empty_portals_array() {
571 let mut file = NamedTempFile::new().unwrap();
572 writeln!(file, "portals = []").unwrap();
573
574 let config = load_portals_config(Some(file.path().to_path_buf()))
575 .unwrap()
576 .unwrap();
577
578 assert!(config.portals.is_empty());
579 assert!(config.enabled_portals().is_empty());
580 }
581}