//! Atlas CLI — query and manage celestial bodies and stations in systems.db. //! //! # Usage //! //! ```sh //! # Via wrapper script (recommended): //! tooling/atlas list-bodies --system "GJ 15A" //! tooling/atlas list-bodies --type planet --inhabited //! tooling/atlas show-system "GJ 15A" //! tooling/atlas show-body "GJ 15Ab" //! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab" //! tooling/atlas stats //! tooling/atlas populate # bulk classifier pass //! //! # Direct: //! cargo run --bin atlas -- [args] //! ``` use std::path::PathBuf; use std::process; use clap::{Parser, Subcommand}; use rusqlite::{params, Connection}; use serde::Serialize; // --------------------------------------------------------------------------- // CLI structure // --------------------------------------------------------------------------- #[derive(Parser)] #[command( name = "atlas", about = "Query and manage celestial bodies and stations in systems.db" )] struct Cli { /// Path to the SQLite database (default: auto-detect from worktree) #[arg(long, global = true)] db: Option, #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Show full system hierarchy (star → bodies → stations) ShowSystem { /// System ID (e.g., "GJ 15A") system_id: String, }, /// Show a single body's details ShowBody { /// Body ID (e.g., "GJ 15Ab") body_id: String, }, /// Show a single station's details ShowStation { /// Station ID (e.g., "GJ 15Ab-S1") station_id: String, }, /// List bodies with optional filters ListBodies { #[arg(long)] system: Option, #[arg(long, value_name = "TYPE")] r#type: Option, #[arg(long)] inhabited: bool, #[arg(long)] unnamed: bool, }, /// List stations with optional filters ListStations { #[arg(long)] system: Option, #[arg(long, value_name = "TYPE")] r#type: Option, }, /// Add a body record AddBody { #[arg(long)] id: String, #[arg(long)] system: String, #[arg(long, value_name = "TYPE")] r#type: String, #[arg(long)] orbit: Option, #[arg(long)] name: Option, #[arg(long)] parent: Option, #[arg(long)] mass_class: Option, #[arg(long)] atmosphere: Option, #[arg(long)] gravity: Option, #[arg(long)] biome: Option, #[arg(long)] inhabited: bool, #[arg(long)] population: Option, }, /// Add a station record AddStation { #[arg(long)] id: String, #[arg(long)] system: String, #[arg(long)] orbits: Option, #[arg(long, value_name = "TYPE")] r#type: String, #[arg(long)] name: Option, #[arg(long)] population: Option, #[arg(long)] docking: Option, #[arg(long)] gate: bool, }, /// Database statistics Stats, /// Generate a body/station proposal for a single system (writes JSON file for review) Author { /// System ID (e.g., "GJ 71") system_id: String, /// Output directory for proposal JSON (default: docs/atlas/proposals/) #[arg(long, default_value = "docs/atlas/proposals")] outdir: String, /// Path to wiki directory (default: wiki/star-systems/) #[arg(long, default_value = "wiki/star-systems")] wiki: String, }, /// Commit an approved proposal JSON to the database CommitSystem { /// Path to the proposal JSON file path: String, }, /// Wipe all bodies and stations for a single system WipeSystem { /// System ID system_id: String, }, /// List systems with optional filters ListSystems { /// Filter by geographic sector (e.g., "west_reach", "east_reach") #[arg(long)] sector: Option, /// Filter by hop distance #[arg(long)] hop: Option, /// Only show systems that have bodies authored #[arg(long)] finished: bool, /// Only show systems that have NO bodies yet #[arg(long)] unfinished: bool, }, /// List systems that have no bodies yet Unfinished { /// Filter by geographic sector #[arg(long)] sector: Option, }, /// List unfinished systems at a specific hop distance (or next available hop) Next { /// Hop distance (omit to find the lowest hop with unfinished systems) hop: Option, /// Filter by geographic sector #[arg(long)] sector: Option, }, /// Sync body/station data into wiki page for a system (or all systems) SyncWiki { /// System ID (omit for all systems with bodies) system_id: Option, /// Path to wiki directory (default: wiki/star-systems/) #[arg(long, default_value = "wiki/star-systems")] wiki: String, }, } // --------------------------------------------------------------------------- // Output types // --------------------------------------------------------------------------- #[derive(Serialize)] struct BodyRow { body_id: String, system_id: String, parent_body_id: Option, body_type: String, orbit_index: Option, proper_name: Option, mass_class: Option, atmosphere: Option, surface_gravity: Option, biome_summary: Option, hydrosphere: Option, inhabited: bool, population: i64, economic_role: Option, cultural_corridor: Option, industrial_corridor: Option, } #[derive(Serialize)] struct StationRow { station_id: String, system_id: String, orbits_body_id: Option, station_type: String, proper_name: Option, population: i64, economic_role: Option, governance_type: Option, docking_class: Option, has_gate_infrastructure: bool, district_count: i32, } #[derive(Serialize)] struct SystemSummary { system_id: String, proper_name: Option, star_type: Option, geographic_sector: Option, habitable_planet_count: Option, inhabited_planet_count: Option, bodies: Vec, stations: Vec, } #[derive(Serialize)] struct StatsOutput { systems: i64, bodies: i64, bodies_by_type: Vec, inhabited_bodies: i64, stations: i64, stations_by_type: Vec, systems_with_bodies: i64, systems_without_bodies: i64, } #[derive(Serialize)] struct TypeCount { r#type: String, count: i64, } #[derive(Serialize, serde::Deserialize, Clone)] struct ProposalBody { body_id: String, proper_name: Option, body_type: String, orbit_index: i32, parent_body_id: Option, inhabited: bool, population: Option, mass_class: Option, surface_gravity: Option, orbital_period_days: Option, rotation_period_hours: Option, atmosphere: Option, biome_summary: Option, hydrosphere: Option, economic_role: Option, settlement_pattern: Option, industrial_corridor: Option, notes: String, } #[derive(Serialize, serde::Deserialize, Clone)] struct ProposalStation { station_id: String, proper_name: Option, orbits_body_id: String, station_type: String, population: Option, economic_role: Option, docking_class: Option, has_gate_infrastructure: bool, notes: String, } #[derive(Serialize, serde::Deserialize)] struct SystemProposal { system_id: String, proper_name: Option, star_type: Option, spectral_class: Option, wiki_data: WikiData, bodies: Vec, stations: Vec, } #[derive(Serialize, serde::Deserialize)] struct WikiData { habitable_count: i32, inhabited_count: i32, has_gas_giant: bool, has_asteroid_belt: bool, has_horizon_station: bool, raw_bodies_line: Option, } // --------------------------------------------------------------------------- // Database helpers // --------------------------------------------------------------------------- fn resolve_db_path(explicit: Option) -> PathBuf { if let Some(p) = explicit { return p; } // Walk up from CWD looking for server/data/systems.db let mut dir = std::env::current_dir().expect("Cannot determine CWD"); loop { let candidate = dir.join("server").join("data").join("systems.db"); if candidate.exists() { return candidate; } if !dir.pop() { break; } } eprintln!("error: cannot find server/data/systems.db — pass --db explicitly"); process::exit(1); } fn open_db(path: &PathBuf) -> Connection { let conn = Connection::open(path).unwrap_or_else(|e| { eprintln!("error: cannot open {}: {}", path.display(), e); process::exit(1); }); conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .unwrap(); conn } // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- fn cmd_show_system(conn: &Connection, system_id: &str) { let mut stmt = conn .prepare( "SELECT system_id, proper_name, star_type, geographic_sector, habitable_planet_count, inhabited_planet_count FROM star_systems WHERE system_id = ?1", ) .unwrap(); let sys: Option = stmt .query_row(params![system_id], |row| { Ok(SystemSummary { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, geographic_sector: row.get(3)?, habitable_planet_count: row.get(4)?, inhabited_planet_count: row.get(5)?, bodies: Vec::new(), stations: Vec::new(), }) }) .ok(); let Some(mut sys) = sys else { eprintln!("error: system '{}' not found", system_id); process::exit(1); }; sys.bodies = query_bodies(conn, Some(system_id), None, false, false); sys.stations = query_stations(conn, Some(system_id), None); println!("{}", serde_json::to_string_pretty(&sys).unwrap()); } fn cmd_show_body(conn: &Connection, body_id: &str) { let row = conn .query_row( "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, proper_name, mass_class, atmosphere, surface_gravity, biome_summary, hydrosphere, inhabited, population, economic_role, cultural_corridor, industrial_corridor FROM bodies WHERE body_id = ?1", params![body_id], |row| { Ok(BodyRow { body_id: row.get(0)?, system_id: row.get(1)?, parent_body_id: row.get(2)?, body_type: row.get(3)?, orbit_index: row.get(4)?, proper_name: row.get(5)?, mass_class: row.get(6)?, atmosphere: row.get(7)?, surface_gravity: row.get(8)?, biome_summary: row.get(9)?, hydrosphere: row.get(10)?, inhabited: row.get::<_, i32>(11)? != 0, population: row.get::<_, Option>(12)?.unwrap_or(0), economic_role: row.get(13)?, cultural_corridor: row.get(14)?, industrial_corridor: row.get(15)?, }) }, ) .unwrap_or_else(|_| { eprintln!("error: body '{}' not found", body_id); process::exit(1); }); // Also fetch stations orbiting this body let stations = query_stations_for_body(conn, body_id); #[derive(Serialize)] struct BodyDetail { #[serde(flatten)] body: BodyRow, stations: Vec, } let detail = BodyDetail { body: row, stations, }; println!("{}", serde_json::to_string_pretty(&detail).unwrap()); } fn cmd_show_station(conn: &Connection, station_id: &str) { let row = conn .query_row( "SELECT station_id, system_id, orbits_body_id, station_type, proper_name, population, economic_role, governance_type, docking_class, has_gate_infrastructure, district_count FROM stations WHERE station_id = ?1", params![station_id], |row| { Ok(StationRow { station_id: row.get(0)?, system_id: row.get(1)?, orbits_body_id: row.get(2)?, station_type: row.get(3)?, proper_name: row.get(4)?, population: row.get::<_, Option>(5)?.unwrap_or(0), economic_role: row.get(6)?, governance_type: row.get(7)?, docking_class: row.get(8)?, has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, district_count: row.get::<_, Option>(10)?.unwrap_or(1), }) }, ) .unwrap_or_else(|_| { eprintln!("error: station '{}' not found", station_id); process::exit(1); }); println!("{}", serde_json::to_string_pretty(&row).unwrap()); } fn query_bodies( conn: &Connection, system: Option<&str>, body_type: Option<&str>, inhabited_only: bool, unnamed_only: bool, ) -> Vec { let mut sql = String::from( "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, proper_name, mass_class, atmosphere, surface_gravity, biome_summary, hydrosphere, inhabited, population, economic_role, cultural_corridor, industrial_corridor FROM bodies WHERE 1=1", ); let mut param_values: Vec> = Vec::new(); if let Some(s) = system { sql.push_str(" AND system_id = ?"); param_values.push(Box::new(s.to_string())); } if let Some(t) = body_type { sql.push_str(" AND body_type = ?"); param_values.push(Box::new(t.to_string())); } if inhabited_only { sql.push_str(" AND inhabited = 1"); } if unnamed_only { sql.push_str(" AND proper_name IS NULL"); } sql.push_str(" ORDER BY system_id, orbit_index"); let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql).unwrap(); let rows = stmt .query_map(params_ref.as_slice(), |row| { Ok(BodyRow { body_id: row.get(0)?, system_id: row.get(1)?, parent_body_id: row.get(2)?, body_type: row.get(3)?, orbit_index: row.get(4)?, proper_name: row.get(5)?, mass_class: row.get(6)?, atmosphere: row.get(7)?, surface_gravity: row.get(8)?, biome_summary: row.get(9)?, hydrosphere: row.get(10)?, inhabited: row.get::<_, i32>(11)? != 0, population: row.get::<_, Option>(12)?.unwrap_or(0), economic_role: row.get(13)?, cultural_corridor: row.get(14)?, industrial_corridor: row.get(15)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect(); rows } fn query_stations( conn: &Connection, system: Option<&str>, station_type: Option<&str>, ) -> Vec { let mut sql = String::from( "SELECT station_id, system_id, orbits_body_id, station_type, proper_name, population, economic_role, governance_type, docking_class, has_gate_infrastructure, district_count FROM stations WHERE 1=1", ); let mut param_values: Vec> = Vec::new(); if let Some(s) = system { sql.push_str(" AND system_id = ?"); param_values.push(Box::new(s.to_string())); } if let Some(t) = station_type { sql.push_str(" AND station_type = ?"); param_values.push(Box::new(t.to_string())); } sql.push_str(" ORDER BY system_id, station_id"); let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql).unwrap(); stmt.query_map(params_ref.as_slice(), |row| { Ok(StationRow { station_id: row.get(0)?, system_id: row.get(1)?, orbits_body_id: row.get(2)?, station_type: row.get(3)?, proper_name: row.get(4)?, population: row.get::<_, Option>(5)?.unwrap_or(0), economic_role: row.get(6)?, governance_type: row.get(7)?, docking_class: row.get(8)?, has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, district_count: row.get::<_, Option>(10)?.unwrap_or(1), }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec { let mut stmt = conn .prepare( "SELECT station_id, system_id, orbits_body_id, station_type, proper_name, population, economic_role, governance_type, docking_class, has_gate_infrastructure, district_count FROM stations WHERE orbits_body_id = ?1 ORDER BY station_id", ) .unwrap(); stmt.query_map(params![body_id], |row| { Ok(StationRow { station_id: row.get(0)?, system_id: row.get(1)?, orbits_body_id: row.get(2)?, station_type: row.get(3)?, proper_name: row.get(4)?, population: row.get::<_, Option>(5)?.unwrap_or(0), economic_role: row.get(6)?, governance_type: row.get(7)?, docking_class: row.get(8)?, has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, district_count: row.get::<_, Option>(10)?.unwrap_or(1), }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } fn cmd_list_bodies( conn: &Connection, system: Option<&str>, body_type: Option<&str>, inhabited: bool, unnamed: bool, ) { let bodies = query_bodies(conn, system, body_type, inhabited, unnamed); println!("{}", serde_json::to_string_pretty(&bodies).unwrap()); } fn cmd_list_stations(conn: &Connection, system: Option<&str>, station_type: Option<&str>) { let stations = query_stations(conn, system, station_type); println!("{}", serde_json::to_string_pretty(&stations).unwrap()); } fn cmd_add_body(conn: &Connection, args: &Commands) { let Commands::AddBody { id, system, r#type, orbit, name, parent, mass_class, atmosphere, gravity, biome, inhabited, population, } = args else { unreachable!() }; conn.execute( "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, proper_name, mass_class, atmosphere, surface_gravity, biome_summary, inhabited, population) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ id, system, parent, r#type, orbit, name, mass_class, atmosphere, gravity, biome, *inhabited as i32, population.unwrap_or(0), ], ) .unwrap_or_else(|e| { eprintln!("error: {}", e); process::exit(1); }); println!(r#"{{"ok": true, "body_id": "{}"}}"#, id); } fn cmd_add_station(conn: &Connection, args: &Commands) { let Commands::AddStation { id, system, orbits, r#type, name, population, docking, gate, } = args else { unreachable!() }; conn.execute( "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, proper_name, population, docking_class, has_gate_infrastructure) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ id, system, orbits, r#type, name, population.unwrap_or(0), docking, *gate as i32, ], ) .unwrap_or_else(|e| { eprintln!("error: {}", e); process::exit(1); }); println!(r#"{{"ok": true, "station_id": "{}"}}"#, id); } fn cmd_stats(conn: &Connection) { let systems: i64 = conn .query_row("SELECT COUNT(*) FROM star_systems", [], |r| r.get(0)) .unwrap(); let bodies: i64 = conn .query_row("SELECT COUNT(*) FROM bodies", [], |r| r.get(0)) .unwrap(); let inhabited_bodies: i64 = conn .query_row("SELECT COUNT(*) FROM bodies WHERE inhabited = 1", [], |r| { r.get(0) }) .unwrap(); let stations: i64 = conn .query_row("SELECT COUNT(*) FROM stations", [], |r| r.get(0)) .unwrap(); let systems_with_bodies: i64 = conn .query_row("SELECT COUNT(DISTINCT system_id) FROM bodies", [], |r| { r.get(0) }) .unwrap(); let mut bodies_by_type = Vec::new(); { let mut stmt = conn .prepare("SELECT body_type, COUNT(*) FROM bodies GROUP BY body_type ORDER BY body_type") .unwrap(); let rows = stmt .query_map([], |row| { Ok(TypeCount { r#type: row.get(0)?, count: row.get(1)?, }) }) .unwrap(); for r in rows.flatten() { bodies_by_type.push(r); } } let mut stations_by_type = Vec::new(); { let mut stmt = conn .prepare("SELECT station_type, COUNT(*) FROM stations GROUP BY station_type ORDER BY station_type") .unwrap(); let rows = stmt .query_map([], |row| { Ok(TypeCount { r#type: row.get(0)?, count: row.get(1)?, }) }) .unwrap(); for r in rows.flatten() { stations_by_type.push(r); } } let stats = StatsOutput { systems, bodies, bodies_by_type, inhabited_bodies, stations, stations_by_type, systems_with_bodies, systems_without_bodies: systems - systems_with_bodies, }; println!("{}", serde_json::to_string_pretty(&stats).unwrap()); } fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option, i32, i32) { // Try to find the wiki page for this system let sid_slug = system_id.replace(' ', "-"); let wiki_path = std::path::Path::new(wiki_dir) .join(&sid_slug) .join("index.md"); if !wiki_path.exists() { return (None, 0, 0); } let content = std::fs::read_to_string(&wiki_path).unwrap_or_default(); // Look for "| **Bodies** | X habitable · Y inhabited |" for line in content.lines() { if line.contains("**Bodies**") { let raw = line.to_string(); let mut hab = 0i32; let mut inh = 0i32; // Parse "N habitable" if let Some(pos) = line.find("habitable") { let before = &line[..pos]; let parts: Vec<&str> = before.split_whitespace().collect(); if let Some(n) = parts.last() { hab = n.parse().unwrap_or(0); } } // Parse "N inhabited" if let Some(pos) = line.find("inhabited") { let before = &line[..pos]; let parts: Vec<&str> = before.split_whitespace().collect(); if let Some(n) = parts.last() { inh = n.parse().unwrap_or(0); } } return (Some(raw), hab, inh); } } (None, 0, 0) } fn generate_body_matrix( system_id: &str, star_type: Option<&str>, spectral: Option<&str>, wiki_hab: i32, wiki_inh: i32, db_gas_giant: bool, db_belt: bool, has_horizon: bool, ) -> (Vec, Vec) { let sid = system_id.replace(' ', ""); let mut bodies = Vec::new(); let mut stations = Vec::new(); let mut orbit = 1; // Determine planet count from star type if wiki doesn't specify let spectral_char = spectral.and_then(|s| s.chars().next()).unwrap_or('M'); // Base planet count by spectral type. // Sol has 8. TRAPPIST-1 (M-dwarf) has 7. Minimum 6 for any star. let total_planets = if wiki_hab > 0 || wiki_inh > 0 { // Wiki has data — use it as the inhabited/habitable core, pad to realistic count let known = wiki_hab.max(wiki_inh); match spectral_char { 'O' | 'B' | 'A' => (known + 4).max(6), // hot stars — fewer but still 6+ 'F' => (known + 5).max(8), // bright — wide system, 8+ 'G' => (known + 5).max(8), // sol-like — 8 is baseline 'K' => (known + 4).max(7), // cooler — 7+ typical 'M' => (known + 4).max(6), // compact but TRAPPIST-1 has 7 _ => (known + 4).max(6), } } else { // No wiki data — generate realistic count match spectral_char { 'O' | 'B' | 'A' => 6, 'F' => 8, 'G' => 8, 'K' => 7, 'M' => 6, _ => 6, } }; // Determine if binary — affects naming let is_binary = star_type == Some("binary"); // Place inner barren rocky planets let inner_barren = if total_planets > wiki_inh + 1 { 1 } else { 0 }; for _ in 0..inner_barren { let letter = (b'b' + orbit as u8 - 1) as char; let body_id = if is_binary { format!("{}A{}", sid, letter) // circumbinary assumed for now } else { format!("{}{}", sid, letter) }; bodies.push(ProposalBody { body_id, body_type: "planet".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: Some("terrestrial".into()), surface_gravity: Some(0.38), orbital_period_days: Some(88.0), rotation_period_hours: Some(1408.0), atmosphere: Some("none".into()), biome_summary: Some("barren".into()), hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "inner rocky, uninhabited".into(), }); orbit += 1; } // Place inhabited planets for i in 0..wiki_inh { let letter = (b'b' + orbit as u8 - 1) as char; let body_id = if is_binary { format!("{}A{}", sid, letter) } else { format!("{}{}", sid, letter) }; bodies.push(ProposalBody { body_id, body_type: "planet".into(), orbit_index: orbit, parent_body_id: None, inhabited: true, proper_name: None, population: None, mass_class: Some("terrestrial".into()), surface_gravity: Some(0.9), orbital_period_days: Some(365.0), rotation_period_hours: Some(24.0), atmosphere: Some("breathable".into()), biome_summary: Some("temperate".into()), hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: format!("inhabited planet {}/{}", i + 1, wiki_inh), }); orbit += 1; } // Place habitable-but-uninhabited planets (hab > inh) let hab_only = (wiki_hab - wiki_inh).max(0); for _ in 0..hab_only { let letter = (b'b' + orbit as u8 - 1) as char; let body_id = if is_binary { format!("{}A{}", sid, letter) } else { format!("{}{}", sid, letter) }; bodies.push(ProposalBody { body_id, body_type: "planet".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: Some("terrestrial".into()), surface_gravity: Some(0.85), orbital_period_days: Some(400.0), rotation_period_hours: Some(26.0), atmosphere: Some("breathable".into()), biome_summary: Some("temperate".into()), hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "habitable, uninhabited".into(), }); orbit += 1; } // Outer rocky/ice planets to fill remaining count let placed = inner_barren + wiki_inh + hab_only; let remaining_planets = (total_planets - placed).max(0); for _ in 0..remaining_planets { let letter = (b'b' + orbit as u8 - 1) as char; let body_id = if is_binary { format!("{}A{}", sid, letter) } else { format!("{}{}", sid, letter) }; bodies.push(ProposalBody { body_id, body_type: "planet".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: Some("terrestrial".into()), surface_gravity: Some(0.5), orbital_period_days: Some(2000.0), rotation_period_hours: Some(18.0), atmosphere: Some("thin".into()), biome_summary: Some("frozen".into()), hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "outer rocky/ice, uninhabited".into(), }); orbit += 1; } // Asteroid belt if db_belt { bodies.push(ProposalBody { body_id: format!("{}-belt", sid), body_type: "asteroid_belt".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: None, surface_gravity: None, orbital_period_days: None, rotation_period_hours: None, atmosphere: None, biome_summary: None, hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "asteroid belt".into(), }); orbit += 1; } // Gas giant (with up to 2 moons as default) if db_gas_giant { let letter = (b'b' + orbit as u8 - 1) as char; let gg_id = if is_binary { format!("{}A{}", sid, letter) } else { format!("{}{}", sid, letter) }; bodies.push(ProposalBody { body_id: gg_id.clone(), body_type: "gas_giant".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: Some("gas_giant".into()), surface_gravity: None, orbital_period_days: Some(4300.0), rotation_period_hours: Some(10.0), atmosphere: Some("dense".into()), biome_summary: None, hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "gas giant".into(), }); // 2 default moons for m in 1..=2 { bodies.push(ProposalBody { body_id: format!("{}-{}", gg_id, m), body_type: "moon".into(), orbit_index: m, parent_body_id: Some(gg_id.clone()), inhabited: false, proper_name: None, population: None, mass_class: Some("dwarf".into()), surface_gravity: Some(0.1), orbital_period_days: Some(3.5 * m as f64), rotation_period_hours: Some(3.5 * 24.0 * m as f64), atmosphere: Some("none".into()), biome_summary: Some("barren".into()), hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: format!("moon {} of gas giant", m), }); } orbit += 1; } // Oort cloud (always) let oort_id = format!("{}-oort", sid); bodies.push(ProposalBody { body_id: oort_id.clone(), body_type: "oort_cloud".into(), orbit_index: orbit, parent_body_id: None, inhabited: false, proper_name: None, population: None, mass_class: None, surface_gravity: None, orbital_period_days: None, rotation_period_hours: None, atmosphere: None, biome_summary: None, hydrosphere: None, economic_role: None, settlement_pattern: None, industrial_corridor: None, notes: "oort cloud".into(), }); // Horizon station if has_horizon { stations.push(ProposalStation { station_id: format!("{}-oort-S1", sid), proper_name: None, orbits_body_id: oort_id, station_type: "horizon".into(), population: None, economic_role: Some("transit".into()), docking_class: Some("major".into()), has_gate_infrastructure: true, notes: "horizon station — gate infrastructure".into(), }); } (bodies, stations) } fn cmd_author(conn: &Connection, system_id: &str, outdir: &str, wiki_dir: &str) { // Get system data from DB let sys = conn .query_row( "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, s.habitable_planet_count, s.inhabited_planet_count, s.asteroid_belt, s.gas_giant, g.horizon_station FROM star_systems s LEFT JOIN system_gates g ON s.system_id = g.system_id WHERE s.system_id = ?1", params![system_id], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, )) }, ) .unwrap_or_else(|_| { eprintln!("error: system '{}' not found", system_id); process::exit(1); }); let (sid, proper_name, star_type, spectral, db_hab, db_inh, db_belt, db_gg, db_horizon) = sys; // Parse wiki for body info let (raw_line, wiki_hab, wiki_inh) = parse_wiki_bodies_line(wiki_dir, &sid); // Use wiki data preferentially, fall back to DB let hab = if wiki_hab > 0 { wiki_hab } else { db_hab.unwrap_or(0) }; let inh = if wiki_inh > 0 { wiki_inh } else { db_inh.unwrap_or(0) }; let has_belt = db_belt.unwrap_or(0) != 0; let has_gg = db_gg.unwrap_or(0) != 0; let has_horizon = db_horizon.unwrap_or(0) != 0; let wiki_data = WikiData { habitable_count: hab, inhabited_count: inh, has_gas_giant: has_gg, has_asteroid_belt: has_belt, has_horizon_station: has_horizon, raw_bodies_line: raw_line, }; let (bodies, stations) = generate_body_matrix( &sid, star_type.as_deref(), spectral.as_deref(), hab, inh, has_gg, has_belt, has_horizon, ); let proposal = SystemProposal { system_id: sid.clone(), proper_name, star_type, spectral_class: spectral, wiki_data, bodies, stations, }; // Write proposal JSON let sid_compact = sid.replace(' ', ""); std::fs::create_dir_all(outdir).unwrap(); let path = std::path::Path::new(outdir).join(format!("{}.json", sid_compact)); let json = serde_json::to_string_pretty(&proposal).unwrap(); std::fs::write(&path, &json).unwrap(); eprintln!("Proposal written to {}", path.display()); println!("{}", json); } fn cmd_commit_system(conn: &Connection, path: &str) { let content = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("error: cannot read {}: {}", path, e); process::exit(1); }); let proposal: SystemProposal = serde_json::from_str(&content).unwrap_or_else(|e| { eprintln!("error: invalid proposal JSON: {}", e); process::exit(1); }); // Check for existing bodies let existing: i64 = conn .query_row( "SELECT COUNT(*) FROM bodies WHERE system_id = ?1", params![proposal.system_id], |r| r.get(0), ) .unwrap(); if existing > 0 { eprintln!( "error: system '{}' already has {} bodies. Use wipe-system first.", proposal.system_id, existing ); process::exit(1); } let tx = conn.unchecked_transaction().unwrap(); for body in &proposal.bodies { tx.execute( "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, proper_name, inhabited, population, mass_class, surface_gravity, orbital_period_days, rotation_period_hours, atmosphere, biome_summary, hydrosphere, economic_role, settlement_pattern, industrial_corridor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", params![ body.body_id, proposal.system_id, body.parent_body_id, body.body_type, body.orbit_index, body.proper_name, body.inhabited as i32, body.population.unwrap_or(0), body.mass_class, body.surface_gravity, body.orbital_period_days, body.rotation_period_hours, body.atmosphere, body.biome_summary, body.hydrosphere, body.economic_role, body.settlement_pattern, body.industrial_corridor, ], ) .unwrap_or_else(|e| { eprintln!("error inserting body '{}': {}", body.body_id, e); process::exit(1); }); } for station in &proposal.stations { tx.execute( "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, proper_name, population, economic_role, docking_class, has_gate_infrastructure) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ station.station_id, proposal.system_id, station.orbits_body_id, station.station_type, station.proper_name, station.population.unwrap_or(0), station.economic_role, station.docking_class, station.has_gate_infrastructure as i32, ], ) .unwrap_or_else(|e| { eprintln!("error inserting station '{}': {}", station.station_id, e); process::exit(1); }); } // Update star_systems counts from the actual body data let habitable_count: i32 = proposal .bodies .iter() .filter(|b| { b.atmosphere.as_deref() == Some("breathable") && (b.body_type == "planet" || b.body_type == "moon") }) .count() as i32; let inhabited_count: i32 = proposal.bodies.iter().filter(|b| b.inhabited).count() as i32 + proposal .stations .iter() .filter(|s| s.population.unwrap_or(0) > 0 || s.station_type == "horizon") .count() as i32; let has_gas_giant: i32 = proposal.bodies.iter().any(|b| b.body_type == "gas_giant") as i32; let has_belt: i32 = proposal .bodies .iter() .any(|b| b.body_type == "asteroid_belt") as i32; tx.execute( "UPDATE star_systems SET habitable_planet_count = ?1, inhabited_planet_count = ?2, gas_giant = ?3, asteroid_belt = ?4 WHERE system_id = ?5", params![ habitable_count, inhabited_count, has_gas_giant, has_belt, proposal.system_id, ], ) .unwrap(); tx.commit().unwrap(); #[derive(Serialize)] struct CommitResult { system_id: String, bodies_created: usize, stations_created: usize, habitable_count: i32, inhabited_count: i32, } let result = CommitResult { system_id: proposal.system_id, bodies_created: proposal.bodies.len(), stations_created: proposal.stations.len(), habitable_count, inhabited_count, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } fn cmd_wipe_system(conn: &Connection, system_id: &str) { let stations_deleted: usize = conn .execute( "DELETE FROM stations WHERE system_id = ?1", params![system_id], ) .unwrap(); let bodies_deleted: usize = conn .execute( "DELETE FROM bodies WHERE system_id = ?1", params![system_id], ) .unwrap(); #[derive(Serialize)] struct WipeResult { system_id: String, bodies_deleted: usize, stations_deleted: usize, } let result = WipeResult { system_id: system_id.to_string(), bodies_deleted, stations_deleted, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } fn cmd_list_systems( conn: &Connection, sector: Option<&str>, hop: Option, finished: bool, unfinished: bool, ) { // Build query dynamically based on filters let mut conditions: Vec = Vec::new(); let mut param_values: Vec> = Vec::new(); let mut idx = 1; if let Some(s) = sector { conditions.push(format!("s.geographic_sector = ?{idx}")); param_values.push(Box::new(s.to_string())); idx += 1; } if let Some(h) = hop { conditions.push(format!("g.hop_distance_from_gateway = ?{idx}")); param_values.push(Box::new(h)); idx += 1; } let _ = idx; // suppress unused warning if finished { conditions.push("s.system_id IN (SELECT DISTINCT system_id FROM bodies)".to_string()); } if unfinished { conditions.push("s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)".to_string()); } let where_clause = if conditions.is_empty() { String::new() } else { format!(" WHERE {}", conditions.join(" AND ")) }; let sql = format!( "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, g.gate_topology, s.geographic_sector, g.hop_distance_from_gateway, s.habitable_planet_count, s.inhabited_planet_count, e.population FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id LEFT JOIN system_economy e ON s.system_id = e.system_id {where_clause} ORDER BY g.hop_distance_from_gateway, s.system_id" ); let mut stmt = conn.prepare(&sql).unwrap(); let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); #[derive(Serialize)] struct ListSystem { system_id: String, proper_name: Option, star_type: Option, spectral_class: Option, gate_topology: Option, geographic_sector: Option, hop_distance: Option, habitable_planet_count: Option, inhabited_planet_count: Option, population: Option, } let systems: Vec = stmt .query_map(params_refs.as_slice(), |row| { Ok(ListSystem { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, spectral_class: row.get(3)?, gate_topology: row.get(4)?, geographic_sector: row.get(5)?, hop_distance: row.get(6)?, habitable_planet_count: row.get(7)?, inhabited_planet_count: row.get(8)?, population: row.get(9)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect(); #[derive(Serialize)] struct ListResult { count: usize, #[serde(skip_serializing_if = "Option::is_none")] sector: Option, #[serde(skip_serializing_if = "Option::is_none")] hop: Option, systems: Vec, } let result = ListResult { count: systems.len(), sector: sector.map(|s| s.to_string()), hop, systems, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } fn cmd_unfinished(conn: &Connection, sector: Option<&str>) { let sql = if sector.is_some() { "SELECT s.system_id, s.proper_name, s.star_type FROM star_systems s WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) AND s.geographic_sector = ?1 ORDER BY s.system_id" } else { "SELECT s.system_id, s.proper_name, s.star_type FROM star_systems s WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) ORDER BY s.system_id" }; let mut stmt = conn.prepare(sql).unwrap(); #[derive(Serialize)] struct UnfinishedSystem { system_id: String, proper_name: Option, star_type: Option, } let rows: Vec = if let Some(s) = sector { stmt.query_map(params![s], |row| { Ok(UnfinishedSystem { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } else { stmt.query_map([], |row| { Ok(UnfinishedSystem { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() }; #[derive(Serialize)] struct UnfinishedResult { count: usize, sector: Option, systems: Vec, } let result = UnfinishedResult { count: rows.len(), sector: sector.map(|s| s.to_string()), systems: rows, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } fn cmd_next(conn: &Connection, hop: Option, sector: Option<&str>) { // Find the target hop — either specified or the lowest with unfinished systems let target_hop: i32 = if let Some(h) = hop { h } else { let sql = if sector.is_some() { "SELECT MIN(g.hop_distance_from_gateway) FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) AND s.geographic_sector = ?1" } else { "SELECT MIN(g.hop_distance_from_gateway) FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)" }; if let Some(s) = sector { conn.query_row(sql, params![s], |r| r.get::<_, Option>(0)) } else { conn.query_row(sql, [], |r| r.get::<_, Option>(0)) } .unwrap() .unwrap_or(-1) }; if target_hop < 0 { println!( r#"{{"hop": null, "count": 0, "systems": [], "message": "all systems have bodies"}}"# ); return; } #[derive(Serialize)] struct NextSystem { system_id: String, proper_name: Option, star_type: Option, spectral_class: Option, gate_topology: Option, geographic_sector: Option, habitable_planet_count: Option, inhabited_planet_count: Option, population: Option, } let sql = if sector.is_some() { "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, g.gate_topology, s.geographic_sector, s.habitable_planet_count, s.inhabited_planet_count, e.population FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id LEFT JOIN system_economy e ON s.system_id = e.system_id WHERE g.hop_distance_from_gateway = ?1 AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) AND s.geographic_sector = ?2 ORDER BY s.system_id" } else { "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, g.gate_topology, s.geographic_sector, s.habitable_planet_count, s.inhabited_planet_count, e.population FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id LEFT JOIN system_economy e ON s.system_id = e.system_id WHERE g.hop_distance_from_gateway = ?1 AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) ORDER BY s.system_id" }; let mut stmt = conn.prepare(sql).unwrap(); let systems: Vec = if let Some(s) = sector { stmt.query_map(params![target_hop, s], |row| { Ok(NextSystem { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, spectral_class: row.get(3)?, gate_topology: row.get(4)?, geographic_sector: row.get(5)?, habitable_planet_count: row.get(6)?, inhabited_planet_count: row.get(7)?, population: row.get(8)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } else { stmt.query_map(params![target_hop], |row| { Ok(NextSystem { system_id: row.get(0)?, proper_name: row.get(1)?, star_type: row.get(2)?, spectral_class: row.get(3)?, gate_topology: row.get(4)?, geographic_sector: row.get(5)?, habitable_planet_count: row.get(6)?, inhabited_planet_count: row.get(7)?, population: row.get(8)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() }; #[derive(Serialize)] struct NextResult { hop: i32, sector: Option, count: usize, systems: Vec, } let result = NextResult { hop: target_hop, sector: sector.map(|s| s.to_string()), count: systems.len(), systems, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } fn cmd_sync_wiki(conn: &Connection, system_id: Option<&str>, wiki_dir: &str) { // Get list of systems to sync let system_ids: Vec = if let Some(sid) = system_id { vec![sid.to_string()] } else { let mut stmt = conn .prepare("SELECT DISTINCT system_id FROM bodies ORDER BY system_id") .unwrap(); stmt.query_map([], |row| row.get::<_, String>(0)) .unwrap() .filter_map(|r| r.ok()) .collect() }; let mut synced = 0; for sid in &system_ids { let sid_slug = sid.replace(' ', "-"); let wiki_path = std::path::Path::new(wiki_dir) .join(&sid_slug) .join("index.md"); if !wiki_path.exists() { eprintln!("skip: {} — no wiki page at {}", sid, wiki_path.display()); continue; } // Query bodies let mut body_stmt = conn .prepare( "SELECT body_id, proper_name, body_type, orbit_index, parent_body_id, inhabited, population, mass_class, atmosphere, surface_gravity, orbital_period_days, rotation_period_hours, biome_summary, hydrosphere, economic_role, settlement_pattern, industrial_corridor FROM bodies WHERE system_id = ?1 ORDER BY CASE WHEN parent_body_id IS NULL THEN orbit_index ELSE 1000 + orbit_index END", ) .unwrap(); struct Body { id: String, name: Option, btype: String, orbit: i32, parent: Option, inhabited: bool, population: i64, mass_class: Option, atmosphere: Option, gravity: Option, orbital_days: Option, rotation_hours: Option, biome: Option, hydro: Option, econ: Option, settlement: Option, industrial: Option, } let bodies: Vec = body_stmt .query_map(params![sid], |row| { Ok(Body { id: row.get(0)?, name: row.get(1)?, btype: row.get(2)?, orbit: row.get::<_, Option>(3)?.unwrap_or(0), parent: row.get(4)?, inhabited: row.get::<_, i32>(5)? != 0, population: row.get::<_, Option>(6)?.unwrap_or(0), mass_class: row.get(7)?, atmosphere: row.get(8)?, gravity: row.get(9)?, orbital_days: row.get(10)?, rotation_hours: row.get(11)?, biome: row.get(12)?, hydro: row.get(13)?, econ: row.get(14)?, settlement: row.get(15)?, industrial: row.get(16)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect(); // Query stations let mut station_stmt = conn .prepare( "SELECT station_id, proper_name, orbits_body_id, station_type, population, economic_role, governance_type, docking_class, has_gate_infrastructure, district_count FROM stations WHERE system_id = ?1 ORDER BY station_id", ) .unwrap(); struct Station { id: String, name: Option, orbits: Option, stype: String, population: i64, econ: Option, governance: Option, docking: Option, gate: bool, districts: i32, } let stations: Vec = station_stmt .query_map(params![sid], |row| { Ok(Station { id: row.get(0)?, name: row.get(1)?, orbits: row.get(2)?, stype: row.get(3)?, population: row.get::<_, Option>(4)?.unwrap_or(0), econ: row.get(5)?, governance: row.get(6)?, docking: row.get(7)?, gate: row.get::<_, Option>(8)?.unwrap_or(0) != 0, districts: row.get::<_, Option>(9)?.unwrap_or(1), }) }) .unwrap() .filter_map(|r| r.ok()) .collect(); if bodies.is_empty() && stations.is_empty() { continue; } // Build the Celestial Bodies section let mut section = String::new(); section.push_str("## Celestial Bodies\n"); section .push_str("\n\n"); // Bodies table if !bodies.is_empty() { section.push_str("| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |\n"); section.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n"); // First pass: top-level bodies (no parent) for b in &bodies { if b.parent.is_some() { continue; } let name = b.name.as_deref().unwrap_or("—"); let pop = if b.population > 0 { format_population(b.population) } else { "—".to_string() }; let grav = b .gravity .map(|g| format!("{:.2}g", g)) .unwrap_or_else(|| "—".to_string()); let year = b .orbital_days .map(|d| format!("{:.0}", d)) .unwrap_or_else(|| "—".to_string()); let day = b .rotation_hours .map(|h| format!("{:.1}", h)) .unwrap_or_else(|| "—".to_string()); section.push_str(&format!( "| {} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", b.orbit, b.id, name, b.btype, if b.inhabited { "yes" } else { "no" }, pop, b.mass_class.as_deref().unwrap_or("—"), grav, year, day, b.atmosphere.as_deref().unwrap_or("—"), b.biome.as_deref().unwrap_or("—"), b.hydro.as_deref().unwrap_or("—"), b.econ.as_deref().unwrap_or("—"), b.settlement.as_deref().unwrap_or("—"), b.industrial.as_deref().unwrap_or("—"), )); // Child bodies (moons) for m in &bodies { if m.parent.as_deref() == Some(&b.id) { let mname = m.name.as_deref().unwrap_or("—"); let mpop = if m.population > 0 { format_population(m.population) } else { "—".to_string() }; let mgrav = m .gravity .map(|g| format!("{:.2}g", g)) .unwrap_or_else(|| "—".to_string()); let myear = m .orbital_days .map(|d| format!("{:.0}", d)) .unwrap_or_else(|| "—".to_string()); let mday = m .rotation_hours .map(|h| format!("{:.1}", h)) .unwrap_or_else(|| "—".to_string()); section.push_str(&format!( "| ↳ {}.{} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", b.orbit, m.orbit, m.id, mname, m.btype, if m.inhabited { "yes" } else { "no" }, mpop, m.mass_class.as_deref().unwrap_or("—"), mgrav, myear, mday, m.atmosphere.as_deref().unwrap_or("—"), m.biome.as_deref().unwrap_or("—"), m.hydro.as_deref().unwrap_or("—"), m.econ.as_deref().unwrap_or("—"), m.settlement.as_deref().unwrap_or("—"), m.industrial.as_deref().unwrap_or("—"), )); } } } section.push('\n'); } // Stations table if !stations.is_empty() { section.push_str("### Stations & Facilities\n\n"); section.push_str("| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |\n"); section.push_str("|---|---|---|---|---|---|---|---|---|---|\n"); for s in &stations { let name = s.name.as_deref().unwrap_or("—"); let orbits = s.orbits.as_deref().unwrap_or("—"); let pop = if s.population > 0 { format_population(s.population) } else { "—".to_string() }; section.push_str(&format!( "| `{}` | {} | {} | `{}` | {} | {} | {} | {} | {} | {} |\n", s.id, name, s.stype, orbits, pop, s.econ.as_deref().unwrap_or("—"), s.governance.as_deref().unwrap_or("—"), s.docking.as_deref().unwrap_or("—"), if s.gate { "yes" } else { "no" }, s.districts, )); } section.push('\n'); } // Read current wiki content let content = std::fs::read_to_string(&wiki_path).unwrap(); // Replace or insert the Celestial Bodies section let marker_start = "## Celestial Bodies"; let new_content = if let Some(start_pos) = content.find(marker_start) { // Find the next ## heading after the section (or end of file) let after = &content[start_pos + marker_start.len()..]; let end_offset = after .find("\n## ") .map(|p| start_pos + marker_start.len() + p + 1) .unwrap_or(content.len()); format!( "{}{}\n{}", &content[..start_pos], section.trim_end(), &content[end_offset..] ) } else { // Insert before ## Topology if it exists, otherwise before end of file if let Some(topo_pos) = content.find("\n## Topology") { let insert_pos = topo_pos + 1; // after the newline format!( "{}{}\n\n{}", &content[..insert_pos], section.trim_end(), &content[insert_pos..] ) } else { // No topology section — append at end format!("{}\n{}", content.trim_end(), section) } }; std::fs::write(&wiki_path, new_content).unwrap(); eprintln!("synced: {} → {}", sid, wiki_path.display()); synced += 1; } #[derive(Serialize)] struct SyncResult { systems_synced: usize, } println!( "{}", serde_json::to_string_pretty(&SyncResult { systems_synced: synced }) .unwrap() ); } fn format_population(pop: i64) -> String { if pop >= 1_000_000_000 { format!("{:.1}B", pop as f64 / 1_000_000_000.0) } else if pop >= 1_000_000 { format!("{}M", pop / 1_000_000) } else if pop >= 1_000 { format!("{}K", pop / 1_000) } else { format!("{}", pop) } } // cmd_populate removed — replaced by per-system author/commit workflow // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- fn main() { let cli = Cli::parse(); let db_path = resolve_db_path(cli.db); let conn = open_db(&db_path); match &cli.command { Commands::ShowSystem { system_id } => cmd_show_system(&conn, system_id), Commands::ShowBody { body_id } => cmd_show_body(&conn, body_id), Commands::ShowStation { station_id } => cmd_show_station(&conn, station_id), Commands::ListBodies { system, r#type, inhabited, unnamed, } => cmd_list_bodies( &conn, system.as_deref(), r#type.as_deref(), *inhabited, *unnamed, ), Commands::ListStations { system, r#type } => { cmd_list_stations(&conn, system.as_deref(), r#type.as_deref()) } cmd @ Commands::AddBody { .. } => cmd_add_body(&conn, cmd), cmd @ Commands::AddStation { .. } => cmd_add_station(&conn, cmd), Commands::Stats => cmd_stats(&conn), Commands::Author { system_id, outdir, wiki, } => cmd_author(&conn, system_id, outdir, wiki), Commands::CommitSystem { path } => cmd_commit_system(&conn, path), Commands::WipeSystem { system_id } => cmd_wipe_system(&conn, system_id), Commands::ListSystems { sector, hop, finished, unfinished, } => cmd_list_systems(&conn, sector.as_deref(), *hop, *finished, *unfinished), Commands::Unfinished { sector } => cmd_unfinished(&conn, sector.as_deref()), Commands::Next { hop, sector } => cmd_next(&conn, *hop, sector.as_deref()), Commands::SyncWiki { system_id, wiki } => cmd_sync_wiki(&conn, system_id.as_deref(), wiki), } }