openstranded-map-tool 0.2.0

Convert Stranded II .s2 map files to .osmap (OpenStranded MAP) format
Documentation
// openstranded-map-tool — convert Stranded II .s2 maps to .osmap format
// Copyright (C) 2026  OpenStranded contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

//! CLI subcommands for `openstranded-map-tool`.

use std::path::PathBuf;

use clap::{Parser, Subcommand};

use crate::convert::s2_to_osmap;
use crate::parser::parse_s2_file;
use crate::save_osmap_ron;
use crate::types::OpenStrandedMap;

/// Convert Stranded II .s2 map files to .osmap (OpenStranded MAP) format.
#[derive(Parser)]
#[command(name = "openstranded-map-tool", version, about)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand)]
pub enum Command {
    /// Convert a .s2 map to .osmap format (RON)
    Convert {
        /// Input .s2 file
        input: PathBuf,
        /// Output .osmap file (default: input filename with .osmap extension)
        output: Option<PathBuf>,
    },
    /// Display information about a .osmap or .s2 file
    Info {
        /// Input .s2 or .osmap file
        input: PathBuf,
    },
}

/// Run the CLI.
pub fn run() -> Result<(), anyhow::Error> {
    let cli = Cli::parse();
    match cli.command {
        Command::Convert { input, output } => cmd_convert(&input, output.as_deref()),
        Command::Info { input } => cmd_info(&input),
    }
}

fn cmd_convert(input: &PathBuf, output: Option<&std::path::Path>) -> Result<(), anyhow::Error> {
    if !input.is_file() {
        anyhow::bail!("input file not found: {}", input.display());
    }

    // Detect input format by extension
    let ext = input
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();

    match ext.as_str() {
        "s2" | "map" => {
            let s2 = parse_s2_file(input)?;
            let source_name = input
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("unknown");
            let osmap = s2_to_osmap(s2, source_name);
            let out_path = output
                .map(|p| p.to_path_buf())
                .unwrap_or_else(|| input.with_extension("osmap"));
            save_osmap_ron(&osmap, &out_path)?;
            eprintln!("converted {} -> {}", input.display(), out_path.display());
        }
        "osmap" => {
            anyhow::bail!(
                "input {} is already .osmap; use 'info' to inspect it",
                input.display()
            );
        }
        _ => {
            anyhow::bail!("unsupported input format: .{ext} (expected .s2 or .map)");
        }
    }

    Ok(())
}

fn cmd_info(input: &PathBuf) -> Result<(), anyhow::Error> {
    if !input.is_file() {
        anyhow::bail!("file not found: {}", input.display());
    }

    let ext = input
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();

    match ext.as_str() {
        "s2" | "map" => {
            let s2 = parse_s2_file(input)?;
            println!("=== .s2 Map Info ===");
            println!("File:      {}", input.display());
            println!("Version:   {}", s2.header.version);
            println!("Date:      {} {}", s2.header.date, s2.header.time);
            println!("Author:    {}", s2.header.author);
            println!("Type:      {}", s2.header.map_type);
            println!("Terrain:   {}×{}", s2.terrain_size, s2.terrain_size);
            println!("Colormap:  {}×{}", s2.colormap_dim, s2.colormap_dim);
            println!("Objects:   {}", s2.objects.len());
            println!("Units:     {}", s2.units.len());
            println!("Items:     {}", s2.items.len());
            println!("Infos:     {}", s2.infos.len());
            println!("States:    {}", s2.states.len());
            println!("Exts:      {}", s2.extensions.len());
            if let Some(lt) = s2.extensions.iter().find(|e| e.key == "lasttime") {
                println!("SaveTime:  {}", lt.stuff);
            }
            if !s2.password.decoded.is_empty() {
                println!("Password:  {} (key={})", s2.password.decoded, s2.password.key);
            }
        }
        "osmap" => {
            let data = std::fs::read_to_string(input)?;
            let osmap: OpenStrandedMap = ron::from_str(&data)
                .map_err(|e| anyhow::anyhow!("failed to parse .osmap: {e}"))?;
            println!("=== .osmap Info ===");
            println!("File:      {}", input.display());
            println!("Name:      {}", osmap.meta.name);
            println!("Engine:    {}", osmap.meta.engine_version);
            println!("MapVer:    {}", osmap.meta.map_version);
            println!("Source:    {}", osmap.meta.source_file);
            if let Some(hdr) = &osmap.meta.s2_header {
                println!("S2Ver:     {}", hdr.version);
                println!("S2Author:  {}", hdr.author);
            }
            println!("Terrain:   {}×{}", osmap.terrain.size, osmap.terrain.size);
            println!("Entities:  {}", osmap.entities.len());
            let by_class = |class: &str| -> usize {
                osmap.entities.iter().filter(|e| e.class == class).count()
            };
            println!("  objects: {}", by_class("object"));
            println!("  units:   {}", by_class("unit"));
            println!("  items:   {}", by_class("item"));
            println!("  infos:   {}", by_class("info"));
            if let Some(cm) = &osmap.colormap {
                println!("Colormap:  {}×{}", cm.dim, cm.dim);
            } else {
                println!("Colormap:  (none)");
            }
            if let Some(gr) = &osmap.grass {
                let grass_count = gr.values.iter().filter(|&&v| v != 0).count();
                println!("Grass:     {}×{} ({} cells filled)", gr.dim, gr.dim, grass_count);
            } else {
                println!("Grass:     (none)");
            }
            println!("Scripts:   {}", osmap.scripts.len());
            println!("Env Vars:  {}", osmap.environment.len());
            if osmap.environment.contains_key("password") {
                println!("Password:  {}", osmap.environment.get("password").unwrap());
            }
        }
        _ => {
            anyhow::bail!("unsupported format: .{ext} (expected .s2, .map, or .osmap)");
        }
    }

    Ok(())
}