mod api; mod cli; mod db; mod ingest; mod types; use std::path::Path; use std::sync::Arc; use std::time::Instant; use axum::serve; use clap::Parser; use tokio::net::TcpListener; use tracing_subscriber::EnvFilter; struct LoggerGuard { _guard: Option, } fn init_logging(log_file_path: Option<&str>) -> LoggerGuard { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let is_json = std::env::var("HAGFISH_LOG_FORMAT").as_deref() == Ok("json"); match log_file_path { Some(path) => { let file = std::fs::File::create(path).expect("Failed to create log file"); let (non_blocking, guard) = tracing_appender::non_blocking(file); let subscriber = tracing_subscriber::fmt() .with_env_filter(filter) .with_writer(non_blocking); if is_json { subscriber.json().init(); } else { subscriber.init(); } LoggerGuard { _guard: Some(guard), } } None => { let subscriber = tracing_subscriber::fmt().with_env_filter(filter); if is_json { subscriber.json().init(); } else { subscriber.init(); } LoggerGuard { _guard: None } } } } fn cleanup_stale_parquet_files() { let tmp_dir = std::env::temp_dir(); match std::fs::read_dir(&tmp_dir) { Ok(entries) => { for entry in entries.filter_map(|e| e.ok()) { if let Some(name) = entry.file_name().to_str() && name.starts_with("hagfish-") && name.ends_with(".parquet") && let Ok(metadata) = entry.metadata() && let Ok(modified) = metadata.modified() { let age = std::time::SystemTime::now().duration_since(modified); if age.map(|d| d.as_secs() > 3600).unwrap_or(false) { let _ = std::fs::remove_file(entry.path()); } } } } Err(e) => tracing::warn!(error = %e, "Failed to list temp dir"), } } #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = cli::Cli::parse(); let config = match &cli.config { Some(path) => types::Config::load(path)?, None => { let default_path = std::path::Path::new("config.json"); if default_path.exists() { types::Config::load(default_path)? } else { eprintln!("WARN: No config file found, using defaults"); types::Config::default() } } }; let _logger_guard = init_logging(config.log_file_path.as_deref()); tracing::info!( duckdb_path = %config.duckdb_path, bind_address = %config.bind_address, "configuration loaded" ); match cli.command { cli::Command::Serve => run_serve(&config).await, cli::Command::Ingest { full } => run_ingest(&config, full).await, cli::Command::Export { out } => run_export(&config, &out), } } async fn run_serve(config: &types::Config) -> anyhow::Result<()> { cleanup_stale_parquet_files(); let conn = db::init(&config.duckdb_path)?; let state = api::AppState { conn: Arc::new(tokio::sync::Mutex::new(conn)), config: Arc::new(config.clone()), }; let app = api::build_router(state); let listener = TcpListener::bind(&config.bind_address).await?; tracing::info!(address = %config.bind_address, "hagfish server started"); serve(listener, app).await?; Ok(()) } async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> { let run_start = Instant::now(); tracing::info!( mode = if full { "full" } else { "incremental" }, "starting ingestion run" ); let conn = db::init(&config.duckdb_path)?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .build()?; let (lookup_maps, metadata) = ingest::fetch_metadata(&client, &config.data_source_url) .await .inspect_err(|e| { tracing::error!(error = %e, stage = "fetch_metadata", "ingestion failed"); })?; db::update_lookups(&conn, &lookup_maps)?; let species_codes = ingest::extract_non_total_values(&metadata, ingest::DIM_SPECIES); let gear_codes = ingest::extract_non_total_values(&metadata, ingest::DIM_GEAR); let zone_codes = ingest::extract_non_total_values(&metadata, ingest::DIM_ZONE); tracing::info!( species_count = species_codes.len(), gear_count = gear_codes.len(), zone_count = zone_codes.len(), "Extracted non-TOTAL dimension values for leaf-only query" ); let all_months = ingest::extract_available_months(&metadata); if full { conn.execute("DELETE FROM landings", [])?; tracing::info!("Cleared landings table for full re-ingest"); } let months_to_fetch: Vec = if full { all_months } else { let last = db::get_last_month(&conn)?; match last { Some(last_month) => all_months .into_iter() .filter(|m| m.as_str() > last_month.as_str()) .collect(), None => all_months, } }; if months_to_fetch.is_empty() { let elapsed = run_start.elapsed(); tracing::info!( mode = if full { "full" } else { "incremental" }, months_requested = 0, total_rows = 0, batches = 0, errors = 0, elapsed_ms = elapsed.as_millis() as u64, "ingestion complete — no new months" ); return Ok(()); } const BATCH_SIZE: usize = 12; let batches = ingest::chunk_months(&months_to_fetch, BATCH_SIZE); tracing::info!( mode = if full { "full" } else { "incremental" }, months_requested = months_to_fetch.len(), batches = batches.len(), "beginning batched ingestion" ); let mut total_rows = 0; let mut errors = 0; for (i, batch) in batches.iter().enumerate() { let batch_start = Instant::now(); tracing::info!( batch = i + 1, total_batches = batches.len(), months_in_batch = batch.len(), months = %batch.join(", "), "fetching batch" ); let query = ingest::build_query(batch, &species_codes, &gear_codes, &zone_codes)?; let data = match ingest::fetch_data(&client, &config.data_source_url, &query).await { Ok(d) => d, Err(e) => { tracing::error!( batch = i + 1, error = %e, stage = "fetch_data", "batch failed, continuing to next batch" ); errors += 1; continue; } }; let mut landings = Vec::with_capacity(data.dataset.value.len()); let mut parse_errors = 0; for j in 0..data.dataset.value.len() { match ingest::parse_row(j, &data.dataset, &lookup_maps) { Ok(row) => { if row.species_code == "TOTAL" || row.gear_code == "TOTAL" || row.zone_code == "TOTAL" { continue; } landings.push(ingest::data_row_to_landing(row)); } Err(e) => { tracing::warn!( batch = i + 1, row_index = j, error = %e, "failed to parse row" ); parse_errors += 1; errors += 1; } } } let count = match db::upsert_landings(&conn, &landings) { Ok(c) => c, Err(e) => { tracing::error!( batch = i + 1, error = %e, stage = "upsert_landings", "batch upsert failed" ); errors += 1; continue; } }; total_rows += count; let batch_elapsed = batch_start.elapsed(); tracing::info!( batch = i + 1, total_batches = batches.len(), rows_inserted = count, parse_errors = parse_errors, elapsed_ms = batch_elapsed.as_millis() as u64, "batch complete" ); if i < batches.len() - 1 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } let elapsed = run_start.elapsed(); tracing::info!( mode = if full { "full" } else { "incremental" }, months_requested = months_to_fetch.len(), batches = batches.len(), total_rows = total_rows, errors = errors, elapsed_ms = elapsed.as_millis() as u64, "ingestion run complete" ); Ok(()) } fn run_export(config: &types::Config, out: &Path) -> anyhow::Result<()> { let run_start = Instant::now(); let conn = db::init(&config.duckdb_path)?; let path_str = out .to_str() .ok_or_else(|| anyhow::anyhow!("Output path contains invalid UTF-8"))?; db::export_parquet(&conn, path_str)?; let elapsed = run_start.elapsed(); tracing::info!( path = path_str, elapsed_ms = elapsed.as_millis() as u64, "export complete" ); Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::types::Config; #[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")); } #[test] fn test_run_export_writes_valid_parquet() { let tmp_dir = tempfile::tempdir().unwrap(); let db_path = tmp_dir.path().join("test.db"); let parquet_path = tmp_dir.path().join("export.parquet"); let conn = db::init(db_path.to_str().unwrap()).unwrap(); let rows = vec![crate::types::Landing { month: "2024M01".to_string(), species_code: "COD".to_string(), species_label: "Toskur".to_string(), gear_code: "TR1".to_string(), zone_code: "FO".to_string(), processing_code: "TOTAL".to_string(), preservation_code: "TOTAL".to_string(), shipsize_code: "TOTAL".to_string(), measure_code: "MASS".to_string(), value: Some(1000.0), }]; db::upsert_landings(&conn, &rows).unwrap(); drop(conn); let config = Config { duckdb_path: db_path.to_str().unwrap().to_string(), ..Default::default() }; let result = run_export(&config, &parquet_path); assert!(result.is_ok()); let metadata = std::fs::metadata(&parquet_path).unwrap(); assert!(metadata.len() > 0); let file_bytes = std::fs::read(&parquet_path).unwrap(); assert_eq!(&file_bytes[..4], b"PAR1"); } }