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
//! Command to render route visualizations as PNG images

use crate::config::Config;
use crate::map::{MapRenderer, MapRendererConfig};
use anyhow::{Context, Result};
use clap::Args;
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Debug, Args)]
pub struct RenderMapArgs {
    /// Input route file (JSON with `{ "route": [[lon, lat], ...] }` or `GeoJSON`)
    #[arg(short, long)]
    input: PathBuf,

    /// Output PNG file path
    #[arg(short, long)]
    output: PathBuf,

    /// Image width in pixels
    #[arg(long, default_value = "1920")]
    width: u32,

    /// Image height in pixels
    #[arg(long, default_value = "1080")]
    height: u32,

    /// Route line color (hex format: #RRGGBB or #RRGGBBAA)
    #[arg(long, default_value = "#4285F4")]
    route_color: String,

    /// Route line width in pixels
    #[arg(long, default_value = "3")]
    route_width: f32,

    /// Background color (hex format)
    #[arg(long, default_value = "#FFFFFF")]
    background: String,
}

/// Render a route map from an input file to a PNG image.
///
/// # Errors
/// Returns an error if the input file cannot be read, parsed, or if the
/// rendering fails (e.g., empty route, missing `tiny-skia` dependency).
pub fn run(args: &RenderMapArgs, _config: &Config) -> Result<()> {
    // Read input file
    let content = std::fs::read_to_string(&args.input)
        .context("Failed to read input file")?;

    // Parse route from JSON or GeoJSON
    let route = parse_route(&content)?;

    // Build configuration
    let config = MapRendererConfig {
        width: args.width,
        height: args.height,
        background_color: parse_color(&args.background)?,
        route_color: parse_color(&args.route_color)?,
        route_width: args.route_width,
        ..Default::default()
    };

    // Render (will return error until tiny-skia is vendored)
    let renderer = MapRenderer::with_config(config);
    renderer
        .render_route_to_png(&route, &args.output)
        .context("Failed to render map")?;

    eprintln!("Rendered route to: {}", args.output.display());
    Ok(())
}

/// Parse route from JSON or `GeoJSON` content
fn parse_route(content: &str) -> Result<Vec<(f64, f64)>> {
    // Try parsing as simple JSON route first
    #[derive(Deserialize)]
    struct SimpleRoute {
        route: Vec<[f64; 2]>,
    }

    if let Ok(simple) = serde_json::from_str::<SimpleRoute>(content) {
        return Ok(simple.route.iter().map(|p| (p[0], p[1])).collect());
    }

    // Try parsing as GeoJSON
    if let Ok(geojson) = serde_json::from_str::<geojson::GeoJson>(content) {
        return parse_geojson_route(&geojson);
    }

    anyhow::bail!("Input must be JSON with 'route' array or valid GeoJSON");
}

/// Parse route from a `GeoJSON` value
fn parse_geojson_route(geojson: &geojson::GeoJson) -> Result<Vec<(f64, f64)>> {
    match geojson {
        geojson::GeoJson::FeatureCollection(fc) => {
            let mut route = Vec::new();
            for feature in &fc.features {
                if let Some(ref geom) = feature.geometry {
                    if let geojson::Value::LineString(coords) = &geom.value {
                        for coord in coords {
                            route.push((coord[0], coord[1]));
                        }
                    }
                }
            }
            if route.is_empty() {
                anyhow::bail!("No LineString geometries found in GeoJSON");
            }
            Ok(route)
        }
        geojson::GeoJson::Feature(feature) => {
            if let Some(ref geom) = feature.geometry {
                if let geojson::Value::LineString(coords) = &geom.value {
                    return Ok(coords.iter().map(|c| (c[0], c[1])).collect());
                }
            }
            anyhow::bail!("Feature does not contain a LineString geometry");
        }
        geojson::GeoJson::Geometry(geom) => {
            if let geojson::Value::LineString(coords) = &geom.value {
                return Ok(coords.iter().map(|c| (c[0], c[1])).collect());
            }
            anyhow::bail!("Geometry is not a LineString");
        }
    }
}

/// Parse hex color string to RGBA array
fn parse_color(color: &str) -> Result<[u8; 4]> {
    let color = color.trim_start_matches('#');

    match color.len() {
        6 => {
            let r = u8::from_str_radix(&color[0..2], 16)
                .context("Invalid red component in color")?;
            let g = u8::from_str_radix(&color[2..4], 16)
                .context("Invalid green component in color")?;
            let b = u8::from_str_radix(&color[4..6], 16)
                .context("Invalid blue component in color")?;
            Ok([r, g, b, 255])
        }
        8 => {
            let r = u8::from_str_radix(&color[0..2], 16)
                .context("Invalid red component in color")?;
            let g = u8::from_str_radix(&color[2..4], 16)
                .context("Invalid green component in color")?;
            let b = u8::from_str_radix(&color[4..6], 16)
                .context("Invalid blue component in color")?;
            let a = u8::from_str_radix(&color[6..8], 16)
                .context("Invalid alpha component in color")?;
            Ok([r, g, b, a])
        }
        _ => anyhow::bail!("Color must be 6 or 8 hex digits (RRGGBB or RRGGBBAA)"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_color_hex() {
        assert_eq!(parse_color("#FF0000").unwrap(), [255, 0, 0, 255]);
        assert_eq!(parse_color("#00FF00").unwrap(), [0, 255, 0, 255]);
        assert_eq!(parse_color("#0000FF").unwrap(), [0, 0, 255, 255]);
        assert_eq!(parse_color("#FF000080").unwrap(), [255, 0, 0, 128]);
    }

    #[test]
    fn test_parse_simple_json_route() {
        let json = r#"{"route": [[-73.5, 45.5], [-73.6, 45.6]]}"#;
        let route = parse_route(json).unwrap();
        assert_eq!(route.len(), 2);
        assert_eq!(route[0], (-73.5, 45.5));
    }

    #[test]
    fn test_parse_geojson_linestring() {
        let geojson = r#"{"type":"LineString","coordinates":[[-73.5,45.5],[-73.6,45.6]]}"#;
        let route = parse_route(geojson).unwrap();
        assert_eq!(route.len(), 2);
    }
}