rmpca 0.1.1

Enterprise-grade route optimization engine — Chinese Postman Problem solver with Eulerian circuit detection, Lean 4 FFI boundary, and property-based testing
Documentation
//! `setup-offline` subcommand — one-click offline data preparation
//!
//! Orchestrates downloading OSM data, `PMTiles`, compiling routing cache,
//! and generating geocoding index for a region.
//!
//! NOTE: Full functionality requires `futures-util` crate to be vendored
//! for streaming downloads. The command works in degraded mode until then.

use anyhow::{Context, Result};
use clap::Args;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;

use crate::config::Config;

/// Predefined city/region configurations
#[derive(Debug, Clone, Deserialize)]
struct RegionConfig {
    /// OSM PBF download URL (Geofabrik or similar)
    osm_pbf_url: String,
    /// `PMTiles` download URL (optional)
    pmtiles_url: Option<String>,
    /// Bounding box for geocoding index `[west, south, east, north]`
    bbox: Option<(f64, f64, f64, f64)>,
    /// Photon API endpoint for geocoding (optional)
    #[allow(dead_code)]
    photon_url: Option<String>,
}

/// Known regions with preconfigured download URLs
fn known_regions() -> Vec<(&'static str, RegionConfig)> {
    vec![
        ("montreal", RegionConfig {
            osm_pbf_url: "https://download.geofabrik.de/north-america/canada/quebec-latest.osm.pbf".into(),
            pmtiles_url: Some("https://r2.rmp.ca/montreal.pmtiles".into()),
            bbox: Some((-73.9, 45.4, -73.5, 45.7)),
            photon_url: None,
        }),
        ("toronto", RegionConfig {
            osm_pbf_url: "https://download.geofabrik.de/north-america/canada/ontario-latest.osm.pbf".into(),
            pmtiles_url: None,
            bbox: Some((-79.6, 43.6, -79.3, 43.8)),
            photon_url: None,
        }),
        ("vancouver", RegionConfig {
            osm_pbf_url: "https://download.geofabrik.de/north-america/canada/british-columbia-latest.osm.pbf".into(),
            pmtiles_url: None,
            bbox: Some((-123.3, 49.2, -123.0, 49.3)),
            photon_url: None,
        }),
    ]
}

#[derive(Debug, Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct SetupOfflineArgs {
    /// City or region name (e.g., `montreal`, `toronto`, `vancouver`)
    #[arg(long, short)]
    city: Option<String>,

    /// Custom OSM PBF URL (overrides city preset)
    #[arg(long)]
    osm_url: Option<String>,

    /// Custom `PMTiles` URL (overrides city preset)
    #[arg(long)]
    pmtiles_url: Option<String>,

    /// Output directory for offline data
    #[arg(long, short, env = "RMPCA_DATA_DIR")]
    data_dir: Option<PathBuf>,

    /// Skip OSM PBF download
    #[arg(long)]
    skip_osm: bool,

    /// Skip `PMTiles` download
    #[arg(long)]
    skip_pmtiles: bool,

    /// Skip routing cache compilation
    #[arg(long)]
    skip_compile: bool,

    /// Skip geocoding index generation
    #[arg(long)]
    skip_geocode: bool,

    /// Force re-download even if files exist
    #[arg(long)]
    force: bool,

    /// Photon server URL for geocoding (default: <http://localhost:2322>)
    #[arg(long, default_value = "http://localhost:2322")]
    photon_url: String,
}

/// Progress event for structured output
#[derive(Serialize)]
struct ProgressEvent {
    step: String,
    status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    percent: Option<u8>,
    #[serde(skip_serializing_if = "Option::is_none")]
    message: Option<String>,
}

impl SetupOfflineArgs {
    /// Get the data directory, defaulting to ~/.local/share/rmpca
    fn data_dir(&self) -> PathBuf {
        self.data_dir.clone().unwrap_or_else(|| {
            dirs::data_local_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join("rmpca")
        })
    }

