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;
#[derive(Debug, Clone, Deserialize)]
struct RegionConfig {
osm_pbf_url: String,
pmtiles_url: Option<String>,
bbox: Option<(f64, f64, f64, f64)>,
#[allow(dead_code)]
photon_url: Option<String>,
}
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 {
#[arg(long, short)]
city: Option<String>,
#[arg(long)]
osm_url: Option<String>,
#[arg(long)]
pmtiles_url: Option<String>,
#[arg(long, short, env = "RMPCA_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long)]
skip_osm: bool,
#[arg(long)]
skip_pmtiles: bool,
#[arg(long)]
skip_compile: bool,
#[arg(long)]
skip_geocode: bool,
#[arg(long)]
force: bool,
#[arg(long, default_value = "http://localhost:2322")]
photon_url: String,
}
#[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 {
fn data_dir(&self) -> PathBuf {
self.data_dir.clone().unwrap_or_else(|| {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("rmpca")
})
}
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,
})))
}
}
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(®ion_name);
eprintln!("Setting up offline data for: {region_name}");
eprintln!("Data directory: {}", region_dir.display());
fs::create_dir_all(®ion_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}");
}
};
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(®ion.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");
}
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"));
}
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");
}
}
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"));
}
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(())
}
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(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegionManifest {
pub name: String,
pub osm_pbf: String,
pub pmtiles: Option<String>,
pub routing_cache: String,
pub bbox: Option<(f64, f64, f64, f64)>,
pub created_at: String,
}
impl RegionManifest {
#[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")
}
#[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()))
}
}