use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use std::path::PathBuf;
use crate::models::{DetailLevel, DocConfig, GroupBy, OperationRef, OperationSelector, SortMethod};
#[derive(Parser, Debug)]
#[command(name = "vimanam", version)]
#[command(about = "OpenAPI to Markdown documentation generator", long_about = None)]
#[command(subcommand_negates_reqs = true, args_conflicts_with_subcommands = true)]
#[command(group(ArgGroup::new("tree_output").args(["split", "output_mode"])))]
pub struct Cli {
#[arg(value_name = "FILE", required = true)]
pub input: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(short, long, value_name = "FILE")]
pub output: Option<PathBuf>,
#[arg(long, value_enum, requires = "output", conflicts_with_all = ["stats", "max_tokens", "inline_schemas"])]
pub split: Option<SplitArg>,
#[arg(long, value_enum, requires = "output", conflicts_with_all = ["stats", "max_tokens", "inline_schemas"])]
pub output_mode: Option<OutputModeArg>,
#[arg(long, value_name = "N", requires = "tree_output")]
pub overview_max_tokens: Option<usize>,
#[arg(long)]
pub method: bool,
#[arg(long, value_enum, default_value = "service")]
pub group_by: GroupByArg,
#[arg(long)]
pub flat: bool,
#[arg(long, value_delimiter = ',')]
pub service_filter: Option<Vec<String>>,
#[arg(long)]
pub path_filter: Option<String>,
#[arg(long, value_delimiter = ',')]
pub method_filter: Option<Vec<String>>,
#[arg(long = "operation", value_name = "METHOD PATH", value_parser = parse_operation)]
pub operations: Vec<OperationRef>,
#[arg(long = "operation-id", value_name = "ID")]
pub operation_ids: Vec<String>,
#[arg(long)]
pub exclude_deprecated: bool,
#[arg(long)]
pub required_only: bool,
#[arg(long, value_enum, default_value = "summary")]
pub detail: DetailLevelArg,
#[arg(long)]
pub include_schemas: bool,
#[arg(long)]
pub inline_schemas: bool,
#[arg(long, value_name = "N")]
pub schema_depth: Option<usize>,
#[arg(long = "schema", value_name = "NAME", conflicts_with_all = ["split", "output_mode", "stats"])]
pub schema_names: Vec<String>,
#[arg(long = "schema-field", value_name = "NAME#POINTER", conflicts_with_all = ["split", "output_mode", "stats"])]
pub schema_fields: Vec<String>,
#[arg(long)]
pub include_examples: bool,
#[arg(long)]
pub include_auth: bool,
#[arg(long, overrides_with = "no_toc")]
pub toc: bool,
#[arg(long)]
pub no_toc: bool,
#[arg(long, value_enum, default_value = "alpha")]
pub sort: SortArg,
#[arg(long, value_name = "N")]
pub max_tokens: Option<usize>,
#[arg(long)]
pub no_report: bool,
#[arg(long, conflicts_with_all = ["output", "max_tokens"])]
pub stats: bool,
#[arg(long, conflicts_with_all = ["output", "max_tokens", "stats", "split", "output_mode", "schema_names", "schema_fields"])]
pub costs: bool,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Completions {
#[arg(value_enum)]
shell: Shell,
},
Diff(DiffArgs),
}
#[derive(Args, Debug)]
pub struct DiffArgs {
#[arg(value_name = "OLD")]
pub old: PathBuf,
#[arg(value_name = "NEW")]
pub new: PathBuf,
#[arg(long)]
pub report: bool,
#[arg(long, value_enum, default_value_t = DiffFormatArg::Markdown)]
pub format: DiffFormatArg,
#[arg(long)]
pub fail_on_breaking: bool,
#[arg(short, long, value_name = "FILE")]
pub output: Option<PathBuf>,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum DiffFormatArg {
Markdown,
Json,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum GroupByArg {
Service,
Method,
Path,
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum, Debug)]
pub enum SplitArg {
Service,
Tag,
Endpoint,
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum, Debug)]
pub enum OutputModeArg {
Skill,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum DetailLevelArg {
Summary,
Basic,
Standard,
Full,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum SortArg {
Alpha,
PathLength,
None,
}
impl From<GroupByArg> for GroupBy {
fn from(arg: GroupByArg) -> Self {
match arg {
GroupByArg::Service => GroupBy::Service,
GroupByArg::Method => GroupBy::Method,
GroupByArg::Path => GroupBy::Path,
}
}
}
impl From<DetailLevelArg> for DetailLevel {
fn from(arg: DetailLevelArg) -> Self {
match arg {
DetailLevelArg::Summary => DetailLevel::Summary,
DetailLevelArg::Basic => DetailLevel::Basic,
DetailLevelArg::Standard => DetailLevel::Standard,
DetailLevelArg::Full => DetailLevel::Full,
}
}
}
impl From<SortArg> for SortMethod {
fn from(arg: SortArg) -> Self {
match arg {
SortArg::Alpha => SortMethod::Alphabetical,
SortArg::PathLength => SortMethod::PathLength,
SortArg::None => SortMethod::None,
}
}
}
pub fn build_config(cli: &Cli, notice: &mut dyn FnMut(&str)) -> DocConfig {
let group_by = if cli.flat {
GroupBy::Flat
} else if cli.method {
GroupBy::Method
} else {
cli.group_by.into()
};
let config = DocConfig {
group_by,
service_filter: cli.service_filter.clone(),
path_filter: cli.path_filter.clone(),
method_filter: cli
.method_filter
.as_ref()
.map(|methods| methods.iter().map(|m| m.to_uppercase()).collect()),
operation_selector: if cli.operations.is_empty() && cli.operation_ids.is_empty() {
None
} else {
Some(OperationSelector {
operations: cli.operations.iter().cloned().collect(),
operation_ids: cli.operation_ids.iter().cloned().collect(),
})
},
exclude_deprecated: cli.exclude_deprecated,
required_only: cli.required_only,
detail_level: if cli.schema_names.is_empty() && cli.schema_fields.is_empty() {
cli.detail.into()
} else {
DetailLevel::Full
},
include_schemas: cli.include_schemas
|| !cli.schema_names.is_empty()
|| !cli.schema_fields.is_empty(),
inline_schemas: cli.inline_schemas,
schema_depth: cli.schema_depth,
schema_names: cli.schema_names.clone(),
schema_fields: cli.schema_fields.clone(),
source_path: cli
.input
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
include_examples: cli.include_examples,
include_auth: cli.include_auth,
include_toc: cli.toc || !cli.no_toc,
sort_method: cli.sort.into(),
max_tokens: cli.max_tokens,
include_report: !cli.no_report
&& cli.schema_names.is_empty()
&& cli.schema_fields.is_empty(),
};
let detail_name = detail_arg_name(cli.detail);
if config.include_schemas && config.detail_level != DetailLevel::Full {
notice(&format!(
"vimanam: --include-schemas has no effect at --detail {detail_name}; use --detail full."
));
}
if config.include_examples && config.detail_level != DetailLevel::Full {
notice(&format!(
"vimanam: --include-examples has no effect at --detail {detail_name}; use --detail full."
));
}
if config.inline_schemas && !config.include_schemas {
notice("vimanam: --inline-schemas has no effect without --include-schemas.");
}
if config.required_only
&& matches!(
config.detail_level,
DetailLevel::Basic | DetailLevel::Summary
)
{
notice(&format!(
"vimanam: --required-only has no effect at --detail {detail_name}; use --detail standard or full."
));
}
config
}
fn parse_operation(value: &str) -> Result<OperationRef, String> {
let usage =
|| format!("expected \"METHOD /path\" (for example \"GET /users/{{id}}\"), got {value:?}");
let (method, rest) = value.split_once(char::is_whitespace).ok_or_else(usage)?;
let path = rest.trim_start();
if method.is_empty()
|| !method.chars().all(|c| c.is_ascii_alphabetic())
|| !path.starts_with('/')
{
return Err(usage());
}
Ok(OperationRef {
method: method.to_ascii_uppercase(),
path: path.to_string(),
})
}
fn detail_arg_name(detail: DetailLevelArg) -> &'static str {
match detail {
DetailLevelArg::Summary => "summary",
DetailLevelArg::Basic => "basic",
DetailLevelArg::Standard => "standard",
DetailLevelArg::Full => "full",
}
}
impl From<SplitArg> for crate::models::SplitMode {
fn from(value: SplitArg) -> Self {
match value {
SplitArg::Service => Self::Service,
SplitArg::Tag => Self::Tag,
SplitArg::Endpoint => Self::Endpoint,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn test_detail_level_conversion() {
let cli = Cli {
input: Some(PathBuf::from("spec.json")),
command: None,
output: None,
split: None,
output_mode: None,
overview_max_tokens: None,
method: false,
group_by: GroupByArg::Service,
flat: false,
service_filter: None,
path_filter: None,
method_filter: None,
operations: Vec::new(),
operation_ids: Vec::new(),
exclude_deprecated: false,
required_only: false,
detail: DetailLevelArg::Summary,
include_schemas: false,
inline_schemas: false,
schema_depth: None,
schema_names: Vec::new(),
schema_fields: Vec::new(),
include_examples: false,
include_auth: false,
toc: false,
no_toc: false,
sort: SortArg::Alpha,
max_tokens: None,
no_report: false,
stats: false,
costs: false,
};
let config = build_config(&cli, &mut |_| {});
assert_matches!(config.detail_level, DetailLevel::Summary);
let mut cli_basic = cli;
cli_basic.detail = DetailLevelArg::Basic;
let config_basic = build_config(&cli_basic, &mut |_| {});
assert_matches!(config_basic.detail_level, DetailLevel::Basic);
}
#[test]
fn parse_operation_splits_on_first_whitespace_run() {
let op = parse_operation("get \t /users/{id}").unwrap();
assert_eq!(op.method, "GET");
assert_eq!(op.path, "/users/{id}");
let op = parse_operation("POST /a,b c").unwrap();
assert_eq!(op.path, "/a,b c");
}
#[test]
fn parse_operation_rejects_malformed_values() {
for bad in ["GET", "/users", "GET users", " GET /users", "", "G3T /x"] {
assert!(parse_operation(bad).is_err(), "{bad:?} should be rejected");
}
}
}