    /// Resolve region configuration
    fn resolve_region(&self) -> Result<Option<(String, RegionConfig)>> {
        if let Some(ref city) = self.city {
            for (name, config) in known_regions() {
                if name.eq_ignore_ascii_case(city) {
                    let mut cfg = config.clone();
                    if let Some(ref url) = self.osm_url {
                        cfg.osm_pbf_url.clone_from(url);
                    }
                    if let Some(ref url) = self.pmtiles_url {
                        cfg.pmtiles_url = Some(url.clone());
                    }
                    return Ok(Some((name.to_string(), cfg)));
                }
            }
            if self.osm_url.is_none() {
                anyhow::bail!(
                    "Unknown city '{}'. Use --osm-url to specify the OSM PBF URL, \
                     or choose from: {}",
                    city,
                    known_regions().iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
                );
            }
            return Ok(Some((city.clone(), RegionConfig {
                osm_pbf_url: self.osm_url.as_ref().unwrap().clone(),
                pmtiles_url: self.pmtiles_url.clone(),
                bbox: None,
                photon_url: None,
            })));
        }

        if self.osm_url.is_none() {
            anyhow::bail!(
                "Either --city or --osm-url is required. \
                 Known cities: {}",
                known_regions().iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
            );
        }

        Ok(Some(("custom".into(), RegionConfig {
            osm_pbf_url: self.osm_url.as_ref().unwrap().clone(),
            pmtiles_url: self.pmtiles_url.clone(),
            bbox: None,
            photon_url: None,
        })))
    }
}

/// Set up offline data for a region.
///
/// # Errors
/// Returns an error if any download, compilation, or setup step fails.
pub async fn run(args: SetupOfflineArgs, _config: &Config) -> Result<()> {
    let (region_name, region) = args.resolve_region()?
        .context("No region configuration available")?;

    let data_dir = args.data_dir();
    let region_dir = data_dir.join(&region_name);

    eprintln!("Setting up offline data for: {region_name}");
    eprintln!("Data directory: {}", region_dir.display());

    // Create directory structure
    fs::create_dir_all(&region_dir).await
        .context("Failed to create data directory")?;

    let progress = |step: &str, status: &str, pct: Option<u8>, msg: Option<&str>| {
        let evt = ProgressEvent {
            step: step.into(),
            status: status.into(),
            percent: pct,
            message: msg.map(Into::into),
        };
        if let Ok(json) = serde_json::to_string(&evt) {
            eprintln!("{json}");
        }
    };

    // Step 1: Download OSM PBF
    let osm_path = region_dir.join(format!("{region_name}.osm.pbf"));
    if !args.skip_osm {
        progress("osm", "downloading", Some(10), Some(&format!("Downloading from {}", region.osm_pbf_url)));
        download_file(&region.osm_pbf_url, &osm_path, args.force).await?;
        progress("osm", "complete", Some(25), None);
    } else if osm_path.exists() {
        progress("osm", "skipped", Some(25), Some("Using existing file"));
    } else {
        anyhow::bail!("OSM PBF file not found and --skip-osm was specified");
    }

    // Step 2: Download PMTiles (optional)
    let pmtiles_path = region_dir.join(format!("{region_name}.pmtiles"));
    if args.skip_pmtiles {
        progress("pmtiles", "skipped", Some(45), None);
    } else if let Some(ref url) = region.pmtiles_url {
        progress("pmtiles", "downloading", Some(30), Some(&format!("Downloading from {url}")));
        download_file(url, &pmtiles_path, args.force).await?;
        progress("pmtiles", "complete", Some(45), None);
    } else {
        progress("pmtiles", "skipped", Some(45), Some("No PMTiles URL configured for this region"));
    }

    // Step 3: Compile routing cache
    let cache_path = region_dir.join(format!("{region_name}.rmp"));
    if args.skip_compile {
        progress("compile", "skipped", Some(75), None);
    } else {
        progress("compile", "running", Some(50), Some("Compiling routing cache..."));

        let status = tokio::process::Command::new(std::env::current_exe()?)
            .arg("compile-map")
            .arg("--input")
            .arg(&osm_path)
            .arg("--output")
            .arg(&cache_path)
            .status()
            .await
            .context("Failed to run compile-map")?;

        if status.success() {
            progress("compile", "complete", Some(75), None);
        } else {
            progress("compile", "failed", Some(75), Some("compile-map returned non-zero exit code"));
            anyhow::bail!("Routing cache compilation failed");
        }
    }

    // Step 4: Generate geocoding index
    if args.skip_geocode {
        progress("geocode", "skipped", Some(95), None);
    } else {
        progress("geocode", "running", Some(80), Some("Generating geocoding index..."));

        eprintln!("\n  Geocoding index generation requires Photon:");
        eprintln!("  1. Install Photon: https://photon.komoot.io/");
        eprintln!("  2. Import OSM data: photon -import-data {}", osm_path.display());
        eprintln!("  3. Or use pre-built indexes from: https://photon.komoot.io/download/");

        progress("geocode", "manual", Some(95), Some("See instructions above"));
    }

    // Write region manifest
    let manifest = RegionManifest {
        name: region_name.clone(),
        osm_pbf: osm_path.display().to_string(),
        pmtiles: if pmtiles_path.exists() { Some(pmtiles_path.display().to_string()) } else { None },
        routing_cache: cache_path.display().to_string(),
        bbox: region.bbox,
        created_at: chrono::Utc::now().to_rfc3339(),
    };

    let manifest_path = region_dir.join("manifest.json");
    let manifest_json = serde_json::to_string_pretty(&manifest)?;
    fs::write(&manifest_path, &manifest_json).await?;

    progress("setup", "complete", Some(100), Some(&format!("Offline data ready at {}", region_dir.display())));

    eprintln!("\nOffline setup complete!");
    eprintln!("  Region: {}", manifest.name);
    eprintln!("  OSM PBF: {}", manifest.osm_pbf);
    if let Some(ref p) = manifest.pmtiles {
        eprintln!("  PMTiles: {p}");
    }
    eprintln!("  Routing cache: {}", manifest.routing_cache);

    Ok(())
}

