use std::{collections::BTreeMap, path::PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use figment::{
Profile, Provider,
value::{Dict, Map, Value},
};
use serde::{Deserialize, Serialize};
use crate::config::{AmbiguousWidth, SpacingRule};
use crate::language::Language;
#[derive(ValueEnum, Debug, Clone, Deserialize, Serialize)]
pub enum ColorOutputMode {
Always,
Never,
Auto,
}
#[derive(Parser, Debug, Deserialize, Serialize)]
#[command(version, about, long_about = None)]
pub struct CliArgs {
#[arg(value_enum, long, default_value_t = ColorOutputMode::Auto)]
pub color: ColorOutputMode,
#[arg(short, long)]
pub max_width: Option<u32>,
#[arg(long, value_enum)]
pub ambiguous_width: Option<AmbiguousWidth>,
#[arg(long, value_enum)]
pub spacing_alphabets: Option<SpacingRule>,
#[arg(long, value_enum)]
pub spacing_digits: Option<SpacingRule>,
#[command(subcommand)]
pub command: Commands,
}
impl Provider for CliArgs {
fn metadata(&self) -> figment::Metadata {
figment::Metadata::named("Command line arguments")
}
fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
let mut dict = BTreeMap::new();
if let Some(max_width) = self.max_width {
dict.insert("max_width".to_string(), Value::from(max_width));
}
if let Some(ambiguous_width) = self.ambiguous_width {
dict.insert(
"ambiguous_width".to_string(),
Value::serialize(ambiguous_width)?,
);
}
let mut spacing = BTreeMap::new();
if let Some(alphabets) = self.spacing_alphabets {
spacing.insert("alphabets".to_string(), Value::serialize(alphabets)?);
}
if let Some(digits) = self.spacing_digits {
spacing.insert("digits".to_string(), Value::serialize(digits)?);
}
if !spacing.is_empty() {
dict.insert("spacing".to_string(), Value::from(spacing));
}
let mut map = BTreeMap::new();
map.insert(Profile::Default, dict);
Ok(map)
}
}
#[derive(Subcommand, Debug, Deserialize, Serialize)]
pub enum Commands {
Format {
#[arg(short, long, requires = "filenames")]
write: bool,
#[arg(long, value_enum)]
language: Option<Language>,
#[arg()]
filenames: Vec<PathBuf>,
},
Check {
#[arg(long, value_enum)]
language: Option<Language>,
#[arg()]
filenames: Vec<PathBuf>,
},
DebugCst {
#[arg(long, value_enum)]
language: Option<Language>,
#[arg()]
filenames: Vec<PathBuf>,
},
}
#[cfg(test)]
mod tests {
use std::path::Path;
use figment::{Figment, providers::Serialized};
use rstest::rstest;
use super::*;
use crate::config::Config;
fn config_from(arguments: impl IntoIterator<Item = &'static str>) -> Config {
let args =
CliArgs::try_parse_from(arguments).expect("the command-line arguments should parse");
Figment::new()
.merge(Serialized::defaults(Config::default()))
.merge(&args)
.extract()
.expect("CLI values should deserialize as configuration")
}
#[rstest]
#[case("--write")]
#[case("-w")]
fn format_write_flag_accepts_a_filename(#[case] flag: &str) {
let args = CliArgs::try_parse_from(["cjkfmt", "format", flag, "file.md"])
.expect("the write command-line arguments should parse");
match args.command {
Commands::Format {
write,
language,
filenames,
} => {
assert!(write);
assert_eq!(language, None);
assert_eq!(filenames, [PathBuf::from("file.md")]);
}
_ => panic!("expected format command"),
}
}
#[test]
fn format_write_flag_requires_a_filename() {
let result = CliArgs::try_parse_from(["cjkfmt", "format", "--write"]);
assert!(result.is_err());
}
fn selected_language(args: CliArgs) -> Option<Language> {
match args.command {
Commands::Format { language, .. }
| Commands::Check { language, .. }
| Commands::DebugCst { language, .. } => language,
}
}
#[rstest]
#[case("format")]
#[case("check")]
#[case("debug-cst")]
fn language_flag_accepts_supported_values_and_defaults_to_none(#[case] command: &str) {
for (value, expected) in [("markdown", Language::Markdown), ("json", Language::Json)] {
let args = CliArgs::try_parse_from(["cjkfmt", command, "--language", value])
.expect("supported language should parse");
assert_eq!(selected_language(args), Some(expected));
}
let args =
CliArgs::try_parse_from(["cjkfmt", command]).expect("language should be optional");
assert_eq!(selected_language(args), None);
}
#[rstest]
#[case("format")]
#[case("check")]
#[case("debug-cst")]
fn language_flag_rejects_unknown_values(#[case] command: &str) {
assert!(
CliArgs::try_parse_from(["cjkfmt", command, "--language", "unknown-lang"]).is_err()
);
}
#[test]
fn language_resolution_prefers_an_explicit_language() {
assert_eq!(
Language::explicit_or_inferred_path(
Some(Language::Markdown),
Path::new("document.json"),
),
Some(Language::Markdown)
);
assert_eq!(
Language::explicit_or_inferred_path(Some(Language::Json), Path::new("document.txt")),
Some(Language::Json)
);
}
#[test]
fn language_resolution_uses_canonical_filename_extensions() {
assert_eq!(
Language::explicit_or_inferred_path(None, Path::new("document.MarkDown")),
Some(Language::Markdown)
);
assert_eq!(
Language::explicit_or_inferred_path(None, Path::new("document.JSON")),
Some(Language::Json)
);
assert_eq!(
Language::explicit_or_inferred_path(None, Path::new("document.txt")),
None
);
}
#[test]
fn max_width_flag_maps_clap_value_to_config() {
let config = config_from(["cjkfmt", "--max-width", "42", "format"]);
assert_eq!(config.max_width, 42);
}
#[rstest]
#[case("narrow", AmbiguousWidth::Narrow)]
#[case("wide", AmbiguousWidth::Wide)]
fn ambiguous_width_flag_maps_each_clap_value_to_config(
#[case] value: &'static str,
#[case] expected: AmbiguousWidth,
) {
let config = config_from(["cjkfmt", "--ambiguous-width", value, "format"]);
assert_eq!(config.ambiguous_width, expected);
}
#[rstest]
#[case("require", SpacingRule::Require)]
#[case("prohibit", SpacingRule::Prohibit)]
#[case("ignore", SpacingRule::Ignore)]
fn spacing_alphabets_flag_maps_each_clap_value_to_config(
#[case] value: &'static str,
#[case] expected: SpacingRule,
) {
let config = config_from(["cjkfmt", "--spacing-alphabets", value, "format"]);
assert_eq!(config.spacing.alphabets, expected);
}
#[rstest]
#[case("require", SpacingRule::Require)]
#[case("prohibit", SpacingRule::Prohibit)]
#[case("ignore", SpacingRule::Ignore)]
fn spacing_digits_flag_maps_each_clap_value_to_config(
#[case] value: &'static str,
#[case] expected: SpacingRule,
) {
let config = config_from(["cjkfmt", "--spacing-digits", value, "format"]);
assert_eq!(config.spacing.digits, expected);
}
#[test]
fn spacing_flags_are_merged_as_independent_nested_config_values() {
let config = config_from([
"cjkfmt",
"--spacing-alphabets",
"require",
"--spacing-digits",
"prohibit",
"format",
]);
assert_eq!(config.spacing.alphabets, SpacingRule::Require);
assert_eq!(config.spacing.digits, SpacingRule::Prohibit);
}
}