//! The command line: what the flags are, and what `--help` says about them.
//!
//! Parsed by hand. A derive-macro dependency would cost more to compile than
//! it saves to read at this size, and the parser is small enough to hold in
//! one screen and test exhaustively - which is what the tests at the bottom
//! do, one property over every flag rather than a case per flag.
use std::path::PathBuf;
pub(crate) fn usage() {
eprintln!(
"helm - a queryable database of MegaMek's unit library
USAGE
helm build --megamek
--out [--bridge-dir ]
helm stats
helm bv-report --megamek --bridge-dir
helm art --megamek [--out ]
helm index --megamek --bridge-dir --out [--helm-version []
[--filters ]]
helm force --megamek --bridge-dir
helm check --megamek --bridge-dir
helm repairs --megamek --bridge-dir
helm wear --to --megamek --bridge-dir
helm catalogue --bridge-dir --out
helm wire-check --megamek --bridge-dir --out
BUILD
--megamek A MegaMek install. Reads data/mekfiles/unit_files.zip,
or the loose .mtf and .blk files if the zip is absent.
--out Where to write the SQLite database.
--bridge-dir Directory holding units.jsonl and equipment.jsonl from
the Java bridge. Without it the database is built from
the unit files alone and the computed columns - battle
value, cost, Alpha Strike - are null.
--megamek-version Recorded in the meta table. Read from the install's
docs/history.txt when not given.
BV-REPORT
How helm-bv's battle values compare with MegaMek's own, design by design.
Needs both: the library to compute from, and the bridge dump to check
against. Exits non-zero while anything still disagrees.
--unit Print one design's working beside MegaMek's instead of
the whole report. Matches on any part of the name.
--clusters Group the designs that still disagree by what they have
in common, worst first. What to fix next, rather than
which single design is furthest out.
--label List the designs that disagree and carry that label, and
which half of the calculation is wrong. `*` lists every
design that disagrees. Implies --clusters.
--rules How many designs each design-level rule fires on. A rule
firing on nothing is a typo or a component this MegaMek
does not ship, and both look like an unwritten rule.
--bench What a recompute costs, split by what changed. Reseating
a pilot and repairing armour are not the same work.
--damaged Compare against damaged.jsonl instead of the whole
library: what a handful of designs are worth once they
have been shot at, which a .mtf cannot say.
CHECK
What is wrong with a .mul before anything else touches it: designs that are
not in the library, crews outside 0-8, locations a shape does not have,
more plate or frame than a design was built with. Exits non-zero when a
file has something wrong with it.
WEAR
Damage a design until it is worth a given battle value, for fitting a
machine that has already been in a fight into a scenario's budget. Plate
first, then the frame under it, spread evenly across the locations.
--to The figure to aim for. Required.
--battle Damage it in a battle instead of shaving plate evenly:
fire lands where the hit table sends it, clusters, and
takes equipment out when it gets through. Every seed is
a different machine worth the same, and none of them is
a mission kill.
--seed One battle in particular. Implies --battle.
--write Write the worn machine out as a .mul, so it can be
loaded into MegaMek rather than only read here.
REPAIRS
What a force needs putting right, location by location: points of plate,
points of frame, the equipment shot out and the magazines to fill.
--why Add what each machine's damage cost it in battle value.
FORCE
What each machine in a .mul is worth in the state the file leaves it in,
as flown by the crew the file seats.
--why For each damaged machine, where its value went: the
terms that moved against the design as it left the
factory, worst loss first.
"
);
}
#[derive(Debug, Default)]
pub(crate) struct Opts {
pub(crate) megamek: Option,
pub(crate) out: Option,
pub(crate) bridge_dir: Option,
pub(crate) version: Option,
pub(crate) unit: Option,
pub(crate) clusters: bool,
/// `index`: every unit type rather than Meks alone.
pub(crate) all_types: bool,
/// `index`: the release id of the helm that built it.
pub(crate) helm_version: Option,
/// `index`: a corpus of filters to answer beside the documents, for
/// `facets.mjs` to check the wasm module against.
pub(crate) filters: Option,
pub(crate) label: Option,
pub(crate) rules: bool,
pub(crate) bench: bool,
pub(crate) damaged: bool,
pub(crate) why: bool,
pub(crate) to: Option,
pub(crate) out_mul: Option,
pub(crate) battle: bool,
pub(crate) seed: Option,
}
pub(crate) fn parse_opts(args: &[String]) -> Result {
let mut o = Opts::default();
let mut i = 0;
while i < args.len() {
let need = |i: usize| -> Result {
args.get(i + 1)
.cloned()
.ok_or_else(|| format!("{} needs a value", args[i]))
};
match args[i].as_str() {
"--megamek" => {
o.megamek = Some(PathBuf::from(need(i)?));
i += 2;
}
"--out" => {
o.out = Some(PathBuf::from(need(i)?));
i += 2;
}
"--bridge-dir" => {
o.bridge_dir = Some(PathBuf::from(need(i)?));
i += 2;
}
"--megamek-version" => {
o.version = Some(need(i)?);
i += 2;
}
"--helm-version" => {
o.helm_version = Some(need(i)?);
i += 2;
}
"--filters" => {
o.filters = Some(PathBuf::from(need(i)?));
i += 2;
}
"--unit" => {
o.unit = Some(need(i)?);
i += 2;
}
"--clusters" => {
o.clusters = true;
i += 1;
}
"--all-types" => {
o.all_types = true;
i += 1;
}
"--label" => {
o.label = Some(need(i)?);
o.clusters = true;
i += 2;
}
"--bench" => {
o.bench = true;
i += 1;
}
"--write" => {
o.out_mul = Some(PathBuf::from(need(i)?));
i += 2;
}
"--to" => {
o.to = Some(
need(i)?
.parse()
.map_err(|_| "--to takes a battle value".to_string())?,
);
i += 2;
}
"--battle" => {
o.battle = true;
i += 1;
}
"--seed" => {
o.seed = Some(
need(i)?
.parse()
.map_err(|_| "--seed takes a number".to_string())?,
);
o.battle = true;
i += 2;
}
"--damaged" => {
o.damaged = true;
i += 1;
}
"--why" => {
o.why = true;
i += 1;
}
"--rules" => {
o.rules = true;
i += 1;
}
other => return Err(format!("unknown flag: {other}")),
}
}
Ok(o)
}
#[cfg(test)]
mod tests {
use super::*;
/// Every flag consumes itself and its value and nothing else.
///
/// The parser walks the arguments with an index it steps itself, so a
/// flag that takes a value and steps one lands on its own value and reads
/// it as a flag, and one that takes none and steps two eats whatever
/// follows. Both are silent, and the second is how an argument goes
/// missing between the command line and the run.
///
/// The test is that **exactly one** of `--flag` and `--flag 1` parses. A
/// flag that wants a value refuses the first and takes the second; one
/// that wants none takes the first and chokes on `1`. A flag that steps
/// wrong accepts both, or neither.
#[test]
fn every_flag_consumes_exactly_what_it_takes() {
for flag in FLAGS {
// `1` because two of them want a number and the rest do not care.
let alone = parse_opts(&[(*flag).to_string()]).is_ok();
let valued = parse_opts(&[(*flag).to_string(), "1".to_string()]).is_ok();
assert!(
alone != valued,
"{flag}: parses alone = {alone}, parses with a value = {valued}; \
one of those has to be false or it is stepping the cursor wrong"
);
}
}
/// A flag whose value is missing is an error rather than a default.
#[test]
fn a_flag_with_no_value_says_so() {
for flag in FLAGS {
if parse_opts(&[(*flag).to_string()]).is_ok() {
continue;
}
let e = parse_opts(&[(*flag).to_string()]).unwrap_err();
assert!(e.contains(flag), "{flag}'s complaint does not name it: {e}");
}
}
#[test]
fn an_unknown_flag_is_refused_rather_than_ignored() {
let e = parse_opts(&["--wintermute".to_string()]).unwrap_err();
assert!(e.contains("--wintermute"), "{e}");
}
/// Two flags turn another on, and a caller that gave only the one still
/// gets the behaviour it asked for.
#[test]
fn a_flag_that_implies_another_sets_it() {
assert!(
parse_opts(&["--label".into(), "*".into()])
.unwrap()
.clusters
);
assert!(parse_opts(&["--seed".into(), "7".into()]).unwrap().battle);
}
#[test]
fn a_number_that_is_not_one_is_refused() {
assert!(parse_opts(&["--to".into(), "lots".into()]).is_err());
assert!(parse_opts(&["--seed".into(), "lots".into()]).is_err());
}
/// Every command `main` dispatches is one `--help` mentions.
///
/// Both lists are read out of the source rather than written down here:
/// a third copy would drift the same way the first two did, and this way
/// adding a command to the dispatch is what makes the test ask about it.
/// `helm catalogue` and `helm wire-check` were dispatched and in no usage
/// text when this was written.
#[test]
fn the_usage_text_names_every_command() {
let usage = include_str!("options.rs")
.split_once("USAGE")
.expect("a usage block in this file")
.1
.split_once("\n\n")
.expect("a usage block that ends")
.0;
let dispatch = include_str!("main.rs");
let mut found = 0;
for line in dispatch.lines() {
let Some(rest) = line.trim().strip_prefix("Some(\"") else {
continue;
};
let Some((command, _)) = rest.split_once('"') else {
continue;
};
// `-h`, `--help` and `help` are the usage text itself.
if command.starts_with('-') || command == "help" {
continue;
}
found += 1;
assert!(
usage.contains(&format!("helm {command}")),
"`helm {command}` is dispatched and not in the usage text"
);
}
assert!(
found > 8,
"only {found} commands were found in the dispatch"
);
}
/// A required flag says why it is required.
///
/// "--bridge-dir is required" tells somebody what to type and not what
/// they were missing; three of these said only that, in three different
/// commands that want it for three different reasons.
#[test]
fn every_required_flag_says_what_it_is_for() {
for source in [
include_str!("build.rs"),
include_str!("force.rs"),
include_str!("report.rs"),
include_str!("wire.rs"),
include_str!("inputs.rs"),
] {
for line in source.lines() {
let Some(at) = line.find("is required") else {
continue;
};
let rest = &line[at + "is required".len()..];
assert!(
rest.starts_with(':') || rest.starts_with(" <"),
"a required flag with no reason: {}",
line.trim()
);
}
}
}
/// The flags `parse_opts` knows, for the tests that ask about all of them.
const FLAGS: &[&str] = &[
"--megamek",
"--out",
"--bridge-dir",
"--megamek-version",
"--helm-version",
"--filters",
"--unit",
"--clusters",
"--all-types",
"--label",
"--bench",
"--write",
"--to",
"--battle",
"--seed",
"--damaged",
"--why",
"--rules",
];
}