use std::path::PathBuf;
use bastyn_core::Framework;
use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Debug, Parser)]
#[command(
name = "bastyn",
version,
about,
long_about = None,
propagate_version = true,
)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
#[command(flatten)]
pub(crate) global: GlobalArgs,
}
#[derive(Debug, Args)]
pub(crate) struct GlobalArgs {
#[arg(long, short, global = true, value_enum, default_value_t = Format::Text)]
pub(crate) format: Format,
#[arg(long, short, global = true)]
pub(crate) quiet: bool,
#[arg(long, global = true)]
pub(crate) no_color: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub(crate) enum Format {
Text,
Json,
Sarif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum GroupBy {
#[value(name = "layer")]
Layer,
#[value(name = "eu-ai-act")]
EuAiAct,
#[value(name = "nist-ai-rmf")]
NistAiRmf,
#[value(name = "nist-genai")]
NistGenAi,
}
impl GroupBy {
pub(crate) const fn framework(self) -> Option<Framework> {
match self {
Self::Layer => None,
Self::EuAiAct => Some(Framework::EuAiAct),
Self::NistAiRmf => Some(Framework::NistAiRmf),
Self::NistGenAi => Some(Framework::NistGenAi),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
#[value(rename_all = "lower")]
pub(crate) enum FailOn {
None,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Scan(ScanArgs),
}
#[derive(Debug, Args)]
#[expect(
clippy::struct_excessive_bools,
reason = "command-line flags are booleans; a state machine would obscure the surface"
)]
pub(crate) struct ScanArgs {
#[arg(default_value = ".", value_name = "PATH")]
pub(crate) path: PathBuf,
#[arg(long, value_enum, default_value_t = FailOn::High)]
pub(crate) fail_on: FailOn,
#[arg(long, value_enum, default_value_t = GroupBy::Layer, value_name = "TAXONOMY")]
pub(crate) group_by: GroupBy,
#[arg(long)]
pub(crate) show_observations: bool,
#[arg(long)]
pub(crate) offline: bool,
#[arg(long, value_name = "GLOB")]
pub(crate) exclude: Vec<String>,
#[arg(long)]
pub(crate) no_ignore: bool,
#[arg(long)]
pub(crate) hidden: bool,
#[arg(long)]
pub(crate) follow_symlinks: bool,
#[arg(long, value_name = "N")]
pub(crate) max_depth: Option<usize>,
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "a failed assumption in a test should fail the test"
)]
mod tests {
use clap::{CommandFactory as _, Parser as _};
use super::{Cli, Command, GroupBy};
use bastyn_core::Framework;
#[test]
fn the_default_grouping_is_the_one_that_existed_before_the_flag() {
let cli = Cli::parse_from(["bastyn", "scan"]);
let Command::Scan(args) = &cli.command;
assert_eq!(args.group_by, GroupBy::Layer);
assert_eq!(args.group_by.framework(), None);
}
#[test]
fn every_framework_value_is_spelled_the_same_on_both_sides() {
for (typed, framework) in [
("eu-ai-act", Framework::EuAiAct),
("nist-ai-rmf", Framework::NistAiRmf),
("nist-genai", Framework::NistGenAi),
] {
let cli = Cli::parse_from(["bastyn", "scan", "--group-by", typed]);
let Command::Scan(args) = &cli.command;
assert_eq!(
args.group_by.framework(),
Some(framework),
"--group-by {typed} must select {}",
framework.name()
);
assert_eq!(framework.id(), typed, "the two spellings have drifted");
}
}
#[test]
fn no_framework_is_unreachable_from_the_command_line() {
for framework in Framework::ALL {
let cli = Cli::parse_from(["bastyn", "scan", "--group-by", framework.id()]);
let Command::Scan(args) = &cli.command;
assert_eq!(args.group_by.framework(), Some(framework));
}
}
#[test]
fn the_help_text_refuses_to_promise_compliance() {
let mut command = Cli::command().find_subcommand("scan").unwrap().clone();
let rendered = command.render_long_help().to_string();
let help = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(help.contains("crosswalk"), "{rendered}");
assert!(help.contains("cannot determine compliance"), "{rendered}");
assert!(
help.contains("Finding nothing does not mean an obligation is met"),
"{rendered}"
);
for word in ["complies", "compliant", "certified", "audit passed"] {
assert!(
!help.to_lowercase().contains(word),
"{word:?} appears in --help"
);
}
}
#[test]
fn an_unknown_grouping_is_rejected_with_the_valid_values() {
let error = Cli::try_parse_from(["bastyn", "scan", "--group-by", "iso-42001"])
.unwrap_err()
.to_string();
assert!(error.contains("eu-ai-act"), "{error}");
assert!(error.contains("layer"), "{error}");
}
}