use std::io::Write;
use anyhow::{Context, Result};
use serde::Serialize;
use super::RunOptions;
use super::proposal::{Confidence, Proposal, ProposalKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Human,
Yaml,
Json,
}
pub fn render(proposals: &[Proposal], opts: &RunOptions, out: &mut dyn Write) -> Result<()> {
match opts.format {
OutputFormat::Human => render_human(proposals, opts, out),
OutputFormat::Yaml => render_yaml(proposals, out),
OutputFormat::Json => render_json(proposals, out),
}
}
fn render_human(proposals: &[Proposal], opts: &RunOptions, out: &mut dyn Write) -> Result<()> {
if proposals.is_empty() {
writeln!(
out,
"No proposals — your existing config already covers what we'd suggest."
)?;
return Ok(());
}
writeln!(
out,
"Found {} proposal{} (sorted by confidence):",
proposals.len(),
if proposals.len() == 1 { "" } else { "s" },
)?;
writeln!(out)?;
for p in proposals {
let glyph = match p.confidence {
Confidence::High => "✓",
Confidence::Medium => "·",
Confidence::Low => "?",
};
writeln!(
out,
" {glyph} [{conf:>6}] {label}",
conf = p.confidence.label(),
label = headline_for(p),
)?;
writeln!(out, " {}", p.summary)?;
if opts.explain {
for e in &p.evidence {
writeln!(out, " └─ {}", e.message)?;
}
}
writeln!(out)?;
}
writeln!(out, "Run with --format yaml to print as a config snippet.")?;
if !opts.explain {
writeln!(out, "Run with --explain to see file-level evidence.")?;
}
out.flush().context("flush stdout")?;
Ok(())
}
fn headline_for(p: &Proposal) -> String {
match &p.kind {
ProposalKind::BundledRuleset { uri } => uri.clone(),
ProposalKind::Rule { kind, .. } => format!("{} (`{}`)", p.id, kind),
}
}
fn render_yaml(proposals: &[Proposal], out: &mut dyn Write) -> Result<()> {
writeln!(
out,
"# Generated by `alint suggest`. Review before adopting."
)?;
writeln!(out)?;
let bundled: Vec<&Proposal> = proposals.iter().filter(|p| p.is_bundled()).collect();
let rules: Vec<&Proposal> = proposals.iter().filter(|p| !p.is_bundled()).collect();
if !bundled.is_empty() {
writeln!(out, "extends:")?;
for p in &bundled {
if let Some(uri) = p.bundled_uri() {
writeln!(out, " - {uri}")?;
}
}
writeln!(out)?;
}
if !rules.is_empty() {
writeln!(out, "rules:")?;
for p in &rules {
if let ProposalKind::Rule { yaml, .. } = &p.kind {
for line in yaml.lines() {
if line.is_empty() {
writeln!(out)?;
} else {
writeln!(out, " {line}")?;
}
}
writeln!(out)?;
}
}
}
out.flush().context("flush stdout")?;
Ok(())
}
#[derive(Serialize)]
struct JsonReport<'a> {
schema_version: u32,
format: &'static str,
generated_at: String,
proposals: Vec<JsonProposal<'a>>,
}
#[derive(Serialize)]
struct JsonProposal<'a> {
id: &'a str,
confidence: &'static str,
summary: &'a str,
evidence: Vec<&'a str>,
#[serde(flatten)]
body: JsonBody<'a>,
}
#[derive(Serialize)]
#[serde(tag = "shape")]
#[serde(rename_all = "snake_case")]
enum JsonBody<'a> {
BundledRuleset { uri: &'a str },
Rule { kind: &'a str, yaml: &'a str },
}
fn render_json(proposals: &[Proposal], out: &mut dyn Write) -> Result<()> {
let report = JsonReport {
schema_version: 1,
format: "suggest",
generated_at: timestamp_now(),
proposals: proposals
.iter()
.map(|p| JsonProposal {
id: &p.id,
confidence: p.confidence.label(),
summary: &p.summary,
evidence: p.evidence.iter().map(|e| e.message.as_str()).collect(),
body: match &p.kind {
ProposalKind::BundledRuleset { uri } => JsonBody::BundledRuleset { uri },
ProposalKind::Rule { kind, yaml } => JsonBody::Rule { kind, yaml },
},
})
.collect(),
};
serde_json::to_writer_pretty(&mut *out, &report).context("encoding json")?;
writeln!(out).context("trailing newline")?;
out.flush().context("flush stdout")?;
Ok(())
}
fn timestamp_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map_or(0, |d| d.as_secs());
let (year, month, day, hour, min, sec) = epoch_to_civil(secs);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
fn epoch_to_civil(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
let days = (secs / 86_400) as i64;
let time_of_day = secs % 86_400;
let hour = (time_of_day / 3600) as u32;
let min = ((time_of_day / 60) % 60) as u32;
let sec = (time_of_day % 60) as u32;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y_final = if m <= 2 { y + 1 } else { y };
(y_final, m as u32, d as u32, hour, min, sec)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::suggest::proposal::Evidence;
fn opts(format: OutputFormat, explain: bool) -> RunOptions {
RunOptions {
format,
confidence: Confidence::Low,
include_bundled: false,
explain,
quiet: false,
}
}
fn bundled_proposal() -> Proposal {
Proposal {
id: "alint://bundled/rust@v1".into(),
kind: ProposalKind::BundledRuleset {
uri: "alint://bundled/rust@v1".into(),
},
confidence: Confidence::High,
evidence: vec![Evidence {
message: "Cargo.toml at root; 4 .rs files in src/".into(),
}],
summary: "Rust project detected — extend the language ruleset.".into(),
}
}
fn rule_proposal() -> Proposal {
Proposal {
id: "stale-todos".into(),
kind: ProposalKind::Rule {
kind: "git_blame_age".into(),
yaml: "- id: stale-todos\n kind: git_blame_age\n paths: \"**/*.rs\"\n pattern: 'TODO'\n max_age_days: 180\n level: warning\n".into(),
},
confidence: Confidence::Medium,
evidence: vec![Evidence {
message: "3 TODO markers older than 180 days".into(),
}],
summary: "Stale TODO markers — `git_blame_age` would surface them.".into(),
}
}
#[test]
fn human_renders_summary_and_one_line_per_proposal() {
let proposals = vec![bundled_proposal(), rule_proposal()];
let mut buf = Vec::new();
render_human(&proposals, &opts(OutputFormat::Human, false), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Found 2 proposals"));
assert!(s.contains("alint://bundled/rust@v1"));
assert!(s.contains("stale-todos"));
assert!(!s.contains("3 TODO markers"));
}
#[test]
fn human_explain_surfaces_evidence() {
let proposals = vec![rule_proposal()];
let mut buf = Vec::new();
render_human(&proposals, &opts(OutputFormat::Human, true), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("3 TODO markers older than 180 days"));
}
#[test]
fn human_empty_set_states_so() {
let mut buf = Vec::new();
render_human(&[], &opts(OutputFormat::Human, false), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("No proposals"));
}
#[test]
fn yaml_separates_extends_and_rules_blocks() {
let proposals = vec![bundled_proposal(), rule_proposal()];
let mut buf = Vec::new();
render_yaml(&proposals, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("extends:"));
assert!(s.contains("alint://bundled/rust@v1"));
assert!(s.contains("rules:"));
assert!(s.contains("kind: git_blame_age"));
}
#[test]
fn json_emits_stable_envelope() {
let proposals = vec![bundled_proposal(), rule_proposal()];
let mut buf = Vec::new();
render_json(&proposals, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["schema_version"], 1);
assert_eq!(parsed["format"], "suggest");
let arr = parsed["proposals"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["shape"], "bundled_ruleset");
assert_eq!(arr[0]["uri"], "alint://bundled/rust@v1");
assert_eq!(arr[1]["shape"], "rule");
assert_eq!(arr[1]["kind"], "git_blame_age");
}
#[test]
fn epoch_to_civil_known_dates() {
assert_eq!(
epoch_to_civil(0),
(1970, 1, 1, 0, 0, 0),
"Unix epoch maps to 1970-01-01"
);
assert_eq!(epoch_to_civil(1_709_208_000), (2024, 2, 29, 12, 0, 0));
assert_eq!(epoch_to_civil(1_777_334_400), (2026, 4, 28, 0, 0, 0));
}
#[test]
fn timestamp_now_format_matches_rfc3339_shape() {
let s = timestamp_now();
assert_eq!(s.len(), 20);
assert!(s.ends_with('Z'));
assert_eq!(s.chars().nth(4), Some('-'));
assert_eq!(s.chars().nth(10), Some('T'));
}
}