use clap::ValueEnum;
use rumdl_lib::config as rumdl_config;
use rumdl_lib::exit_codes::exit;
use rumdl_lib::rule::{FixCapability, Rule, RuleCategory};
#[derive(Clone, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
#[value(alias("jsonl"))]
JsonLines,
}
#[derive(serde::Serialize)]
struct RuleInfo {
code: String,
name: String,
aliases: Vec<String>,
summary: String,
category: String,
fix: String,
fix_availability: String,
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
explanation: Option<String>,
}
pub fn handle_rule(
rule: Option<String>,
output_format: OutputFormat,
fixable: bool,
category: Option<String>,
explain: bool,
list_categories: bool,
) {
let default_config = rumdl_config::Config::default();
let all_rules = rumdl_lib::rules::all_rules(&default_config);
let mut categories: Vec<String> = all_rules
.iter()
.map(|r| category_to_string(r.category()).to_string())
.collect();
categories.sort();
categories.dedup();
if list_categories {
println!("Available categories:");
for cat in &categories {
let count = all_rules
.iter()
.filter(|r| category_to_string(r.category()) == cat)
.count();
println!(" {cat} ({count} rules)");
}
return;
}
if let Some(ref cat_filter) = category {
let cat_filter_lower = cat_filter.to_lowercase();
if !categories.iter().any(|c| c.to_lowercase() == cat_filter_lower) {
eprintln!("Invalid category: '{cat_filter}'");
eprintln!("Valid categories: {}", categories.join(", "));
exit::tool_error();
}
}
let aliases_map = build_rule_aliases_map();
let build_rule_info = |r: &dyn Rule, include_explanation: bool| -> RuleInfo {
let code = r.name().to_string();
let all_aliases = aliases_map.get(&code).cloned().unwrap_or_default();
let (primary_name, remaining_aliases) = get_primary_and_remaining_aliases(&code, &all_aliases);
let (fix_desc, fix_avail) = fix_capability_to_strings(r.fix_capability());
let explanation = if include_explanation {
read_rule_explanation(&code)
} else {
None
};
RuleInfo {
name: primary_name,
aliases: remaining_aliases,
code: code.clone(),
summary: r.description().to_string(),
category: category_to_string(r.category()).to_string(),
fix: fix_desc.to_string(),
fix_availability: fix_avail.to_string(),
url: format!("https://rumdl.dev/{}/", code.to_lowercase()),
explanation,
}
};
let mut rule_infos: Vec<RuleInfo> = if let Some(rule_query) = &rule {
let rule_query_upper = rule_query.to_ascii_uppercase();
let found = all_rules.iter().find(|r| {
r.name().eq_ignore_ascii_case(&rule_query_upper)
|| r.name().replace("MD", "") == rule_query_upper.replace("MD", "")
});
if let Some(r) = found {
vec![build_rule_info(r.as_ref(), explain)]
} else {
eprintln!("Rule '{rule_query}' not found.");
exit::tool_error();
}
} else {
all_rules.iter().map(|r| build_rule_info(r.as_ref(), explain)).collect()
};
if fixable {
rule_infos.retain(|info| info.fix_availability != "None");
}
if let Some(ref cat_filter) = category {
let cat_filter_lower = cat_filter.to_lowercase();
rule_infos.retain(|info| info.category.to_lowercase() == cat_filter_lower);
}
if rule_infos.is_empty() && rule.is_none() {
let mut filter_desc = Vec::new();
if fixable {
filter_desc.push("fixable".to_string());
}
if let Some(ref cat) = category {
filter_desc.push(format!("category={cat}"));
}
eprintln!("No rules match the specified filters: {}", filter_desc.join(", "));
eprintln!("Try: rumdl rule --list-categories");
exit::tool_error();
}
match output_format {
OutputFormat::Json => {
let json = if rule.is_some() && rule_infos.len() == 1 {
serde_json::to_string_pretty(&rule_infos[0])
} else {
serde_json::to_string_pretty(&rule_infos)
};
match json {
Ok(output) => println!("{output}"),
Err(e) => {
eprintln!("Error serializing to JSON: {e}");
exit::tool_error();
}
}
}
OutputFormat::JsonLines => {
for info in &rule_infos {
match serde_json::to_string(info) {
Ok(line) => println!("{line}"),
Err(e) => {
eprintln!("Error serializing to JSON: {e}");
exit::tool_error();
}
}
}
}
OutputFormat::Text => {
if rule.is_some() {
if let Some(info) = rule_infos.first() {
println!("{} - {}", info.code, info.summary);
println!();
println!("Name: {}", info.name);
if !info.aliases.is_empty() {
println!("Aliases: {}", info.aliases.join(", "));
}
println!("Category: {}", info.category);
println!("Fix: {}", info.fix);
println!("Documentation: {}", info.url);
if let Some(ref explanation) = info.explanation {
println!();
println!("{explanation}");
}
}
} else {
let filter_info = if fixable || category.is_some() {
let mut parts = Vec::new();
if fixable {
parts.push("fixable".to_string());
}
if let Some(ref cat) = category {
parts.push(format!("category={cat}"));
}
format!(" ({})", parts.join(", "))
} else {
String::new()
};
println!("Available rules{filter_info}:");
for info in &rule_infos {
println!(" {} - {}", info.code, info.summary);
}
println!();
println!("Total: {} rules", rule_infos.len());
}
}
}
}
fn read_rule_explanation(code: &str) -> Option<String> {
let code_lower = code.to_lowercase();
let possible_paths = [format!("docs/{code_lower}.md"), format!("../docs/{code_lower}.md")];
for path in &possible_paths {
if let Ok(content) = std::fs::read_to_string(path) {
return Some(content);
}
}
None
}
fn build_rule_aliases_map() -> std::collections::HashMap<String, Vec<String>> {
use rumdl_config::RULE_ALIAS_MAP;
let mut aliases_map: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for (alias, canonical) in RULE_ALIAS_MAP.entries() {
if *alias == *canonical {
continue;
}
let alias_kebab = alias.to_lowercase();
aliases_map.entry(canonical.to_string()).or_default().push(alias_kebab);
}
for aliases in aliases_map.values_mut() {
aliases.sort();
}
aliases_map
}
fn category_to_string(category: RuleCategory) -> &'static str {
match category {
RuleCategory::Heading => "heading",
RuleCategory::List => "list",
RuleCategory::CodeBlock => "code-block",
RuleCategory::Link => "link",
RuleCategory::Image => "image",
RuleCategory::Html => "html",
RuleCategory::Emphasis => "emphasis",
RuleCategory::Whitespace => "whitespace",
RuleCategory::Blockquote => "blockquote",
RuleCategory::Table => "table",
RuleCategory::FrontMatter => "front-matter",
RuleCategory::Other => "other",
}
}
fn fix_capability_to_strings(capability: FixCapability) -> (&'static str, &'static str) {
match capability {
FixCapability::FullyFixable => ("Fix is always available.", "Always"),
FixCapability::ConditionallyFixable => ("Fix is sometimes available.", "Sometimes"),
FixCapability::Unfixable => ("Fix is not available.", "None"),
}
}
fn get_primary_and_remaining_aliases(code: &str, aliases: &[String]) -> (String, Vec<String>) {
if aliases.is_empty() {
(code.to_lowercase(), Vec::new())
} else {
let primary = aliases[0].clone();
let remaining: Vec<String> = aliases.iter().skip(1).cloned().collect();
(primary, remaining)
}
}