/// Download a file using reqwest (simple approach, no streaming)
async fn download_file(url: &str, dest: &Path, force: bool) -> Result<()> {
    if dest.exists() && !force {
        eprintln!("  File exists, skipping download: {}", dest.display());
        return Ok(());
    }

    eprintln!("  Downloading: {url}");
    eprintln!("  Destination: {}", dest.display());

    let client = reqwest::Client::builder()
        .timeout(Duration::from_hours(1))
        .build()?;

    let response = client.get(url)
        .send()
        .await
        .with_context(|| format!("Failed to start download from {url}"))?;

    if !response.status().is_success() {
        anyhow::bail!("Download failed with status: {}", response.status());
    }

    let total_size = response.content_length();
    if let Some(size) = total_size {
        eprintln!("  Size: {} MB", size / 1_048_576);
    }

    let bytes = response.bytes().await?;
    tokio::fs::write(dest, &bytes).await?;

    eprintln!("  Download complete: {} bytes", bytes.len());
    Ok(())
}

/// Region manifest for tracking downloaded data
#[derive(Debug, Serialize, Deserialize)]
pub struct RegionManifest {
    /// Region name
    pub name: String,
    /// Path to OSM PBF file
    pub osm_pbf: String,
    /// Path to `PMTiles` file (optional)
    pub pmtiles: Option<String>,
    /// Path to routing cache
    pub routing_cache: String,
    /// Bounding box [west, south, east, north]
    pub bbox: Option<(f64, f64, f64, f64)>,
    /// Creation timestamp
    pub created_at: String,
}

impl RegionManifest {
    /// Load manifest from a region directory
    ///
    /// # Errors
    /// Returns an error if the manifest file cannot be read or parsed.
    #[allow(dead_code)]
    pub fn load(region_dir: &Path) -> Result<Self> {
        let manifest_path = region_dir.join("manifest.json");
        let content = std::fs::read_to_string(&manifest_path)
            .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?;
        serde_json::from_str(&content)
            .with_context(|| "Failed to parse manifest JSON")
    }

    /// Save manifest to a region directory
    ///
    /// # Errors
    /// Returns an error if the manifest file cannot be written.
    #[allow(dead_code)]
    pub fn save(&self, region_dir: &Path) -> Result<()> {
        let manifest_path = region_dir.join("manifest.json");
        let content = serde_json::to_string_pretty(self)?;
        std::fs::write(&manifest_path, content)
            .with_context(|| format!("Failed to write manifest: {}", manifest_path.display()))
    }
}