use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub duckdb_path: String, pub bind_address: String, pub data_source_url: String, pub log_file_path: Option, #[serde(default = "default_allowed_origins")] pub allowed_origins: Vec, } fn default_allowed_origins() -> Vec { vec![] } impl Default for Config { fn default() -> Self { Self { duckdb_path: "hagfish.db".to_string(), bind_address: "127.0.0.1:8090".to_string(), data_source_url: "https://statbank.hagstova.fo/api/v1/fo/H2/VV/VV01/fisknv_md.px" .to_string(), log_file_path: Some("hagfish.log".to_string()), allowed_origins: default_allowed_origins(), } } } impl Config { pub fn load(path: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read config file {}: {e}", path.display()))?; let config: Config = serde_json::from_str(&contents) .map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {e}", path.display()))?; tracing::info!("Loaded config from {}", path.display()); Ok(config) } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MetadataResponse { #[serde(default)] pub title: Option, pub variables: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvailableFiltersDto { pub species: Vec, pub zones: Vec, pub gear: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct VariableMeta { pub code: String, #[serde(default)] pub text: String, #[serde(default)] pub role: Option, #[serde(default)] pub values: Vec, #[serde(default, rename = "valueTexts")] pub value_texts: Vec, #[serde(flatten)] pub extra: HashMap, } impl VariableMeta { pub fn lookup_map(&self) -> HashMap { self.values .iter() .zip(self.value_texts.iter()) .map(|(code, label)| (code.clone(), label.clone())) .collect() } } #[derive(Debug, Clone, Serialize)] pub struct Query { pub query: Vec, pub response: QueryResponse, } #[derive(Debug, Clone, Serialize)] pub struct QueryItem { pub code: String, pub selection: Selection, } #[derive(Debug, Clone, Serialize)] pub struct Selection { pub filter: String, pub values: Vec, } #[derive(Debug, Clone, Serialize)] pub struct QueryResponse { pub format: String, } impl Default for QueryResponse { fn default() -> Self { Self { format: "json-stat2".to_string(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DataResponse { #[serde(flatten)] pub dataset: Dataset, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Dataset { #[serde(default)] pub id: Vec, #[serde(default)] pub size: Vec, #[serde(flatten)] pub extra_fields: HashMap, pub dimension: HashMap, pub value: Vec>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Dimension { #[serde(default)] pub label: String, pub category: CategoryInfo, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CategoryInfo { pub index: HashMap, #[serde(default)] pub label: HashMap, } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct DataRow { pub month: String, pub species_code: String, pub species_label: String, pub gear_code: String, pub gear_label: String, pub zone_code: String, pub zone_label: String, pub processing_code: String, pub processing_label: String, pub preservation_code: String, pub preservation_label: String, pub shipsize_code: String, pub shipsize_label: String, pub measure_code: String, pub measure_label: String, pub value: Option, } #[derive(Debug, Clone)] pub struct Landing { pub month: String, pub species_code: String, pub species_label: String, pub gear_code: String, pub zone_code: String, pub processing_code: String, pub preservation_code: String, pub shipsize_code: String, pub measure_code: String, pub value: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpeciesDto { pub code: String, pub label: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LookupDto { pub code: String, pub label: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LandingDto { pub month: String, pub species_code: String, pub species_label: String, pub gear_code: String, pub zone_code: String, pub processing_code: String, pub preservation_code: String, pub shipsize_code: String, pub measure_code: String, pub value: Option, } impl From for LandingDto { fn from(l: Landing) -> Self { Self { month: l.month, species_code: l.species_code, species_label: l.species_label, gear_code: l.gear_code, zone_code: l.zone_code, processing_code: l.processing_code, preservation_code: l.preservation_code, shipsize_code: l.shipsize_code, measure_code: l.measure_code, value: l.value, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MonthlyAggregate { pub month: String, pub total_mass: Option, pub total_value: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TopSpecies { pub species_code: String, pub species_label: String, pub total_value: Option, pub total_mass: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PriceTrend { pub month: String, pub price_per_kg: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SummaryDto { pub monthly: Vec, pub top_species: Vec, pub price_trend: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MonthlySpeciesBreakdown { pub month: String, pub species_code: String, pub species_label: String, pub total_value: Option, pub total_mass: Option, } #[derive(Debug, thiserror::Error)] pub enum IngestError { #[error("HTTP request failed: {0}")] HttpError(#[from] reqwest::Error), #[error("JSON parsing failed: {0}")] JsonError(#[from] serde_json::Error), #[error("API returned status {status}: {body}")] ApiStatus { status: u16, body: String }, #[error("Missing dimension in response: {0}")] MissingDimension(String), #[error("Invalid value code: {0}")] InvalidValueCode(String), #[error("API returned empty data set")] EmptyDataset, } pub type LookupMap = HashMap>; pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; #[test] fn test_config_load_missing_file_hard_error() { let result = Config::load(Path::new("/nonexistent/path/config.json")); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("Failed to read config file")); } #[test] fn test_config_load_invalid_json_error() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), "not valid json {{{").unwrap(); let result = Config::load(tmp.path()); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("Failed to parse config file")); } #[test] fn test_config_load_valid_file() { let tmp = tempfile::NamedTempFile::new().unwrap(); let content = r#"{ "duckdb_path": "test.db", "bind_address": "127.0.0.1:9090", "data_source_url": "http://example.com", "log_file_path": null, "allowed_origins": ["https://example.com"] }"#; std::fs::write(tmp.path(), content).unwrap(); let config = Config::load(tmp.path()).unwrap(); assert_eq!(config.duckdb_path, "test.db"); assert_eq!(config.bind_address, "127.0.0.1:9090"); assert_eq!(config.data_source_url, "http://example.com"); assert!(config.log_file_path.is_none()); assert_eq!(config.allowed_origins, vec!["https://example.com"]); } #[test] fn test_config_load_defaults_allowed_origins() { let tmp = tempfile::NamedTempFile::new().unwrap(); let content = r#"{ "duckdb_path": "test.db", "bind_address": "127.0.0.1:9090", "data_source_url": "http://example.com" }"#; std::fs::write(tmp.path(), content).unwrap(); let config = Config::load(tmp.path()).unwrap(); assert!(config.allowed_origins.is_empty()); } #[test] fn test_config_default_values() { let config = Config::default(); assert_eq!(config.duckdb_path, "hagfish.db"); assert_eq!(config.bind_address, "127.0.0.1:8090"); assert!(config.data_source_url.contains("statbank.hagstova.fo")); assert!(config.log_file_path.is_some()); assert!(config.allowed_origins.is_empty()); } }