mod markdown;
mod splice;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::str::FromStr;
use alint_core::Level;
use anyhow::{Context, Result, bail};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Markdown,
Json,
}
impl FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"markdown" => Ok(Self::Markdown),
"json" => Ok(Self::Json),
other => Err(format!(
"invalid format {other:?}; expected one of `markdown`, `json`"
)),
}
}
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)] pub struct Directive {
pub rule_id: String,
pub severity: Level,
pub directive: String,
pub policy_url: Option<String>,
}
#[derive(Debug)]
pub struct RunOptions {
pub format: OutputFormat,
pub output: Option<PathBuf>,
pub inline: bool,
pub section_title: String,
pub include_info: bool,
}
pub fn run(config_path: Option<&Path>, opts: &RunOptions) -> Result<ExitCode> {
let cwd = std::env::current_dir().context("resolving current directory")?;
let resolved_config_path = match config_path {
Some(p) => p.to_path_buf(),
None => alint_dsl::discover(&cwd).ok_or_else(|| {
anyhow::anyhow!("no .alint.yml found (searched from {})", cwd.display())
})?,
};
let config = alint_dsl::load(&resolved_config_path)
.with_context(|| format!("loading {}", resolved_config_path.display()))?;
let directives = collect_directives(&config, opts.include_info);
let body = match opts.format {
OutputFormat::Markdown => markdown::render(&directives, &opts.section_title),
OutputFormat::Json => render_json(&directives, &opts.section_title)?,
};
write_output(&body, opts)?;
Ok(ExitCode::SUCCESS)
}
fn collect_directives(config: &alint_core::Config, include_info: bool) -> Vec<Directive> {
let mut out: Vec<Directive> = config
.rules
.iter()
.filter(|spec| !matches!(spec.level, Level::Off))
.filter(|spec| include_info || !matches!(spec.level, Level::Info))
.map(|spec| Directive {
rule_id: spec.id.clone(),
severity: spec.level,
directive: spec
.message
.clone()
.unwrap_or_else(|| format!("{} rule", spec.kind)),
policy_url: spec.policy_url.clone(),
})
.collect();
out.sort_by(|a, b| {
severity_rank(b.severity)
.cmp(&severity_rank(a.severity))
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
out
}
fn severity_rank(level: Level) -> u8 {
match level {
Level::Error => 3,
Level::Warning => 2,
Level::Info => 1,
Level::Off => 0,
}
}
#[derive(Serialize)]
struct JsonReport<'a> {
schema_version: u32,
format: &'static str,
section_title: &'a str,
generated_at: String,
directives: Vec<JsonDirective<'a>>,
}
#[derive(Serialize)]
struct JsonDirective<'a> {
rule_id: &'a str,
severity: &'static str,
directive: &'a str,
policy_url: Option<&'a str>,
}
fn render_json(directives: &[Directive], section_title: &str) -> Result<String> {
let report = JsonReport {
schema_version: 1,
format: "agents-md",
section_title,
generated_at: timestamp_now(),
directives: directives
.iter()
.map(|d| JsonDirective {
rule_id: &d.rule_id,
severity: severity_label(d.severity),
directive: &d.directive,
policy_url: d.policy_url.as_deref(),
})
.collect(),
};
let mut s = serde_json::to_string_pretty(&report).context("encoding json")?;
s.push('\n');
Ok(s)
}
fn severity_label(level: Level) -> &'static str {
match level {
Level::Error => "error",
Level::Warning => "warning",
Level::Info => "info",
Level::Off => "off",
}
}
fn write_output(body: &str, opts: &RunOptions) -> Result<()> {
match (&opts.output, opts.inline) {
(None, false) => {
std::io::stdout()
.write_all(body.as_bytes())
.context("writing to stdout")?;
std::io::stdout().flush().context("flush stdout")?;
Ok(())
}
(Some(path), false) => {
fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
(Some(path), true) => splice::splice_inline(path, body),
(None, true) => bail!("--inline requires --output to point at the target file"),
}
}
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::*;
fn level_off_rule() -> alint_core::RuleSpec {
spec("disabled", Level::Off, None)
}
fn level_info_rule() -> alint_core::RuleSpec {
spec("nudge", Level::Info, Some("informational"))
}
fn spec(id: &str, level: Level, msg: Option<&str>) -> alint_core::RuleSpec {
alint_core::RuleSpec {
id: id.into(),
kind: "file_exists".into(),
level,
paths: None,
message: msg.map(str::to_string),
policy_url: None,
when: None,
fix: None,
git_tracked_only: false,
extra: serde_yaml_ng::Mapping::new(),
}
}
fn config_with(rules: Vec<alint_core::RuleSpec>) -> alint_core::Config {
alint_core::Config {
version: 1,
rules,
facts: Vec::new(),
extends: Vec::new(),
vars: std::collections::HashMap::new(),
respect_gitignore: true,
ignore: Vec::new(),
fix_size_limit: None,
nested_configs: false,
}
}
#[test]
fn off_rules_always_skipped() {
let cfg = config_with(vec![
level_off_rule(),
spec("err", Level::Error, Some("must not")),
]);
let dirs = collect_directives(&cfg, true);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].rule_id, "err");
}
#[test]
fn info_skipped_unless_include_info() {
let cfg = config_with(vec![
level_info_rule(),
spec("warn", Level::Warning, Some("avoid")),
]);
let dirs = collect_directives(&cfg, false);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].rule_id, "warn");
let dirs_with = collect_directives(&cfg, true);
assert_eq!(dirs_with.len(), 2);
}
#[test]
fn missing_message_falls_back_to_kind() {
let cfg = config_with(vec![spec("no-msg", Level::Warning, None)]);
let dirs = collect_directives(&cfg, false);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].directive, "file_exists rule");
}
#[test]
fn sorts_severity_desc_then_id_asc() {
let cfg = config_with(vec![
spec("z-warn", Level::Warning, Some("z")),
spec("a-err", Level::Error, Some("a")),
spec("b-warn", Level::Warning, Some("b")),
spec("a-info", Level::Info, Some("ai")),
]);
let dirs = collect_directives(&cfg, true);
let order: Vec<&str> = dirs.iter().map(|d| d.rule_id.as_ref()).collect();
assert_eq!(order, vec!["a-err", "b-warn", "z-warn", "a-info"]);
}
#[test]
fn epoch_to_civil_round_trips_2026_04_28() {
assert_eq!(epoch_to_civil(1_777_334_400), (2026, 4, 28, 0, 0, 0));
}
#[test]
fn output_format_parses_two_options() {
assert_eq!(
"markdown".parse::<OutputFormat>().unwrap(),
OutputFormat::Markdown
);
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert!("yaml".parse::<OutputFormat>().is_err());
}
#[test]
fn json_envelope_is_stable_shape() {
let directives = vec![Directive {
rule_id: "no-debugger".into(),
severity: Level::Error,
directive: "no debugger statements".into(),
policy_url: None,
}];
let s = render_json(&directives, "Lint rules enforced by alint").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["schema_version"], 1);
assert_eq!(parsed["format"], "agents-md");
assert_eq!(parsed["directives"][0]["rule_id"], "no-debugger");
assert_eq!(parsed["directives"][0]["severity"], "error");
}
}