alint 0.9.13

Language-agnostic linter for repository structure, file existence, filename conventions, and file content rules.
//! Renderers for the three output formats.
//!
//! - **human** — colorised table with checkmark / warning
//!   glyphs, optional `--explain` evidence block.
//! - **yaml** — paste-ready `.alint.yml` snippet.
//! - **json** — stable shape for agent consumption,
//!   `{schema_version, format, generated_at, proposals: [...]}`.
//!
//! Strict stdout-only contract: nothing here ever writes to
//! stderr. Progress and summary lines belong to
//! [`crate::progress::Progress`].

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),
    }
}

// ─── human ────────────────────────────────────────────────────

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),
    }
}

// ─── yaml ─────────────────────────────────────────────────────

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 {
                // Indent the user-supplied YAML body by two
                // spaces so it lands under `rules:` cleanly.
                for line in yaml.lines() {
                    if line.is_empty() {
                        writeln!(out)?;
                    } else {
                        writeln!(out, "  {line}")?;
                    }
                }
                writeln!(out)?;
            }
        }
    }
    out.flush().context("flush stdout")?;
    Ok(())
}

// ─── json ─────────────────────────────────────────────────────

#[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(())
}

/// RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` from `SystemTime::now()`.
/// Hand-rolled to avoid a chrono / time dep — `suggest` is the
/// only user.
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")
}

/// Convert a Unix epoch seconds count into a (Y, M, D, h, m, s)
/// UTC tuple. Algorithm from Howard Hinnant's
/// `chrono::civil_from_days`.
#[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) {
    // The casts here are bounded: `secs` is a real Unix timestamp
    // from `SystemTime::duration_since(UNIX_EPOCH)`, which fits
    // in i64 by the heat death of the sun. The downstream
    // i64→u32 casts on month/day are bounded by the algorithm
    // (m ∈ 1..=12, d ∈ 1..=31). Keeping the algorithm as written
    // matches Howard Hinnant's reference implementation, so an
    // allow over the whole function is the cleanest expression.
    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"));
        // Without --explain the evidence shouldn't appear.
        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);
        // First proposal is bundled
        assert_eq!(arr[0]["shape"], "bundled_ruleset");
        assert_eq!(arr[0]["uri"], "alint://bundled/rust@v1");
        // Second is rule
        assert_eq!(arr[1]["shape"], "rule");
        assert_eq!(arr[1]["kind"], "git_blame_age");
    }

    #[test]
    fn epoch_to_civil_known_dates() {
        // Cross-checked via `date -u -d @<ts>`. Three points:
        // a leap-year day, a regular new year, an arbitrary
        // timestamp inside 2026.
        assert_eq!(
            epoch_to_civil(0),
            (1970, 1, 1, 0, 0, 0),
            "Unix epoch maps to 1970-01-01"
        );
        // 2024-02-29T12:00:00Z — leap-day check.
        assert_eq!(epoch_to_civil(1_709_208_000), (2024, 2, 29, 12, 0, 0));
        // 2026-04-28T00:00:00Z — fixture for the alint dev
        // window.
        assert_eq!(epoch_to_civil(1_777_334_400), (2026, 4, 28, 0, 0, 0));
    }

    #[test]
    fn timestamp_now_format_matches_rfc3339_shape() {
        // Don't pin the exact value (it's wall-clock); just
        // assert the shape is right.
        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'));
    }
}