use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use lucid_lint::condition::ConditionTag;
use lucid_lint::config::Profile as ProfileConfig;
use lucid_lint::output::tty::BannerPolicy as TtyBannerPolicy;
use lucid_lint::output::Format as FormatConfig;
use lucid_lint::rules::readability::score::FormulaChoice;
#[derive(Debug, Parser)]
#[command(
name = "lucid-lint",
version,
about = "A cognitive accessibility linter for prose.",
long_about = None,
)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Check(CheckArgs),
Explain(ExplainArgs),
}
#[derive(Debug, Parser)]
pub(crate) struct ExplainArgs {
pub(crate) rule_ids: Vec<String>,
#[arg(long, default_value_t = false, conflicts_with = "list_verbose")]
pub(crate) list: bool,
#[arg(long, default_value_t = false)]
pub(crate) list_verbose: bool,
#[arg(long, default_value_t = false)]
pub(crate) keep_relative: bool,
}
#[derive(Debug, Parser)]
#[allow(clippy::struct_excessive_bools)] pub(crate) struct CheckArgs {
#[arg(required = true)]
pub(crate) paths: Vec<PathBuf>,
#[arg(long, value_enum)]
pub(crate) profile: Option<CliProfile>,
#[arg(long, value_name = "PATH")]
pub(crate) config: Option<PathBuf>,
#[arg(long, value_enum, default_value = "tty")]
pub(crate) format: CliFormat,
#[arg(long, default_value_t = false)]
pub(crate) no_group: bool,
#[arg(long, default_value_t = false)]
pub(crate) no_explain_hint: bool,
#[arg(long, default_value_t = false)]
pub(crate) score_first: bool,
#[arg(long, value_enum, default_value = "auto")]
pub(crate) banner: CliBannerPolicy,
#[arg(
long,
default_value_t = true,
num_args = 0..=1,
require_equals = true,
default_missing_value = "true",
value_parser = clap::builder::BoolishValueParser::new(),
)]
pub(crate) fail_on_warning: bool,
#[arg(long = "no-fail-on-warning", hide = true)]
pub(crate) no_fail_on_warning: bool,
#[arg(long)]
pub(crate) min_score: Option<u32>,
#[arg(long, value_enum, value_delimiter = ',')]
pub(crate) conditions: Vec<CliConditionTag>,
#[arg(long, value_delimiter = ',', value_name = "GLOB")]
pub(crate) exclude: Vec<String>,
#[arg(long, value_enum)]
pub(crate) readability_formula: Option<CliFormulaChoice>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliBannerPolicy {
Auto,
Always,
Never,
}
impl From<CliBannerPolicy> for TtyBannerPolicy {
fn from(value: CliBannerPolicy) -> Self {
match value {
CliBannerPolicy::Auto => Self::Auto,
CliBannerPolicy::Always => Self::Always,
CliBannerPolicy::Never => Self::Never,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliFormulaChoice {
Auto,
#[value(name = "flesch-kincaid")]
FleschKincaid,
#[value(name = "kandel-moles")]
KandelMoles,
}
impl From<CliFormulaChoice> for FormulaChoice {
fn from(value: CliFormulaChoice) -> Self {
match value {
CliFormulaChoice::Auto => Self::Auto,
CliFormulaChoice::FleschKincaid => Self::FleschKincaid,
CliFormulaChoice::KandelMoles => Self::KandelMoles,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliConditionTag {
#[value(name = "a11y-markup")]
A11yMarkup,
Dyslexia,
Dyscalculia,
Aphasia,
Adhd,
#[value(name = "non-native")]
NonNative,
General,
}
impl From<CliConditionTag> for ConditionTag {
fn from(value: CliConditionTag) -> Self {
match value {
CliConditionTag::A11yMarkup => Self::A11yMarkup,
CliConditionTag::Dyslexia => Self::Dyslexia,
CliConditionTag::Dyscalculia => Self::Dyscalculia,
CliConditionTag::Aphasia => Self::Aphasia,
CliConditionTag::Adhd => Self::Adhd,
CliConditionTag::NonNative => Self::NonNative,
CliConditionTag::General => Self::General,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliProfile {
#[value(name = "dev-doc")]
DevDoc,
Public,
Falc,
}
impl From<CliProfile> for ProfileConfig {
fn from(value: CliProfile) -> Self {
match value {
CliProfile::DevDoc => Self::DevDoc,
CliProfile::Public => Self::Public,
CliProfile::Falc => Self::Falc,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliFormat {
Tty,
Json,
Sarif,
}
impl From<CliFormat> for FormatConfig {
fn from(value: CliFormat) -> Self {
match value {
CliFormat::Tty => Self::Tty,
CliFormat::Json => Self::Json,
CliFormat::Sarif => Self::Sarif,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn check_args_parse_defaults() {
let args = Cli::try_parse_from(["lucid-lint", "check", "file.md"]).unwrap();
match args.command {
Command::Check(a) => {
assert_eq!(a.paths.len(), 1);
assert!(
a.profile.is_none(),
"unspecified --profile must be None so main.rs can consult TOML"
);
assert!(a.config.is_none());
assert!(a.readability_formula.is_none());
assert!(matches!(a.format, CliFormat::Tty));
},
Command::Explain(_) => unreachable!("expected Check, got Explain"),
}
}
#[test]
fn check_args_parse_profile() {
let args =
Cli::try_parse_from(["lucid-lint", "check", "--profile", "falc", "file.md"]).unwrap();
match args.command {
Command::Check(a) => assert!(matches!(a.profile, Some(CliProfile::Falc))),
Command::Explain(_) => unreachable!("expected Check, got Explain"),
}
}
#[test]
fn check_args_parse_format() {
let args =
Cli::try_parse_from(["lucid-lint", "check", "--format", "json", "file.md"]).unwrap();
match args.command {
Command::Check(a) => assert!(matches!(a.format, CliFormat::Json)),
Command::Explain(_) => unreachable!("expected Check, got Explain"),
}
}
#[test]
fn check_args_parse_exclude_list() {
let args = Cli::try_parse_from([
"lucid-lint",
"check",
"--exclude",
"vendor/**,CHANGELOG.md",
"--exclude",
"tests/fixtures/**",
"docs",
])
.unwrap();
match args.command {
Command::Check(a) => assert_eq!(
a.exclude,
vec![
"vendor/**".to_string(),
"CHANGELOG.md".to_string(),
"tests/fixtures/**".to_string(),
]
),
Command::Explain(_) => unreachable!("expected Check, got Explain"),
}
}
#[test]
fn fail_on_warning_flag_forms_round_trip() {
let a = Cli::try_parse_from(["lucid-lint", "check", "file.md"])
.unwrap()
.command;
let Command::Check(a) = a else { unreachable!() };
assert!(a.fail_on_warning);
assert!(!a.no_fail_on_warning);
let a = Cli::try_parse_from(["lucid-lint", "check", "--fail-on-warning=false", "file.md"])
.unwrap()
.command;
let Command::Check(a) = a else { unreachable!() };
assert!(!a.fail_on_warning);
let a = Cli::try_parse_from(["lucid-lint", "check", "--no-fail-on-warning", "file.md"])
.unwrap()
.command;
let Command::Check(a) = a else { unreachable!() };
assert!(a.no_fail_on_warning);
}
#[test]
fn explain_args_parse_ids() {
let args = Cli::try_parse_from([
"lucid-lint",
"explain",
"structure.sentence-too-long",
"lexicon.weasel-words",
])
.unwrap();
match args.command {
Command::Explain(a) => {
assert_eq!(
a.rule_ids,
vec!["structure.sentence-too-long", "lexicon.weasel-words"]
);
assert!(!a.list);
},
Command::Check(_) => unreachable!("expected Explain, got Check"),
}
}
#[test]
fn explain_args_allow_list_without_ids() {
let args = Cli::try_parse_from(["lucid-lint", "explain", "--list"]).unwrap();
match args.command {
Command::Explain(a) => {
assert!(a.list);
assert!(a.rule_ids.is_empty());
},
Command::Check(_) => unreachable!("expected Explain, got Check"),
}
}
#[test]
fn explain_args_parse_without_ids_or_flags() {
let args = Cli::try_parse_from(["lucid-lint", "explain"]).unwrap();
match args.command {
Command::Explain(a) => {
assert!(a.rule_ids.is_empty());
assert!(!a.list);
assert!(!a.list_verbose);
},
Command::Check(_) => unreachable!("expected Explain"),
}
}
#[test]
fn explain_args_list_flags_are_mutually_exclusive() {
assert!(
Cli::try_parse_from(["lucid-lint", "explain", "--list", "--list-verbose"]).is_err()
);
}
#[test]
fn profile_conversion_is_exhaustive() {
let cases = [
(CliProfile::DevDoc, ProfileConfig::DevDoc),
(CliProfile::Public, ProfileConfig::Public),
(CliProfile::Falc, ProfileConfig::Falc),
];
for (cli, expected) in cases {
let converted: ProfileConfig = cli.into();
assert_eq!(converted, expected);
}
}
}