aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-cli
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /apps/fparkan-cli
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'apps/fparkan-cli')
-rw-r--r--apps/fparkan-cli/Cargo.toml18
-rw-r--r--apps/fparkan-cli/src/main.rs346
2 files changed, 364 insertions, 0 deletions
diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml
new file mode 100644
index 0000000..22952e6
--- /dev/null
+++ b/apps/fparkan-cli/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "fparkan-cli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-corpus = { path = "../../crates/fparkan-corpus" }
+fparkan-nres = { path = "../../crates/fparkan-nres" }
+fparkan-prototype = { path = "../../crates/fparkan-prototype" }
+fparkan-resource = { path = "../../crates/fparkan-resource" }
+fparkan-rsli = { path = "../../crates/fparkan-rsli" }
+fparkan-runtime = { path = "../../crates/fparkan-runtime" }
+fparkan-vfs = { path = "../../crates/fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs
new file mode 100644
index 0000000..6f9f0f6
--- /dev/null
+++ b/apps/fparkan-cli/src/main.rs
@@ -0,0 +1,346 @@
+#![forbid(unsafe_code)]
+#![allow(clippy::print_stderr, clippy::print_stdout)]
+//! `FParkan` command-line tools.
+
+use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
+use fparkan_prototype::{
+ build_prototype_graph_report, extend_graph_report_with_visual_dependencies,
+};
+use fparkan_resource::{resource_name, CachedResourceRepository};
+use fparkan_runtime::{
+ create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
+};
+use fparkan_vfs::DirectoryVfs;
+use std::fmt::Write;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+fn main() {
+ let args: Vec<String> = std::env::args().skip(1).collect();
+ let result = run(&args);
+ let code = exit_code(&result);
+ if let Err(err) = result {
+ eprintln!("{err}");
+ }
+ std::process::exit(code);
+}
+
+fn run(args: &[String]) -> Result<(), String> {
+ match args {
+ [domain, command, rest @ ..] if domain == "corpus" && command == "discover" => {
+ let rest = strip_format_json(rest)?;
+ let root = parse_root(&rest)?;
+ let manifest =
+ discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
+ let report = report(&root, &manifest);
+ println!("{}", render_report_json(&report));
+ Ok(())
+ }
+ [domain, command, rest @ ..] if domain == "corpus" && command == "validate" => {
+ let rest = strip_format_json(rest)?;
+ let root = parse_root(&rest)?;
+ let manifest =
+ discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
+ let report = report(&root, &manifest);
+ if report.casefold_collisions > 0 {
+ return Err("casefold collisions found".to_string());
+ }
+ println!("{}", render_report_json(&report));
+ Ok(())
+ }
+ [domain, command, rest @ ..] if domain == "archive" && command == "inspect" => {
+ let rest = strip_format_json(rest)?;
+ inspect_archive(&rest)
+ }
+ [domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => {
+ let rest = strip_format_json(rest)?;
+ inspect_prototype(&rest)
+ }
+ [domain, command, rest @ ..] if domain == "mission" && command == "graph" => {
+ let rest = strip_format_json(rest)?;
+ graph_mission(&rest)
+ }
+ _ => Err(usage()),
+ }
+}
+
+fn exit_code(result: &Result<(), String>) -> i32 {
+ if result.is_ok() {
+ 0
+ } else {
+ 2
+ }
+}
+
+fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> {
+ let mut stripped = Vec::with_capacity(args.len());
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ if arg == "--format" {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--format requires a value".to_string())?;
+ if value != "json" {
+ return Err(format!("unsupported output format: {value}"));
+ }
+ continue;
+ }
+ stripped.push(arg.clone());
+ }
+ Ok(stripped)
+}
+
+fn parse_root(args: &[String]) -> Result<PathBuf, String> {
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ if arg == "--root" {
+ return iter
+ .next()
+ .map(PathBuf::from)
+ .ok_or_else(|| "--root requires a path".to_string());
+ }
+ }
+ Err("missing --root".to_string())
+}
+
+fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> {
+ parse_option(args, &["--root", "--game-root"])
+ .map(PathBuf::from)
+ .ok_or_else(|| "missing --root".to_string())
+}
+
+fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> {
+ parse_option(args, names).ok_or_else(|| format!("missing {label}"))
+}
+
+fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ if names.iter().any(|name| arg == name) {
+ return iter.next().cloned();
+ }
+ }
+ None
+}
+
+fn inspect_prototype(args: &[String]) -> Result<(), String> {
+ let root = parse_root_alias(args)?;
+ let key = parse_required(args, &["--key"], "--key")?;
+ let vfs = Arc::new(DirectoryVfs::new(root));
+ let repository = CachedResourceRepository::new(vfs.clone());
+ let roots = [resource_name(key.as_bytes())];
+ let (graph, resolved, mut report) =
+ build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
+ extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved);
+ println!("{}", prototype_inspect_json(&key, &graph, &report));
+ Ok(())
+}
+
+fn prototype_inspect_json(
+ key: &str,
+ graph: &fparkan_prototype::PrototypeGraph,
+ report: &fparkan_prototype::PrototypeGraphReport,
+) -> String {
+ format!(
+ "{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
+ json_string(key),
+ report.root_count,
+ graph.prototype_requests.len(),
+ report.resolved_count,
+ report.unit_reference_count,
+ report.unit_component_count,
+ report.direct_reference_count,
+ report.wear_resolved_count,
+ report.material_resolved_count,
+ report.texture_resolved_count,
+ report.lightmap_resolved_count,
+ report.failures.len()
+ )
+}
+
+fn graph_mission(args: &[String]) -> Result<(), String> {
+ let root = parse_root_alias(args)?;
+ let mission = parse_required(args, &["--mission"], "--mission")?;
+ let services = EngineServices::new(Arc::new(DirectoryVfs::new(root)));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ services,
+ )
+ .map_err(|err| err.to_string())?;
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: mission.clone(),
+ },
+ )
+ .map_err(|err| err.to_string())?;
+ println!(
+ "{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
+ json_string(&mission),
+ loaded.object_count,
+ loaded.path_count,
+ loaded.clan_count,
+ loaded.extra_count,
+ loaded.graph_root_count,
+ loaded.graph_direct_reference_count,
+ loaded.graph_unit_reference_count,
+ loaded.graph_unit_component_count,
+ loaded.graph_resolved_count,
+ loaded.graph_wear_resolved_count,
+ loaded.graph_material_resolved_count,
+ loaded.graph_texture_resolved_count,
+ loaded.graph_lightmap_resolved_count,
+ loaded.graph_failure_count
+ );
+ Ok(())
+}
+
+fn inspect_archive(args: &[String]) -> Result<(), String> {
+ let path = parse_archive_path(args)?;
+ let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
+ if bytes.starts_with(b"NRes") {
+ let document = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ println!(
+ "{}",
+ archive_inspect_json(
+ &path.display().to_string(),
+ "NRes",
+ document.entries().len(),
+ Some(document.lookup_order_valid()),
+ )
+ );
+ return Ok(());
+ }
+ if bytes.get(0..4) == Some(b"NL\0\x01") {
+ let document = fparkan_rsli::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ fparkan_rsli::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ println!(
+ "{}",
+ archive_inspect_json(
+ &path.display().to_string(),
+ "RsLi",
+ document.entries().len(),
+ None
+ )
+ );
+ return Ok(());
+ }
+ Err(format!("{}: unsupported archive magic", path.display()))
+}
+
+fn archive_inspect_json(
+ path: &str,
+ kind: &str,
+ entries: usize,
+ lookup_order_valid: Option<bool>,
+) -> String {
+ let mut out = format!(
+ "{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
+ json_string(path),
+ json_string(kind),
+ entries
+ );
+ if let Some(valid) = lookup_order_valid {
+ let _ = write!(out, ",\"lookup_order_valid\":{valid}");
+ }
+ out.push('}');
+ out
+}
+
+fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
+ match args {
+ [path] => Ok(PathBuf::from(path)),
+ [flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
+ _ => Err("archive inspect requires <file> or --file <file>".to_string()),
+ }
+}
+
+fn json_string(value: &str) -> String {
+ let mut out = String::with_capacity(value.len() + 2);
+ out.push('"');
+ for ch in value.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if c.is_control() => {
+ let _ = write!(out, "\\u{:04x}", u32::from(c));
+ }
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+fn usage() -> String {
+ "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn strings(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
+ #[test]
+ fn stable_exit_codes_are_mapped() {
+ assert_eq!(exit_code(&Ok(())), 0);
+ assert_eq!(exit_code(&Err("failure".to_string())), 2);
+ }
+
+ #[test]
+ fn accepts_json_format_option() {
+ assert_eq!(
+ strip_format_json(&strings(&["--root", "testdata", "--format", "json"])),
+ Ok(strings(&["--root", "testdata"]))
+ );
+ assert_eq!(
+ strip_format_json(&strings(&["--format", "text"])),
+ Err("unsupported output format: text".to_string())
+ );
+ }
+
+ #[test]
+ fn archive_json_has_schema_version() {
+ let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
+
+ assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
+ assert!(json.contains("\"kind\":\"NRes\""));
+ assert!(json.contains("\"lookup_order_valid\":true"));
+ }
+
+ #[test]
+ fn prototype_graph_json_has_canonical_field_order() {
+ let mut graph = fparkan_prototype::PrototypeGraph::default();
+ graph
+ .prototype_requests
+ .push(fparkan_prototype::PrototypeKey(resource_name(b"root")));
+ let report = fparkan_prototype::PrototypeGraphReport {
+ root_count: 1,
+ direct_reference_count: 1,
+ resolved_count: 1,
+ ..fparkan_prototype::PrototypeGraphReport::default()
+ };
+
+ let json = prototype_inspect_json("root", &graph, &report);
+
+ assert_eq!(
+ json,
+ "{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
+ );
+ }
+}