diff --git a/Cargo.lock b/Cargo.lock index 050d450c..83c22572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,12 +43,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -261,6 +305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -269,8 +314,22 @@ version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -294,6 +353,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -864,6 +929,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1045,6 +1116,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -1298,6 +1375,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -2097,7 +2180,7 @@ dependencies = [ name = "simlin-cli" version = "0.1.0" dependencies = [ - "pico-args", + "clap", "sha2", "simlin", "simlin-engine", @@ -2259,6 +2342,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913e7b03d63752f6cdd2df77da36749d82669904798fe8944b9ec3d23f159905" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2549,6 +2638,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" diff --git a/scripts/lint-project.sh b/scripts/lint-project.sh index bd1b9ea9..c7969783 100755 --- a/scripts/lint-project.sh +++ b/scripts/lint-project.sh @@ -38,6 +38,9 @@ fi # Rule 2: Rust source file size warning # Threshold set just above the current maximum (vm.rs at ~5513 lines). MAX_LINES=6000 +RS_FILES=$(mktemp) +find src -name '*.rs' -not -path '*/target/*' -not -path '*/.git/*' \ + -not -name '*.gen.rs' -not -path '*/tests/*' > "$RS_FILES" while IFS= read -r file; do lines=$(wc -l < "$file" | tr -d ' ') if [ "$lines" -gt "$MAX_LINES" ]; then @@ -45,8 +48,8 @@ while IFS= read -r file; do echo " Fix: Consider splitting this file into smaller modules." ERRORS=$((ERRORS + 1)) fi -done < <(find src -name '*.rs' -not -path '*/target/*' -not -path '*/.git/*' \ - -not -name '*.gen.rs' -not -path '*/tests/*') +done < "$RS_FILES" +rm -f "$RS_FILES" # Rule 3: Copyright headers on all Rust and TypeScript source files # check-copyright.py writes one error per line to stdout; summary to stderr. diff --git a/src/simlin-cli/CLAUDE.md b/src/simlin-cli/CLAUDE.md index c1600199..7f07de9f 100644 --- a/src/simlin-cli/CLAUDE.md +++ b/src/simlin-cli/CLAUDE.md @@ -7,5 +7,22 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md). ## Key Files -- `src/main.rs` -- CLI entry point: argument parsing, model loading, simulation, format conversion. All compilation and simulation use the incremental salsa path (`SimlinDb` + `compile_project_incremental`); the monolithic `Project::from` path is not used. +- `src/main.rs` -- CLI entry point: clap derive-based argument parsing, model loading, simulation, format conversion. All compilation and simulation use the incremental salsa path (`SimlinDb` + `compile_project_incremental`); the monolithic `Project::from` path is not used. - `src/gen_stdlib.rs` -- Standard library generation utility (generates `stdlib.gen.rs` for simlin-engine) + +## CLI Subcommands + +Uses [clap](https://docs.rs/clap) derive API. Each subcommand declares exactly the arguments it accepts. + +| Subcommand | Description | Key flags | +|---|---|---| +| `simulate` | Simulate a model, print TSV results | `--no-output`, `--ltm` | +| `convert` | Convert between XMILE, Vensim MDL, protobuf | `--to `, `--model-only`, `--output` | +| `equations` | Print model equations as LaTeX | `--output` | +| `debug` | Compare simulation with a reference run | `--reference FILE`, `--ltm` | +| `gen-stdlib` | Generate Rust stdlib code | `--stdlib-dir`, `--output` | +| `vdf-dump` | Pretty-print VDF file contents | positional `PATH` | + +Commands that read model files (`simulate`, `convert`, `equations`, `debug`) share `InputArgs` via `#[command(flatten)]`: +- Positional `PATH` (optional for `simulate`, reads stdin) +- `--format ` -- auto-detected from file extension when omitted diff --git a/src/simlin-cli/Cargo.toml b/src/simlin-cli/Cargo.toml index 8adaa46b..22bfe7ed 100644 --- a/src/simlin-cli/Cargo.toml +++ b/src/simlin-cli/Cargo.toml @@ -10,7 +10,7 @@ name = "simlin" path = "src/main.rs" [dependencies] -pico-args = "0.5" +clap = { version = "4", features = ["derive"] } stringreader = "0.1" sha2 = "0.10" simlin-engine = { version = "0.1", path = "../simlin-engine", features = ["file_io"] } diff --git a/src/simlin-cli/src/main.rs b/src/simlin-cli/src/main.rs index 739e9618..88297412 100644 --- a/src/simlin-cli/src/main.rs +++ b/src/simlin-cli/src/main.rs @@ -4,9 +4,10 @@ use std::fs::File; use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; use std::result::Result as StdResult; -use pico_args::Arguments; +use clap::{Args, Parser, Subcommand, ValueEnum}; use simlin::errors::{ FormattedError, FormattedErrorKind, FormattedErrors, format_diagnostic, format_simulation_error, @@ -26,7 +27,6 @@ use simlin_engine::{load_csv, load_dat, open_vensim, open_xmile, to_mdl, to_xmil mod gen_stdlib; mod vdf_dump; -const VERSION: &str = "1.0"; const EXIT_FAILURE: i32 = 1; #[macro_export] @@ -38,116 +38,162 @@ macro_rules! die( } } ); -fn usage() -> ! { - let argv0 = std::env::args() - .next() - .unwrap_or_else(|| "".to_string()); - die!( - concat!( - "mdl {}: Simulate system dynamics models.\n\ - \n\ - USAGE:\n", - " {} [SUBCOMMAND] [OPTION...] PATH\n", - "\n\ - OPTIONS:\n", - " -h, --help show this message\n", - " --vensim model is a Vensim .mdl file\n", - " --pb-input input is binary protobuf project\n", - " --to-xmile output should be XMILE not protobuf\n", - " --to-mdl output should be Vensim MDL not protobuf\n", - " --model-only for conversion, only output model instead of project\n", - " --output FILE path to write output file\n", - " --reference FILE reference TSV for debug subcommand\n", - " --no-output don't print the output (for benchmarking)\n", - " --ltm enable Loops That Matter analysis\n", - " --stdlib-dir DIR directory containing stdlib/*.stmx files\n", - "\n\ - SUBCOMMANDS:\n", - " simulate Simulate a model and display output\n", - " convert Convert an XMILE or Vensim model to protobuf\n", - " equations Print the equations out\n", - " debug Output model equations interleaved with a reference run\n", - " gen-stdlib Generate Rust code for stdlib models\n", - " vdf-dump Pretty-print VDF file structure and contents\n", - ), - VERSION, - argv0 - ); +#[derive(Debug, Parser)] +#[command(name = "simlin", version, about = "Simulate system dynamics models")] +struct Cli { + #[command(subcommand)] + command: Command, } -#[derive(Clone, Default, Debug)] -struct Args { - path: Option, - output: Option, - reference: Option, - stdlib_dir: Option, - is_vensim: bool, - is_pb_input: bool, - is_to_xmile: bool, - is_to_mdl: bool, - is_convert: bool, - is_model_only: bool, - is_no_output: bool, - is_equations: bool, - is_debug: bool, - is_ltm: bool, - is_gen_stdlib: bool, - is_vdf_dump: bool, +#[derive(Debug, Subcommand)] +enum Command { + /// Simulate a model and print results as TSV + Simulate { + #[command(flatten)] + input: InputArgs, + + /// Suppress output (useful for benchmarking) + #[arg(long)] + no_output: bool, + + /// Enable Loops That Matter analysis + #[arg(long)] + ltm: bool, + }, + + /// Convert a model between formats + Convert { + #[command(flatten)] + input: InputArgs, + + /// Output format (defaults to protobuf) + #[arg(long, value_enum, default_value_t = OutputFormat::Protobuf)] + to: OutputFormat, + + /// Output only the model, not the full project (protobuf only) + #[arg(long)] + model_only: bool, + + /// Output file path (defaults to stdout) + #[arg(long, short)] + output: Option, + }, + + /// Print model equations as LaTeX + Equations { + #[command(flatten)] + input: InputArgs, + + /// Output file path (defaults to stdout) + #[arg(long, short)] + output: Option, + }, + + /// Compare simulation output with a reference run + Debug { + #[command(flatten)] + input: InputArgs, + + /// Reference TSV or DAT file for comparison + #[arg(long)] + reference: PathBuf, + + /// Enable Loops That Matter analysis + #[arg(long)] + ltm: bool, + }, + + /// Generate Rust code for stdlib models + GenStdlib { + /// Directory containing stdlib .stmx files + #[arg(long, default_value = "stdlib")] + stdlib_dir: PathBuf, + + /// Output file path + #[arg(long, short, default_value = "src/simlin-engine/src/stdlib.gen.rs")] + output: PathBuf, + }, + + /// Pretty-print VDF file structure and contents + VdfDump { + /// VDF file path + path: PathBuf, + }, } -fn parse_args() -> StdResult> { - let mut parsed = Arguments::from_env(); - if parsed.contains(["-h", "--help"]) { - usage(); - } +/// Shared arguments for commands that read a model file. +#[derive(Clone, Debug, Args)] +struct InputArgs { + /// Model file path (reads stdin if omitted) + path: Option, - let subcommand = parsed.subcommand()?; - if subcommand.is_none() { - eprintln!("error: subcommand required"); - usage(); - } + /// Input format (auto-detected from file extension when omitted: + /// .mdl -> vensim, .pb/.bin -> protobuf, everything else -> xmile) + #[arg(long, value_enum)] + format: Option, +} - let mut args: Args = Default::default(); - - let subcommand = subcommand.unwrap(); - if subcommand == "convert" { - args.is_convert = true; - } else if subcommand == "simulate" { - } else if subcommand == "equations" { - args.is_equations = true; - } else if subcommand == "debug" { - args.is_debug = true; - } else if subcommand == "gen-stdlib" { - args.is_gen_stdlib = true; - } else if subcommand == "vdf-dump" { - args.is_vdf_dump = true; - } else { - eprintln!("error: unknown subcommand {}", subcommand); - usage(); - } +#[derive(Clone, Debug, ValueEnum)] +enum InputFormat { + Xmile, + Vensim, + Protobuf, +} + +#[derive(Clone, Debug, ValueEnum)] +enum OutputFormat { + Protobuf, + Xmile, + Mdl, +} - args.output = parsed.value_from_str("--output").ok(); - args.reference = parsed.value_from_str("--reference").ok(); - args.stdlib_dir = parsed.value_from_str("--stdlib-dir").ok(); - args.is_no_output = parsed.contains("--no-output"); - args.is_model_only = parsed.contains("--model-only"); - args.is_to_xmile = parsed.contains("--to-xmile"); - args.is_to_mdl = parsed.contains("--to-mdl"); - args.is_vensim = parsed.contains("--vensim"); - args.is_pb_input = parsed.contains("--pb-input"); - args.is_ltm = parsed.contains("--ltm"); - - let free_arguments = parsed.finish(); - if free_arguments.is_empty() && !args.is_gen_stdlib { - eprintln!("error: input path required"); - usage(); +/// Infer input format from file extension, falling back to XMILE. +fn resolve_input_format(input: &InputArgs) -> InputFormat { + if let Some(fmt) = &input.format { + return fmt.clone(); } + match input + .path + .as_ref() + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + { + Some("mdl") => InputFormat::Vensim, + Some("pb" | "bin") => InputFormat::Protobuf, + _ => InputFormat::Xmile, + } +} - args.path = free_arguments - .first() - .and_then(|s| s.to_str().map(|s| s.to_owned())); +/// Load a model file, dispatching on format. Exits on error. +fn open_model(input: &InputArgs) -> DatamodelProject { + let format = resolve_input_format(input); + let file_path = input + .path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/dev/stdin".to_string()); + + let result = match format { + InputFormat::Vensim => { + let contents = std::fs::read_to_string(&file_path).unwrap(); + open_vensim(&contents) + } + InputFormat::Protobuf => { + let file = File::open(&file_path).unwrap(); + let mut reader = BufReader::new(file); + open_binary(&mut reader) + } + InputFormat::Xmile => { + let file = File::open(&file_path).unwrap(); + let mut reader = BufReader::new(file); + open_xmile(&mut reader) + } + }; - Ok(args) + match result { + Ok(project) => project, + Err(err) => die!("model '{}' error: {}", &file_path, err), + } } fn open_binary(reader: &mut dyn BufRead) -> Result { @@ -297,9 +343,9 @@ fn simulate(project: &DatamodelProject, enable_ltm: bool) -> Results { run_datamodel_with_errors(project) } -fn print_equations(project: &DatamodelProject, output: Option) { - let mut output_file = - File::create(output.unwrap_or_else(|| "/dev/stdout".to_string())).unwrap(); +fn print_equations(project: &DatamodelProject, output: Option) { + let output_path = output.unwrap_or_else(|| PathBuf::from("/dev/stdout")); + let mut output_file = File::create(&output_path).unwrap(); let db = SimlinDb::default(); let sync = sync_from_datamodel(&db, project); @@ -409,121 +455,95 @@ fn print_equations(project: &DatamodelProject, output: Option) { } fn main() { - let args = match parse_args() { - Ok(args) => args, - Err(err) => { - eprintln!("error: {}", err); - usage(); - } - }; - - if args.is_gen_stdlib { - let stdlib_dir = args.stdlib_dir.unwrap_or_else(|| "stdlib".to_string()); - let output_path = args - .output - .unwrap_or_else(|| "src/simlin-engine/src/stdlib.gen.rs".to_string()); - if let Err(err) = gen_stdlib::generate(&stdlib_dir, &output_path) { - die!("gen-stdlib failed: {}", err); - } - return; - } - - if args.is_vdf_dump { - let file_path = args.path.unwrap_or_else(|| { - eprintln!("error: VDF file path required"); - std::process::exit(EXIT_FAILURE); - }); - if let Err(err) = vdf_dump::dump_vdf(&file_path) { - die!("vdf-dump failed: {}", err); - } - return; - } - - let file_path = args.path.unwrap_or_else(|| "/dev/stdin".to_string()); - - let project = if args.is_vensim { - let contents = std::fs::read_to_string(&file_path).unwrap(); - open_vensim(&contents) - } else if args.is_pb_input { - let file = File::open(&file_path).unwrap(); - let mut reader = BufReader::new(file); - open_binary(&mut reader) - } else { - let file = File::open(&file_path).unwrap(); - let mut reader = BufReader::new(file); - open_xmile(&mut reader) - }; - - if project.is_err() { - eprintln!("model '{}' error: {}", &file_path, project.err().unwrap()); - return; - }; - - let project = project.unwrap(); - - if args.is_equations { - print_equations(&project, args.output); - } else if args.is_convert { - let pb_project = match serde::serialize(&project) { - Ok(pb) => pb, - Err(err) => die!("protobuf serialization failed: {}", err), - }; - - let mut buf: Vec = if args.is_model_only { - if pb_project.models.len() != 1 { - die!("--model-only specified, but more than 1 model in this project"); + let cli = Cli::parse(); + + match cli.command { + Command::GenStdlib { stdlib_dir, output } => { + if let Err(err) = + gen_stdlib::generate(&stdlib_dir.to_string_lossy(), &output.to_string_lossy()) + { + die!("gen-stdlib failed: {}", err); } - let mut buf = Vec::with_capacity(pb_project.models[0].encoded_len()); - pb_project.models[0].encode(&mut buf).unwrap(); - buf - } else { - let mut buf = Vec::with_capacity(pb_project.encoded_len()); - pb_project.encode(&mut buf).unwrap(); - buf - }; - - if args.is_to_xmile { - match to_xmile(&project) { - Ok(s) => { - buf = s.into_bytes(); - buf.push(b'\n'); - } - Err(err) => { - die!("error converting to XMILE: {}", err); - } + } + Command::VdfDump { path } => { + if let Err(err) = vdf_dump::dump_vdf(&path.to_string_lossy()) { + die!("vdf-dump failed: {}", err); } - } else if args.is_to_mdl { - match to_mdl(&project) { - Ok(s) => { - buf = s.into_bytes(); - } - Err(err) => { - die!("error converting to MDL: {}", err); - } + } + Command::Simulate { + input, + no_output, + ltm, + } => { + let project = open_model(&input); + let results = simulate(&project, ltm); + if !no_output { + results.print_tsv(); } } + Command::Convert { + input, + to, + model_only, + output, + } => { + let project = open_model(&input); + + let buf: Vec = match to { + OutputFormat::Xmile => match to_xmile(&project) { + Ok(s) => { + let mut bytes = s.into_bytes(); + bytes.push(b'\n'); + bytes + } + Err(err) => die!("error converting to XMILE: {}", err), + }, + OutputFormat::Mdl => match to_mdl(&project) { + Ok(s) => s.into_bytes(), + Err(err) => die!("error converting to MDL: {}", err), + }, + OutputFormat::Protobuf => { + let pb_project = match serde::serialize(&project) { + Ok(pb) => pb, + Err(err) => die!("protobuf serialization failed: {}", err), + }; + if model_only { + if pb_project.models.len() != 1 { + die!("--model-only specified, but more than 1 model in this project"); + } + let mut buf = Vec::with_capacity(pb_project.models[0].encoded_len()); + pb_project.models[0].encode(&mut buf).unwrap(); + buf + } else { + let mut buf = Vec::with_capacity(pb_project.encoded_len()); + pb_project.encode(&mut buf).unwrap(); + buf + } + } + }; - let mut output_file = - File::create(args.output.unwrap_or_else(|| "/dev/stdout".to_string())).unwrap(); - output_file.write_all(&buf).unwrap(); - } else if args.is_debug { - if args.reference.is_none() { - eprintln!("missing required argument --reference FILE"); - std::process::exit(1); + let output_path = output.unwrap_or_else(|| PathBuf::from("/dev/stdout")); + let mut output_file = File::create(&output_path).unwrap(); + output_file.write_all(&buf).unwrap(); } - let ref_path = args.reference.unwrap(); - let reference = if ref_path.ends_with(".dat") { - load_dat(&ref_path).unwrap() - } else { - load_csv(&ref_path, b'\t').unwrap() - }; - let results = simulate(&project, args.is_ltm); - - results.print_tsv_comparison(Some(&reference)); - } else { - let results = simulate(&project, args.is_ltm); - if !args.is_no_output { - results.print_tsv(); + Command::Equations { input, output } => { + let project = open_model(&input); + print_equations(&project, output); + } + Command::Debug { + input, + reference, + ltm, + } => { + let project = open_model(&input); + let ref_path = reference.to_string_lossy(); + let reference_data = if ref_path.ends_with(".dat") { + load_dat(&ref_path).unwrap() + } else { + load_csv(&ref_path, b'\t').unwrap() + }; + let results = simulate(&project, ltm); + results.print_tsv_comparison(Some(&reference_data)); } } }