Skip to main content

openstranded_map_tool/
cli.rs

1// openstranded-map-tool — convert Stranded II .s2 maps to .osmap format
2// Copyright (C) 2026  OpenStranded contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! CLI subcommands for `openstranded-map-tool`.
18
19use std::path::PathBuf;
20
21use clap::{Parser, Subcommand};
22
23use crate::convert::s2_to_osmap;
24use crate::parser::parse_s2_file;
25use crate::save_osmap_ron;
26use crate::types::OpenStrandedMap;
27
28/// Convert Stranded II .s2 map files to .osmap (OpenStranded MAP) format.
29#[derive(Parser)]
30#[command(name = "openstranded-map-tool", version, about)]
31pub struct Cli {
32    #[command(subcommand)]
33    pub command: Command,
34}
35
36#[derive(Subcommand)]
37pub enum Command {
38    /// Convert a .s2 map to .osmap format (RON)
39    Convert {
40        /// Input .s2 file
41        input: PathBuf,
42        /// Output .osmap file (default: input filename with .osmap extension)
43        output: Option<PathBuf>,
44    },
45    /// Display information about a .osmap or .s2 file
46    Info {
47        /// Input .s2 or .osmap file
48        input: PathBuf,
49    },
50}
51
52/// Run the CLI.
53pub fn run() -> Result<(), anyhow::Error> {
54    let cli = Cli::parse();
55    match cli.command {
56        Command::Convert { input, output } => cmd_convert(&input, output.as_deref()),
57        Command::Info { input } => cmd_info(&input),
58    }
59}
60
61fn cmd_convert(input: &PathBuf, output: Option<&std::path::Path>) -> Result<(), anyhow::Error> {
62    if !input.is_file() {
63        anyhow::bail!("input file not found: {}", input.display());
64    }
65
66    // Detect input format by extension
67    let ext = input
68        .extension()
69        .and_then(|e| e.to_str())
70        .unwrap_or("")
71        .to_lowercase();
72
73    match ext.as_str() {
74        "s2" | "map" => {
75            let s2 = parse_s2_file(input)?;
76            let source_name = input
77                .file_name()
78                .and_then(|s| s.to_str())
79                .unwrap_or("unknown");
80            let osmap = s2_to_osmap(s2, source_name);
81            let out_path = output
82                .map(|p| p.to_path_buf())
83                .unwrap_or_else(|| input.with_extension("osmap"));
84            save_osmap_ron(&osmap, &out_path)?;
85            eprintln!("converted {} -> {}", input.display(), out_path.display());
86        }
87        "osmap" => {
88            anyhow::bail!(
89                "input {} is already .osmap; use 'info' to inspect it",
90                input.display()
91            );
92        }
93        _ => {
94            anyhow::bail!("unsupported input format: .{ext} (expected .s2 or .map)");
95        }
96    }
97
98    Ok(())
99}
100
101fn cmd_info(input: &PathBuf) -> Result<(), anyhow::Error> {
102    if !input.is_file() {
103        anyhow::bail!("file not found: {}", input.display());
104    }
105
106    let ext = input
107        .extension()
108        .and_then(|e| e.to_str())
109        .unwrap_or("")
110        .to_lowercase();
111
112    match ext.as_str() {
113        "s2" | "map" => {
114            let s2 = parse_s2_file(input)?;
115            println!("=== .s2 Map Info ===");
116            println!("File:      {}", input.display());
117            println!("Version:   {}", s2.header.version);
118            println!("Date:      {} {}", s2.header.date, s2.header.time);
119            println!("Author:    {}", s2.header.author);
120            println!("Type:      {}", s2.header.map_type);
121            println!("Terrain:   {}×{}", s2.terrain_size, s2.terrain_size);
122            println!("Colormap:  {}×{}", s2.colormap_dim, s2.colormap_dim);
123            println!("Objects:   {}", s2.objects.len());
124            println!("Units:     {}", s2.units.len());
125            println!("Items:     {}", s2.items.len());
126            println!("Infos:     {}", s2.infos.len());
127            println!("States:    {}", s2.states.len());
128            println!("Exts:      {}", s2.extensions.len());
129            if let Some(lt) = s2.extensions.iter().find(|e| e.key == "lasttime") {
130                println!("SaveTime:  {}", lt.stuff);
131            }
132            if !s2.password.decoded.is_empty() {
133                println!("Password:  {} (key={})", s2.password.decoded, s2.password.key);
134            }
135        }
136        "osmap" => {
137            let data = std::fs::read_to_string(input)?;
138            let osmap: OpenStrandedMap = ron::from_str(&data)
139                .map_err(|e| anyhow::anyhow!("failed to parse .osmap: {e}"))?;
140            println!("=== .osmap Info ===");
141            println!("File:      {}", input.display());
142            println!("Name:      {}", osmap.meta.name);
143            println!("Engine:    {}", osmap.meta.engine_version);
144            println!("MapVer:    {}", osmap.meta.map_version);
145            println!("Source:    {}", osmap.meta.source_file);
146            if let Some(hdr) = &osmap.meta.s2_header {
147                println!("S2Ver:     {}", hdr.version);
148                println!("S2Author:  {}", hdr.author);
149            }
150            println!("Terrain:   {}×{}", osmap.terrain.size, osmap.terrain.size);
151            println!("Entities:  {}", osmap.entities.len());
152            let by_class = |class: &str| -> usize {
153                osmap.entities.iter().filter(|e| e.class == class).count()
154            };
155            println!("  objects: {}", by_class("object"));
156            println!("  units:   {}", by_class("unit"));
157            println!("  items:   {}", by_class("item"));
158            println!("  infos:   {}", by_class("info"));
159            if let Some(cm) = &osmap.colormap {
160                println!("Colormap:  {}×{}", cm.dim, cm.dim);
161            } else {
162                println!("Colormap:  (none)");
163            }
164            if let Some(gr) = &osmap.grass {
165                let grass_count = gr.values.iter().filter(|&&v| v != 0).count();
166                println!("Grass:     {}×{} ({} cells filled)", gr.dim, gr.dim, grass_count);
167            } else {
168                println!("Grass:     (none)");
169            }
170            println!("Scripts:   {}", osmap.scripts.len());
171            println!("Env Vars:  {}", osmap.environment.len());
172            if osmap.environment.contains_key("password") {
173                println!("Password:  {}", osmap.environment.get("password").unwrap());
174            }
175        }
176        _ => {
177            anyhow::bail!("unsupported format: .{ext} (expected .s2, .map, or .osmap)");
178        }
179    }
180
181    Ok(())
182}
183
184