use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use caexfer::{Error, Format, InfillShape, MshVersion, Result};
#[derive(Debug, Default)]
#[allow(clippy::struct_excessive_bools)] pub(super) struct Args {
pub(super) command: String,
pub(super) help_requested: bool,
pub(super) paths: Vec<PathBuf>,
pub(super) json: bool,
pub(super) preview: bool,
pub(super) strict: bool,
pub(super) overwrite: bool,
pub(super) accept_omissions: bool,
pub(super) accept_basic_frame: bool,
pub(super) accept_zero_rotations: bool,
pub(super) accept_synthetic_zero: bool,
pub(super) accept_all: bool,
pub(super) from: Option<String>,
pub(super) mesh: Option<PathBuf>,
pub(super) mesh_out: Option<PathBuf>,
pub(super) subcase: Option<i64>,
pub(super) step: Option<usize>,
pub(super) max_bytes: Option<usize>,
pub(super) msh_version: Option<MshVersion>,
pub(super) match_grid: Option<PathBuf>,
pub(super) infill_shape: Option<InfillShape>,
pub(super) infill_unit_size: Option<f64>,
pub(super) surface: bool,
pub(super) gmsh: Option<PathBuf>,
pub(super) smooth_iterations: Option<usize>,
}
pub(super) fn usage(message: impl Into<String>) -> Error {
Error::new("E_USAGE", message)
}
fn text(arg: &OsStr) -> Result<&str> {
arg.to_str()
.ok_or_else(|| usage("option value is not valid UTF-8"))
}
fn set_flag(slot: &mut bool, name: &str) -> Result<()> {
if *slot {
return Err(usage(format!("duplicate {name}")));
}
*slot = true;
Ok(())
}
fn set_option<T>(
slot: &mut Option<T>,
name: &str,
parse: impl FnOnce() -> Result<T>,
) -> Result<()> {
if slot.is_some() {
return Err(usage(format!("duplicate {name}")));
}
*slot = Some(parse()?);
Ok(())
}
fn is_op2_output(args: &Args) -> bool {
args.command == "convert"
&& args
.paths
.get(1)
.and_then(|path| path.extension())
.and_then(OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("op2"))
}
fn is_op2_input(args: &Args) -> bool {
args.command == "convert"
&& args.from.as_deref().map_or_else(
|| {
args.paths
.first()
.is_some_and(|path| Format::from_input_path(path).ok() == Some(Format::Op2))
},
|name| Format::parse(name).ok() == Some(Format::Op2),
)
}
#[allow(clippy::too_many_lines)] pub(super) fn parse_args(raw: &[OsString]) -> Result<Args> {
if raw.is_empty() {
return Ok(Args {
command: "help".into(),
..Args::default()
});
}
let command = text(&raw[0])?.to_string();
if matches!(command.as_str(), "-h" | "--help") {
return Ok(Args {
command: "help".into(),
..Args::default()
});
}
if command == "help" {
return match raw.get(1) {
None => Ok(Args {
command,
..Args::default()
}),
Some(topic) if raw.len() == 2 => {
let topic = text(topic)?;
if !matches!(topic, "formats" | "inspect" | "convert") {
return Err(usage(format!("unknown help topic {topic:?}; use --help")));
}
Ok(Args {
command: topic.into(),
help_requested: true,
..Args::default()
})
}
Some(_) => Err(usage("help expects one command name")),
};
}
if matches!(command.as_str(), "-V" | "--version") {
return Ok(Args {
command: "version".into(),
..Args::default()
});
}
if command == "formats" {
let help_requested = match raw.get(1) {
None => false,
Some(option) if raw.len() == 2 && matches!(text(option)?, "-h" | "--help") => true,
Some(_) => return Err(usage("formats takes no paths or options except --help")),
};
return Ok(Args {
command,
help_requested,
..Args::default()
});
}
if !matches!(command.as_str(), "inspect" | "convert") {
return Err(usage(format!("unknown command {command:?}; use --help")));
}
if raw.len() == 1 {
return Ok(Args {
command,
help_requested: true,
..Args::default()
});
}
let mut args = Args {
command,
..Args::default()
};
let mut index = 1;
let mut options = true;
while index < raw.len() {
let arg = &raw[index];
if options && arg == "--" {
options = false;
index += 1;
continue;
}
if options && (arg == "--help" || arg == "-h") {
args.help_requested = true;
return Ok(args);
}
let value = |offset: usize| -> Result<&str> {
raw.get(index + offset)
.ok_or_else(|| usage(format!("missing value after {}", arg.to_string_lossy())))
.and_then(|v| text(v))
};
if options && (arg == "--json" || arg == "-j") {
set_flag(&mut args.json, "--json")?;
} else if options && arg == "--preview" {
set_flag(&mut args.preview, "--preview")?;
} else if options && (arg == "--strict" || arg == "-s") {
set_flag(&mut args.strict, "--strict")?;
} else if options && arg == "--overwrite" {
set_flag(&mut args.overwrite, "--overwrite")?;
} else if options && arg == "--accept-omissions" {
set_flag(&mut args.accept_omissions, "--accept-omissions")?;
} else if options && arg == "--accept-basic-frame" {
set_flag(&mut args.accept_basic_frame, "--accept-basic-frame")?;
} else if options && arg == "--accept-zero-rotations" {
set_flag(&mut args.accept_zero_rotations, "--accept-zero-rotations")?;
} else if options && arg == "--accept-synthetic-zero" {
set_flag(&mut args.accept_synthetic_zero, "--accept-synthetic-zero")?;
} else if options && arg == "--accept-all" {
set_flag(&mut args.accept_all, "--accept-all")?;
} else if options && arg == "--from" {
set_option(&mut args.from, "--from", || Ok(value(1)?.to_string()))?;
index += 1;
} else if options && arg == "--mesh" {
set_option(&mut args.mesh, "--mesh", || Ok(PathBuf::from(value(1)?)))?;
index += 1;
} else if options && arg == "--mesh-out" {
set_option(&mut args.mesh_out, "--mesh-out", || {
Ok(PathBuf::from(value(1)?))
})?;
index += 1;
} else if options && arg == "--subcase" {
set_option(&mut args.subcase, "--subcase", || {
value(1)?
.parse()
.map_err(|_| usage("--subcase requires an integer"))
})?;
index += 1;
} else if options && arg == "--msh-version" {
set_option(&mut args.msh_version, "--msh-version", || match value(1)? {
"2.2" => Ok(MshVersion::V2_2),
"4.1" => Ok(MshVersion::V4_1),
_ => Err(usage("--msh-version requires 2.2 or 4.1")),
})?;
index += 1;
} else if options && arg == "--infill-shape" {
set_option(&mut args.infill_shape, "--infill-shape", || {
InfillShape::parse(value(1)?)
})?;
index += 1;
} else if options && arg == "--infill-unit-size" {
set_option(&mut args.infill_unit_size, "--infill-unit-size", || {
let size: f64 = value(1)?
.parse()
.map_err(|_| usage("--infill-unit-size requires a positive finite number"))?;
if !size.is_finite() || size <= 0.0 {
return Err(usage(
"--infill-unit-size requires a positive finite number",
));
}
Ok(size)
})?;
index += 1;
} else if options && arg == "--match-grid" {
set_option(&mut args.match_grid, "--match-grid", || {
Ok(PathBuf::from(value(1)?))
})?;
index += 1;
} else if options && arg == "--surface" {
set_flag(&mut args.surface, "--surface")?;
} else if options && arg == "--gmsh" {
set_option(&mut args.gmsh, "--gmsh", || Ok(PathBuf::from(value(1)?)))?;
index += 1;
} else if options && arg == "--smooth-iterations" {
set_option(&mut args.smooth_iterations, "--smooth-iterations", || {
let count: usize = value(1)?
.parse()
.map_err(|_| usage("--smooth-iterations requires an integer from 1 to 50"))?;
if !(1..=50).contains(&count) {
return Err(usage(
"--smooth-iterations requires an integer from 1 to 50",
));
}
Ok(count)
})?;
index += 1;
} else if options && arg == "--step" {
set_option(&mut args.step, "--step", || {
value(1)?
.parse()
.map_err(|_| usage("--step requires a nonnegative integer"))
})?;
index += 1;
} else if options && arg == "--max-bytes" {
set_option(&mut args.max_bytes, "--max-bytes", || {
let limit = value(1)?
.parse()
.map_err(|_| usage("--max-bytes requires a positive integer"))?;
if limit == 0 {
return Err(usage("--max-bytes must be positive"));
}
Ok(limit)
})?;
index += 1;
} else if options && arg.to_string_lossy().starts_with('-') {
return Err(usage(format!(
"unknown option {}; use -- before a path beginning with '-'",
arg.to_string_lossy()
)));
} else {
args.paths.push(PathBuf::from(arg));
}
index += 1;
}
let expected = if args.command == "inspect" { 1 } else { 2 };
if args.paths.len() != expected {
return Err(usage(format!(
"{} expects {expected} path argument(s)",
args.command
)));
}
if args.strict && args.command != "inspect" {
return Err(usage("--strict is only for inspect"));
}
if args.preview && args.command != "convert" {
return Err(usage("--preview is only for convert"));
}
if args.overwrite && args.command != "convert" {
return Err(usage("--overwrite is only for convert"));
}
if args.preview && args.overwrite {
return Err(usage("--overwrite cannot be combined with --preview"));
}
if (args.accept_omissions
|| args.accept_basic_frame
|| args.accept_zero_rotations
|| args.accept_synthetic_zero
|| args.accept_all)
&& args.command != "convert"
{
return Err(usage("conversion acceptance flags are only for convert"));
}
if args.preview
&& (args.accept_omissions
|| args.accept_basic_frame
|| args.accept_zero_rotations
|| args.accept_synthetic_zero
|| args.accept_all)
{
return Err(usage("acceptance flags cannot be combined with --preview"));
}
let op2_output = is_op2_output(&args);
if args.mesh_out.is_some() && !op2_output {
return Err(usage("--mesh-out applies only to OP2 output"));
}
if args.accept_basic_frame && args.mesh.is_none() {
return Err(usage(
"--accept-basic-frame requires --mesh for OP2/PCH input",
));
}
if args.accept_zero_rotations && !op2_output {
return Err(usage("--accept-zero-rotations applies only to OP2 output"));
}
if args.accept_synthetic_zero && !op2_output && !is_op2_input(&args) {
return Err(usage(
"--accept-synthetic-zero applies only to OP2 input or output",
));
}
if args.accept_synthetic_zero && args.accept_zero_rotations {
return Err(usage(
"synthetic zero already supplies all six displacement components",
));
}
if args.msh_version.is_some() {
let msh_target = args.command == "convert"
&& Format::from_output_path(&args.paths[1]).is_ok_and(|format| format == Format::Msh);
let msh_companion = args.mesh_out.as_deref().is_some_and(|path| {
Format::from_output_path(path).is_ok_and(|format| format == Format::Msh)
});
if !msh_target && !msh_companion {
return Err(usage("--msh-version applies only to MSH output"));
}
}
if args.infill_shape.is_some() != args.infill_unit_size.is_some() {
return Err(usage(
"--infill-shape and --infill-unit-size must be used together",
));
}
if args.infill_shape.is_some() && args.command != "convert" {
return Err(usage("--infill-shape applies only to convert"));
}
if args.infill_shape.is_some() && args.surface {
return Err(usage("--infill-shape cannot be combined with --surface"));
}
if args.surface && args.command != "convert" {
return Err(usage("--surface applies only to convert"));
}
if args.gmsh.is_some() && args.infill_shape != Some(InfillShape::Tet4) {
return Err(usage("--gmsh requires --infill-shape tet4"));
}
if args.match_grid.is_some() && args.command != "convert" {
return Err(usage("--match-grid applies only to convert"));
}
if args.match_grid.is_some()
&& (args.infill_shape.is_some() || args.surface || args.gmsh.is_some())
{
return Err(usage(
"--match-grid supplies hex8 infill size and cannot use other geometry options",
));
}
if args.smooth_iterations.is_some()
&& (args.command != "convert"
|| Format::from_output_path(&args.paths[1]).ok() != Some(Format::Stl))
{
return Err(usage("--smooth-iterations applies only to STL output"));
}
Ok(args)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Result<Args> {
let raw: Vec<OsString> = values.iter().map(OsString::from).collect();
parse_args(&raw)
}
#[test]
fn conversion_can_request_confirmation_later() {
assert!(args(&["convert", "x.bdf", "x.vtu"]).is_ok());
}
#[test]
fn duplicate_option_rejected() {
assert!(args(&["inspect", "x.bdf", "--json", "--json"]).is_err());
assert!(args(&["inspect", "x.bdf", "--json", "-j"]).is_err());
assert!(args(&["inspect", "x.bdf", "--strict", "-s"]).is_err());
assert!(args(&["convert", "x.bdf", "y.vtu", "--preview", "--preview"]).is_err());
assert!(args(&["convert", "x.vtu", "y.vtu", "--surface", "--surface"]).is_err());
}
#[test]
fn geometry_projection_options_are_scoped() {
assert!(
args(&["convert", "x.vtu", "y.vtu", "--surface"])
.unwrap()
.surface
);
assert!(args(&["inspect", "x.vtu", "--surface"]).is_err());
assert!(
args(&[
"convert",
"x.vtu",
"y.vtu",
"--surface",
"--infill-shape",
"hex8",
"--infill-unit-size",
"1"
])
.is_err()
);
for removed in ["--voxel-size", "--tetrahedralize", "--tet-size"] {
assert!(args(&["convert", "x.vtu", "y.vtu", removed, "1"]).is_err());
}
}
#[test]
fn infill_shape_and_size_are_paired_and_exclusive() {
for shape in ["tet4", "hex8", "wedge6", "pyramid5"] {
let parsed = args(&[
"convert",
"x.stl",
"y.vtu",
"--infill-shape",
shape,
"--infill-unit-size",
"0.5",
])
.unwrap();
assert!(parsed.infill_shape.is_some());
assert_eq!(parsed.infill_unit_size, Some(0.5));
}
assert!(args(&["convert", "x.stl", "y.vtu", "--infill-shape", "hex8"]).is_err());
assert!(args(&["convert", "x.stl", "y.vtu", "--infill-unit-size", "1"]).is_err());
assert!(
args(&[
"convert",
"x.stl",
"y.vtu",
"--infill-shape",
"hex8",
"--infill-unit-size",
"0"
])
.is_err()
);
assert!(
args(&[
"convert",
"x.stl",
"y.vtu",
"--infill-shape",
"hex8",
"--infill-unit-size",
"1",
"--gmsh",
"gmsh"
])
.is_err()
);
assert!(
args(&[
"convert",
"x.stl",
"y.vtu",
"--infill-shape",
"tet4",
"--infill-unit-size",
"1",
"--gmsh",
"gmsh"
])
.is_ok()
);
}
#[test]
fn irrelevant_flag_rejected() {
assert!(args(&["convert", "x.bdf", "x.vtu", "--strict"]).is_err());
assert!(args(&["convert", "x.bdf", "x.vtu", "-s"]).is_err());
assert!(
args(&[
"inspect",
"x.op2",
"--mesh",
"x.vtu",
"--accept-basic-frame"
])
.is_err()
);
}
#[test]
fn short_json_and_strict_options() {
let inspect = args(&["inspect", "x.bdf", "-j", "-s"]).unwrap();
assert!(inspect.json && inspect.strict);
let convert = args(&["convert", "x.bdf", "x.vtu", "-j"]).unwrap();
assert!(convert.json);
}
#[test]
fn catchall_acceptance_uses_short_spelling() {
assert!(
args(&["convert", "x.bdf", "x.vtu", "--accept-all"])
.unwrap()
.accept_all
);
assert!(
args(&[
"convert",
"x.bdf",
"x.vtu",
"--accept-all-approximations-and-infill",
])
.is_err()
);
}
#[test]
fn msh_dialect_is_selected_only_for_msh_output() {
let selected = args(&[
"convert",
"x.bdf",
"x.msh",
"--msh-version",
"2.2",
"--accept-all",
])
.unwrap();
assert_eq!(selected.msh_version, Some(MshVersion::V2_2));
assert!(
args(&[
"convert",
"x.msh",
"x.vtu",
"--msh-version",
"2.2",
"--accept-all"
])
.is_err()
);
assert!(
args(&[
"convert",
"x.bdf",
"x.msh",
"--msh-version",
"9.9",
"--accept-all"
])
.is_err()
);
}
#[test]
fn infill_unit_size_requires_positive_finite_conversion_value() {
assert_eq!(
args(&[
"convert",
"shape.stl",
"shape.vti",
"--infill-shape",
"hex8",
"--infill-unit-size",
"0.5"
])
.unwrap()
.infill_unit_size,
Some(0.5)
);
for bad in ["0", "-1", "NaN", "inf"] {
assert!(
args(&[
"convert",
"shape.stl",
"shape.vox",
"--infill-shape",
"hex8",
"--infill-unit-size",
bad
])
.is_err()
);
}
assert!(
args(&[
"inspect",
"shape.vti",
"--infill-shape",
"hex8",
"--infill-unit-size",
"1"
])
.is_err()
);
}
#[test]
fn smoothing_requires_bounded_stl_conversion() {
assert_eq!(
args(&[
"convert",
"shape.vti",
"shape.stl",
"--smooth-iterations",
"10"
])
.unwrap()
.smooth_iterations,
Some(10)
);
for bad in ["0", "51", "-1", "1.5", "NaN"] {
assert!(
args(&[
"convert",
"shape.vti",
"shape.stl",
"--smooth-iterations",
bad
])
.is_err()
);
}
assert!(
args(&[
"convert",
"shape.vti",
"shape.vtu",
"--smooth-iterations",
"10"
])
.is_err()
);
assert!(args(&["inspect", "shape.stl", "--smooth-iterations", "10"]).is_err());
}
#[test]
fn double_dash_supports_dash_path() {
assert_eq!(
args(&["inspect", "--", "-mesh.bdf"]).unwrap().paths[0],
PathBuf::from("-mesh.bdf")
);
}
#[test]
fn typo_command_rejected() {
assert!(args(&["convertx"]).is_err());
}
#[test]
fn bad_arity_rejected() {
assert!(args(&["convert", "only.bdf", "--accept-all"]).is_err());
}
#[test]
fn formats_command_has_no_paths_or_data_options() {
let report = args(&["formats"]).unwrap();
assert_eq!(report.command, "formats");
assert!(!report.help_requested);
assert!(args(&["formats", "--help"]).unwrap().help_requested);
assert!(args(&["formats", "-h"]).unwrap().help_requested);
assert!(args(&["help", "formats"]).unwrap().help_requested);
assert!(args(&["formats", "--json"]).is_err());
assert!(args(&["formats", "x.bdf"]).is_err());
assert!(args(&["-f"]).is_err());
assert!(args(&["--formats"]).is_err());
}
#[test]
fn assumed_zero_requires_op2_output_and_no_rotation_fill_flag() {
assert!(
args(&[
"convert",
"x.bdf",
"x.op2",
"--accept-all",
"--accept-synthetic-zero"
])
.is_ok()
);
assert!(
args(&[
"convert",
"x.bdf",
"x.vtu",
"--accept-all",
"--accept-synthetic-zero"
])
.is_err()
);
assert!(
args(&[
"convert",
"x.bdf",
"x.op2",
"--accept-all",
"--accept-synthetic-zero",
"--accept-zero-rotations"
])
.is_err()
);
}
#[test]
fn help_topics_and_basic_frame_dependency_are_checked() {
assert_eq!(args(&["help"]).unwrap().command, "help");
assert!(args(&["help", "convert"]).unwrap().help_requested);
assert!(
args(&["help", "unknown"])
.unwrap_err()
.message
.contains("unknown help topic")
);
assert!(
args(&["help", "convert", "extra"])
.unwrap_err()
.message
.contains("one command name")
);
assert!(
args(&["convert", "x.op2", "x.vtu", "--accept-basic-frame"])
.unwrap_err()
.message
.contains("requires --mesh")
);
assert!(
args(&[
"convert",
"x.op2",
"x.vtu",
"--from",
"op2",
"--accept-synthetic-zero"
])
.is_ok()
);
assert!(
args(&[
"convert", "x.bdf", "x.vtu", "--from", "bdf", "--from", "op2"
])
.unwrap_err()
.message
.contains("duplicate --from")
);
}
}