use crate::catalogue::{self, RuleMeta};
pub fn did_you_mean(unknown_id: &str) -> Vec<String> {
let candidates: Vec<&str> = catalogue::all_rules().iter().map(|r| r.rule_id).collect();
close_matches(unknown_id, &candidates, 5, 0.6)
}
fn close_matches(word: &str, candidates: &[&str], n: usize, cutoff: f32) -> Vec<String> {
let mut scored: Vec<(f32, &str)> = candidates
.iter()
.filter_map(|&cand| {
let mut matcher = difflib::sequencematcher::SequenceMatcher::new(cand, word);
let ratio = matcher.ratio();
if ratio >= cutoff {
Some((ratio, cand))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| b.1.cmp(a.1)));
scored.truncate(n);
scored.into_iter().map(|(_, s)| s.to_string()).collect()
}
fn render_one_terminal(rule_id: &str, meta: &RuleMeta) -> String {
let mut lines = vec![
format!("{rule_id} ({})", meta.severity.as_str().to_uppercase()),
format!(" Category: {}", meta.category),
format!(" Authority: {} ({})", meta.authority, meta.authority_ref),
];
if meta.confidence != "high" {
lines.push(format!(
" Confidence: {} (measured corpus precision below the gate; see eval/improvement-log.md)",
meta.confidence
));
}
if !meta.guide_section.is_empty() {
lines.push(format!(" JSS guide: {}", meta.guide_section));
if let Some(url) = meta.guide_url {
lines.push(format!(" See: {url}"));
}
}
lines.push(String::new());
let body = if !meta.explanation.is_empty() {
meta.explanation
} else {
meta.message_template
};
lines.push(body.to_string());
lines.join("\n") + "\n"
}
fn render_one_markdown(rule_id: &str, meta: &RuleMeta) -> String {
let mut parts = vec![format!("# {rule_id}"), String::new()];
parts.push(format!("- **Severity:** {}", meta.severity.as_str()));
parts.push(format!("- **Category:** {}", meta.category));
parts.push(format!(
"- **Authority:** {} ({})",
meta.authority, meta.authority_ref
));
if meta.confidence != "high" {
parts.push(format!("- **Confidence:** {}", meta.confidence));
}
if !meta.guide_section.is_empty() {
if let Some(url) = meta.guide_url {
parts.push(format!("- **JSS guide:** [{}]({url})", meta.guide_section));
} else {
parts.push(format!("- **JSS guide:** {}", meta.guide_section));
}
}
parts.push(String::new());
let body = if !meta.explanation.is_empty() {
meta.explanation
} else {
meta.message_template
};
parts.push(body.to_string());
parts.push(String::new());
parts.join("\n")
}
fn render_listing(fmt: &str) -> String {
use std::collections::BTreeMap;
let mut by_category: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for meta in catalogue::all_rules() {
by_category
.entry(meta.category)
.or_default()
.push(meta.rule_id);
}
for ids in by_category.values_mut() {
ids.sort_unstable();
}
let mut out: Vec<String> = Vec::new();
if fmt == "markdown" {
out.push("# JSS-lint rule catalogue\n".to_string());
} else {
out.push("JSS-lint rule catalogue:\n".to_string());
}
for (category, rule_ids) in &by_category {
if fmt == "markdown" {
out.push(format!("## {category}\n"));
for rid in rule_ids {
let meta = catalogue::lookup(rid).expect("catalogue rule");
out.push(format!("- `{rid}` — {}", meta.message_template));
}
out.push(String::new());
} else {
out.push(format!(" {category}:"));
for rid in rule_ids {
let meta = catalogue::lookup(rid).expect("catalogue rule");
out.push(format!(" {rid} {}", meta.message_template));
}
out.push(String::new());
}
}
let joined = out.join("\n");
if fmt == "markdown" {
joined + "\n"
} else {
joined
}
}
pub fn render(rule_id: Option<&str>, fmt: &str) -> Result<String, String> {
let Some(raw_id) = rule_id else {
return Ok(render_listing(fmt));
};
let normalized = raw_id.trim().to_uppercase();
let Some(meta) = catalogue::lookup(&normalized) else {
return Err(normalized);
};
if fmt == "markdown" {
Ok(render_one_markdown(&normalized, meta))
} else {
Ok(render_one_terminal(&normalized, meta))
}
}