use std::io::{self, ErrorKind, IsTerminal, Write};
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use codespan_reporting::term::termcolor::{Color, ColorSpec, StandardStream, WriteColor};
use rpm_spec_analyzer::registry::builtin_lint_metadata;
use rpm_spec_analyzer::{LintCategory, LintMetadata, Severity};
use crate::app::ColorChoice;
use crate::output::resolve_color;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum Format {
Text,
Markdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum CategoryArg {
Style,
Correctness,
Packaging,
Performance,
}
impl CategoryArg {
fn matches(self, c: LintCategory) -> bool {
matches!(
(self, c),
(Self::Style, LintCategory::Style)
| (Self::Correctness, LintCategory::Correctness)
| (Self::Packaging, LintCategory::Packaging)
| (Self::Performance, LintCategory::Performance)
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum SeverityArg {
Allow,
Warn,
Deny,
}
impl SeverityArg {
fn matches(self, s: Severity) -> bool {
matches!(
(self, s),
(Self::Allow, Severity::Allow)
| (Self::Warn, Severity::Warn)
| (Self::Deny, Severity::Deny)
)
}
}
#[derive(Debug, Args)]
pub struct Cmd {
#[arg(long, default_value_t = Format::Text, value_enum)]
pub format: Format,
#[arg(long, value_enum)]
pub category: Vec<CategoryArg>,
#[arg(long, value_enum)]
pub severity: Vec<SeverityArg>,
}
impl Cmd {
pub fn run(self, color: ColorChoice) -> Result<ExitCode> {
let metadata = builtin_lint_metadata();
let filtered = filter(&metadata, &self.category, &self.severity);
let grouped = group_by_category(&filtered);
let result = match self.format {
Format::Text => render_text(&grouped, color),
Format::Markdown => render_markdown(&grouped),
};
match result {
Ok(()) => Ok(ExitCode::SUCCESS),
Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(ExitCode::SUCCESS),
Err(e) => Err(e).context("writing lint reference to stdout"),
}
}
}
fn filter<'a>(
metadata: &[&'a LintMetadata],
categories: &[CategoryArg],
severities: &[SeverityArg],
) -> Vec<&'a LintMetadata> {
metadata
.iter()
.copied()
.filter(|m| categories.is_empty() || categories.iter().any(|c| c.matches(m.category)))
.filter(|m| {
severities.is_empty() || severities.iter().any(|s| s.matches(m.default_severity))
})
.collect()
}
fn group_by_category<'a>(
metadata: &[&'a LintMetadata],
) -> Vec<(LintCategory, Vec<&'a LintMetadata>)> {
let order = [
LintCategory::Correctness,
LintCategory::Packaging,
LintCategory::Style,
LintCategory::Performance,
];
let mut out = Vec::new();
for cat in order {
let mut group: Vec<&LintMetadata> = metadata
.iter()
.copied()
.filter(|m| m.category == cat)
.collect();
if group.is_empty() {
continue;
}
group.sort_by(|a, b| compare_ids(a.id, b.id));
out.push((cat, group));
}
out
}
fn compare_ids(a: &str, b: &str) -> std::cmp::Ordering {
let (prefix_a, num_a) = split_id(a);
let (prefix_b, num_b) = split_id(b);
prefix_a.cmp(prefix_b).then_with(|| match (num_a, num_b) {
(Some(x), Some(y)) => x.cmp(&y),
_ => a.cmp(b),
})
}
fn split_id(id: &str) -> (&str, Option<u32>) {
let split = id.find(|c: char| c.is_ascii_digit()).unwrap_or(id.len());
let (prefix, tail) = id.split_at(split);
(prefix, tail.parse::<u32>().ok())
}
fn render_text(
groups: &[(LintCategory, Vec<&LintMetadata>)],
color: ColorChoice,
) -> io::Result<()> {
let stream_color = resolve_color(color, || io::stdout().is_terminal());
let mut out = StandardStream::stdout(stream_color);
if groups.is_empty() {
writeln!(out, "(no rules matched the given filters)")?;
return Ok(());
}
for (i, (cat, rules)) in groups.iter().enumerate() {
if i > 0 {
writeln!(out)?;
}
write_heading(&mut out, *cat, rules.len())?;
let id_width = rules.iter().map(|m| m.id.len()).max().unwrap_or(0);
let name_width = rules.iter().map(|m| m.name.len()).max().unwrap_or(0);
for m in rules {
write_row(&mut out, m, id_width, name_width)?;
}
}
Ok(())
}
fn write_heading(out: &mut StandardStream, cat: LintCategory, count: usize) -> io::Result<()> {
apply_spec(out, |s| s.set_bold(true))?;
write!(out, "{}", category_label(cat))?;
out.reset()?;
writeln!(out, " ({count} rules)")?;
Ok(())
}
fn write_row(
out: &mut StandardStream,
m: &LintMetadata,
id_width: usize,
name_width: usize,
) -> io::Result<()> {
write!(out, " ")?;
apply_spec(out, |s| s.set_bold(true))?;
write!(out, "{:<id_width$}", m.id)?;
out.reset()?;
write!(out, " ")?;
apply_spec(out, |s| s.set_fg(Some(Color::Cyan)))?;
write!(out, "{:<name_width$}", m.name)?;
out.reset()?;
write!(out, " ")?;
write_severity(out, m.default_severity)?;
write!(out, " ")?;
writeln!(out, "{}", single_line(m.description))?;
Ok(())
}
const SEVERITY_LABEL_WIDTH: usize = 5;
fn write_severity(out: &mut StandardStream, s: Severity) -> io::Result<()> {
let label = severity_label(s);
let mut spec = ColorSpec::new();
match s {
Severity::Allow => {
spec.set_dimmed(true);
}
Severity::Warn => {
spec.set_fg(Some(Color::Yellow));
}
Severity::Deny => {
spec.set_fg(Some(Color::Red)).set_bold(true);
}
}
out.set_color(&spec)?;
write!(out, "{label:<SEVERITY_LABEL_WIDTH$}")?;
out.reset()?;
Ok(())
}
fn apply_spec<F>(out: &mut StandardStream, f: F) -> io::Result<()>
where
F: FnOnce(&mut ColorSpec) -> &mut ColorSpec,
{
let mut spec = ColorSpec::new();
f(&mut spec);
out.set_color(&spec)
}
fn render_markdown(groups: &[(LintCategory, Vec<&LintMetadata>)]) -> io::Result<()> {
let stdout = io::stdout();
let mut out = stdout.lock();
writeln!(out, "# Lint rules reference")?;
writeln!(out)?;
if groups.is_empty() {
writeln!(out, "_(no rules matched the given filters)_")?;
return Ok(());
}
for (i, (cat, rules)) in groups.iter().enumerate() {
if i > 0 {
writeln!(out)?;
}
writeln!(out, "## {}", category_label(*cat))?;
writeln!(out)?;
writeln!(out, "| ID | Name | Severity | Description |")?;
writeln!(out, "|----|------|----------|-------------|")?;
for m in rules {
writeln!(
out,
"| {} | `{}` | {} | {} |",
escape_md(m.id),
escape_md(m.name),
severity_label(m.default_severity),
escape_md(&single_line(m.description))
)?;
}
}
Ok(())
}
fn single_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn escape_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str(r"\\"),
'|' => out.push_str(r"\|"),
_ => out.push(c),
}
}
out
}
fn category_label(c: LintCategory) -> &'static str {
match c {
LintCategory::Style => "Style",
LintCategory::Correctness => "Correctness",
LintCategory::Packaging => "Packaging",
LintCategory::Performance => "Performance",
#[allow(unreachable_patterns)]
_ => unreachable!("unhandled LintCategory variant — extend category_label"),
}
}
fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Allow => "allow",
Severity::Warn => "warn",
Severity::Deny => "deny",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(
id: &'static str,
name: &'static str,
sev: Severity,
cat: LintCategory,
) -> LintMetadata {
LintMetadata::new(id, name, "test rule", sev, cat)
}
fn collect_ids<'a>(g: &[(LintCategory, Vec<&'a LintMetadata>)]) -> Vec<&'a str> {
g.iter().flat_map(|(_, v)| v.iter().map(|m| m.id)).collect()
}
#[test]
fn filter_by_category_or_logic() {
let a = meta("RPM001", "a", Severity::Warn, LintCategory::Style);
let b = meta("RPM002", "b", Severity::Warn, LintCategory::Correctness);
let c = meta("RPM003", "c", Severity::Warn, LintCategory::Packaging);
let pool = vec![&a, &b, &c];
let result = filter(&pool, &[CategoryArg::Style, CategoryArg::Packaging], &[]);
let ids: Vec<_> = result.iter().map(|m| m.id).collect();
assert_eq!(ids, vec!["RPM001", "RPM003"]);
}
#[test]
fn filter_by_severity_or_logic() {
let a = meta("RPM001", "a", Severity::Warn, LintCategory::Style);
let b = meta("RPM002", "b", Severity::Deny, LintCategory::Style);
let c = meta("RPM003", "c", Severity::Allow, LintCategory::Style);
let pool = vec![&a, &b, &c];
let result = filter(&pool, &[], &[SeverityArg::Warn, SeverityArg::Deny]);
let ids: Vec<_> = result.iter().map(|m| m.id).collect();
assert_eq!(ids, vec!["RPM001", "RPM002"]);
}
#[test]
fn category_severity_and_logic() {
let a = meta("RPM001", "a", Severity::Warn, LintCategory::Style);
let b = meta("RPM002", "b", Severity::Deny, LintCategory::Style);
let c = meta("RPM003", "c", Severity::Warn, LintCategory::Packaging);
let pool = vec![&a, &b, &c];
let result = filter(&pool, &[CategoryArg::Style], &[SeverityArg::Warn]);
let ids: Vec<_> = result.iter().map(|m| m.id).collect();
assert_eq!(ids, vec!["RPM001"]);
}
#[test]
fn empty_filters_pass_everything() {
let a = meta("RPM001", "a", Severity::Warn, LintCategory::Style);
let b = meta("RPM002", "b", Severity::Deny, LintCategory::Packaging);
let pool = vec![&a, &b];
let result = filter(&pool, &[], &[]);
assert_eq!(result.len(), 2);
}
#[test]
fn group_sorts_numerically_inside_category() {
let a = meta("RPM2", "a", Severity::Warn, LintCategory::Style);
let b = meta("RPM10", "b", Severity::Warn, LintCategory::Style);
let c = meta("RPM1", "c", Severity::Warn, LintCategory::Style);
let pool = vec![&a, &b, &c];
let grouped = group_by_category(&pool);
assert_eq!(collect_ids(&grouped), vec!["RPM1", "RPM2", "RPM10"]);
}
#[test]
fn group_puts_correctness_first_then_packaging_style_perf() {
let s = meta("RPM001", "s", Severity::Warn, LintCategory::Style);
let c = meta("RPM002", "c", Severity::Warn, LintCategory::Correctness);
let p = meta("RPM003", "p", Severity::Warn, LintCategory::Packaging);
let perf = meta("RPM004", "f", Severity::Warn, LintCategory::Performance);
let pool = vec![&s, &c, &p, &perf];
let grouped = group_by_category(&pool);
let cats: Vec<_> = grouped.iter().map(|(c, _)| *c).collect();
assert_eq!(
cats,
vec![
LintCategory::Correctness,
LintCategory::Packaging,
LintCategory::Style,
LintCategory::Performance
]
);
}
#[test]
fn group_omits_empty_categories() {
let a = meta("RPM001", "a", Severity::Warn, LintCategory::Style);
let pool = vec![&a];
let grouped = group_by_category(&pool);
assert_eq!(grouped.len(), 1);
assert_eq!(grouped[0].0, LintCategory::Style);
}
#[test]
fn markdown_escapes_pipe_and_backslash() {
assert_eq!(escape_md("foo | bar"), r"foo \| bar");
assert_eq!(escape_md(r"a \ b"), r"a \\ b");
assert_eq!(escape_md(r"|\\"), r"\|\\\\");
}
#[test]
fn markdown_escapes_mixed_pipe_and_backslash_round_trip() {
assert_eq!(escape_md(r"a | b \ c"), r"a \| b \\ c");
assert_eq!(escape_md(r"\|\|"), r"\\\|\\\|");
assert_eq!(escape_md("αβ | γ"), r"αβ \| γ");
}
#[test]
fn compare_ids_sorts_parse_prefix_after_rpm_numerically() {
let mut ids = vec!["RPM10", "parse/E0001", "RPM2", "parse/W0002", "RPM1"];
ids.sort_by(|a, b| compare_ids(a, b));
assert_eq!(
ids,
vec!["RPM1", "RPM2", "RPM10", "parse/E0001", "parse/W0002"]
);
}
#[test]
fn render_text_empty_groups_emits_no_match_message() {
let mut buf: Vec<u8> = Vec::new();
let _ = &mut buf;
let groups: Vec<(LintCategory, Vec<&LintMetadata>)> = vec![];
render_text(&groups, ColorChoice::Never).expect("render_text should not fail");
}
#[test]
fn single_line_collapses_whitespace() {
assert_eq!(single_line("foo\n bar\tbaz"), "foo bar baz");
assert_eq!(single_line(" leading\n"), "leading");
}
#[test]
fn real_registry_has_all_categories_represented() {
let metadata = builtin_lint_metadata();
let mut seen = std::collections::HashSet::new();
for m in metadata {
seen.insert(m.category);
}
assert!(seen.contains(&LintCategory::Correctness));
assert!(seen.contains(&LintCategory::Style));
}
}