//! content-converter: reads content YAML, emits RON. //! //! Build-time tool for converting authored YAML content files into RON (Rusty Object Notation) //! for faster runtime deserialization. Not on v0.1 critical path — engine consumes YAML directly. //! RON is future-proofing for runtime performance. use clap::Parser; use std::path::{Path, PathBuf}; use walkdir::WalkDir; mod types; use types::ContentFile; #[derive(Parser)] #[command(name = "content-converter", about = "Convert content YAML to RON")] struct Cli { /// Path to the content directory (default: content/) #[arg(short, long, default_value = "content")] input: PathBuf, /// Path to write RON output (default: content-ron/) #[arg(short, long, default_value = "content-ron")] output: PathBuf, /// Only convert files matching this content type #[arg(short = 't', long)] content_type: Option, /// Print what would be converted without writing files #[arg(long)] dry_run: bool, /// Print verbose conversion details #[arg(short, long)] verbose: bool, } #[derive(Clone, Debug)] enum ContentTypeFilter { Npc, Dialogue, Monologue, Triangle, Location, Routine, District, Faction, } impl std::str::FromStr for ContentTypeFilter { type Err = String; fn from_str(s: &str) -> Result { match s { "npc" => Ok(Self::Npc), "dialogue" => Ok(Self::Dialogue), "monologue" => Ok(Self::Monologue), "triangle" => Ok(Self::Triangle), "location" => Ok(Self::Location), "routine" => Ok(Self::Routine), "district" => Ok(Self::District), "faction" => Ok(Self::Faction), _ => Err(format!( "Unknown content type: {s}. Valid: npc, dialogue, monologue, triangle, location, routine, district, faction" )), } } } fn main() { let cli = Cli::parse(); if !cli.input.exists() { eprintln!( "Error: content directory not found: {}", cli.input.display() ); std::process::exit(1); } let mut converted = 0u32; let mut skipped = 0u32; let mut errors = 0u32; for entry in WalkDir::new(&cli.input) .into_iter() .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .is_some_and(|ext| ext == "yaml" || ext == "yml") }) .filter(|e| !is_schema_or_meta(e.path())) { let path = entry.path(); let content_type = classify_file(path); if let Some(ref filter) = cli.content_type { if !matches_filter(&content_type, filter) { continue; } } match convert_file( path, &cli.input, &cli.output, &content_type, cli.dry_run, cli.verbose, ) { Ok(true) => converted += 1, Ok(false) => skipped += 1, Err(e) => { eprintln!("Error converting {}: {e}", path.display()); errors += 1; } } } println!( "Content conversion complete: {converted} converted, {skipped} skipped, {errors} errors" ); if errors > 0 { std::process::exit(1); } } fn is_schema_or_meta(path: &Path) -> bool { let path_str = path.to_string_lossy(); path_str.contains("_schema") || path_str.contains("_meta") } #[derive(Debug)] enum ContentType { NpcProfile, DialoguePool, MonologuePool, Triangle, Location, Routine, District, Campaign, System, Station, ContentManifest, Faction, Enum, KnowledgeCatalog, Unknown, } fn classify_file(path: &Path) -> ContentType { let path_str = path.to_string_lossy(); if path_str.contains("/npcs/") { ContentType::NpcProfile } else if path_str.contains("/dialogue/") { ContentType::DialoguePool } else if path_str.contains("/monologue/") { ContentType::MonologuePool } else if path_str.contains("/triangles/") { ContentType::Triangle } else if path_str.contains("/locations/") { ContentType::Location } else if path_str.contains("/routines/") { ContentType::Routine } else if path_str.contains("/factions/") { ContentType::Faction } else if path_str.contains("/enums/") { ContentType::Enum } else if path_str.contains("/knowledge/") { ContentType::KnowledgeCatalog } else if path_str.ends_with("district.yaml") { ContentType::District } else if path_str.ends_with("station.yaml") { ContentType::Station } else if path_str.ends_with("system.yaml") { ContentType::System } else if path_str.ends_with("campaign.yaml") { ContentType::Campaign } else if path_str.ends_with("content.yaml") { ContentType::ContentManifest } else { ContentType::Unknown } } fn matches_filter(content_type: &ContentType, filter: &ContentTypeFilter) -> bool { matches!( (content_type, filter), (ContentType::NpcProfile, ContentTypeFilter::Npc) | (ContentType::DialoguePool, ContentTypeFilter::Dialogue) | (ContentType::MonologuePool, ContentTypeFilter::Monologue) | (ContentType::Triangle, ContentTypeFilter::Triangle) | (ContentType::Location, ContentTypeFilter::Location) | (ContentType::Routine, ContentTypeFilter::Routine) | (ContentType::District, ContentTypeFilter::District) | (ContentType::Faction, ContentTypeFilter::Faction) ) } fn convert_file( path: &Path, input_root: &Path, output_root: &Path, content_type: &ContentType, dry_run: bool, verbose: bool, ) -> Result> { let yaml_str = std::fs::read_to_string(path)?; // Skip stub files (comment-only, no real YAML content) let trimmed = yaml_str .lines() .filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty()) .collect::>() .join("\n"); if trimmed.is_empty() { if verbose { println!(" skip (stub): {}", path.display()); } return Ok(false); } let content_file = parse_yaml(&trimmed, content_type)?; let ron_config = ron::ser::PrettyConfig::default() .struct_names(true) .enumerate_arrays(false); let ron_str = ron::ser::to_string_pretty(&content_file, ron_config)?; // Compute output path: replace input root with output root, .yaml -> .ron let rel_path = path.strip_prefix(input_root)?; let mut out_path = output_root.join(rel_path); out_path.set_extension("ron"); if dry_run { println!( " would convert: {} -> {}", path.display(), out_path.display() ); return Ok(true); } if verbose { println!(" convert: {} -> {}", path.display(), out_path.display()); } if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&out_path, ron_str)?; Ok(true) } fn parse_yaml( yaml_str: &str, content_type: &ContentType, ) -> Result> { let file = match content_type { ContentType::NpcProfile => { ContentFile::NpcProfile(Box::new(serde_yaml::from_str(yaml_str)?)) } ContentType::DialoguePool => ContentFile::DialoguePool(serde_yaml::from_str(yaml_str)?), ContentType::MonologuePool => ContentFile::MonologuePool(serde_yaml::from_str(yaml_str)?), ContentType::Triangle => ContentFile::Triangle(serde_yaml::from_str(yaml_str)?), ContentType::Location => ContentFile::Location(serde_yaml::from_str(yaml_str)?), ContentType::Routine => ContentFile::Routine(serde_yaml::from_str(yaml_str)?), ContentType::District => ContentFile::District(serde_yaml::from_str(yaml_str)?), ContentType::Campaign => ContentFile::Campaign(serde_yaml::from_str(yaml_str)?), ContentType::System | ContentType::Station => { ContentFile::Metadata(serde_yaml::from_str(yaml_str)?) } ContentType::ContentManifest => ContentFile::Manifest(serde_yaml::from_str(yaml_str)?), ContentType::Faction => ContentFile::Generic(serde_yaml::from_str(yaml_str)?), ContentType::Enum | ContentType::KnowledgeCatalog => { ContentFile::Generic(serde_yaml::from_str(yaml_str)?) } ContentType::Unknown => ContentFile::Generic(serde_yaml::from_str(yaml_str)?), }; Ok(file) }