Build-time converter reads content YAML and emits RON format. Lives in tooling/content-converter/ as standalone Rust crate. Runs via make content-ron. Supports --dry-run, --verbose, --content-type filtering. Not on critical path — engine consumes YAML in v0.1, RON is future- proofing for runtime performance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
287 lines
8.6 KiB
Rust
287 lines
8.6 KiB
Rust
//! 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<ContentTypeFilter>,
|
|
|
|
/// 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<Self, Self::Err> {
|
|
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<bool, Box<dyn std::error::Error>> {
|
|
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::<Vec<_>>()
|
|
.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<ContentFile, Box<dyn std::error::Error>> {
|
|
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)
|
|
}
|