use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use alint_core::{Engine, RuleRegistry, WalkOptions, walk};
use alint_output::{ColorChoice, Format, GlyphSet, HumanOptions};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
mod export_agents_md;
mod init;
mod progress;
mod suggest;
const ALINT_LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("ALINT_GIT_SHA"),
", built ",
env!("ALINT_BUILD_DATE"),
")",
);
#[derive(Parser, Debug)]
#[command(
name = "alint",
version,
long_version = ALINT_LONG_VERSION,
about = "Language-agnostic linter for repository structure, existence, naming, and content rules",
long_about = None,
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(long, short = 'c', global = true)]
config: Vec<PathBuf>,
#[arg(long, short = 'f', global = true, default_value = "human")]
format: String,
#[arg(long, global = true)]
no_gitignore: bool,
#[arg(long, global = true)]
fail_on_warning: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
color: String,
#[arg(long, global = true)]
ascii: bool,
#[arg(long, global = true)]
compact: bool,
#[arg(long, global = true, value_name = "COLS")]
width: Option<usize>,
#[arg(long, global = true)]
no_docs: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
progress: String,
#[arg(long, short = 'q', global = true)]
quiet: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Check {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
List,
Explain {
rule_id: String,
},
Fix {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
dry_run: bool,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
Facts {
#[arg(default_value = ".")]
path: PathBuf,
},
Init {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
monorepo: bool,
},
ExportAgentsMd {
#[arg(long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, requires = "output")]
inline: bool,
#[arg(long, value_name = "TEXT")]
section_title: Option<String>,
#[arg(long)]
include_info: bool,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "markdown",
value_parser = clap::builder::PossibleValuesParser::new(["markdown", "json"]),
)]
format: String,
},
Suggest {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "human",
value_parser = clap::builder::PossibleValuesParser::new(["human", "yaml", "json"]),
)]
format: String,
#[arg(
long,
value_name = "LEVEL",
default_value = "medium",
value_parser = clap::builder::PossibleValuesParser::new(["low", "medium", "high"]),
)]
confidence: String,
#[arg(long)]
include_bundled: bool,
#[arg(long)]
explain: bool,
},
ValidateConfig {
path: Option<PathBuf>,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "human",
value_parser = clap::builder::PossibleValuesParser::new(["human", "json"]),
)]
format: String,
},
}
fn main() -> ExitCode {
init_panic_hook();
init_tracing();
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("alint: {e:#}");
ExitCode::from(2)
}
}
}
fn init_panic_hook() {
if std::env::var_os("RUST_BACKTRACE").is_some() {
return;
}
std::panic::set_hook(Box::new(|info| {
let location = info.location().map_or_else(
|| "(unknown)".to_string(),
|l| format!("{}:{}", l.file(), l.line()),
);
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("(non-string panic payload)");
let title = format!("alint panic: {payload}");
let body = format!(
"alint version: {ver}\n\
OS: {os}\n\
Panic location: {location}\n\
Panic message: {payload}\n\n\
Steps to reproduce:\n\
1. ...\n\
2. ...\n\
3. ...\n\n\
Expected behaviour:\n\n\
Actual behaviour:\n",
ver = ALINT_LONG_VERSION,
os = std::env::consts::OS,
);
let url = format!(
"https://github.com/asamarts/alint/issues/new?title={}&body={}",
url_encode(&title),
url_encode(&body),
);
eprintln!("\nalint crashed unexpectedly. This is a bug — please file a report:");
eprintln!(" {url}\n");
eprintln!("Panic: {payload}");
eprintln!("Location: {location}");
eprintln!("Re-run with `RUST_BACKTRACE=1` for the full backtrace.");
}));
}
fn url_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
let c = *b;
if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
out.push(c as char);
} else {
let _ = write!(out, "%{c:02X}");
}
}
out
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_env("ALINT_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt().with_env_filter(filter).with_target(false).try_init();
}
fn run(mut cli: Cli) -> Result<ExitCode> {
let command = cli.command.take().unwrap_or(Command::Check {
path: PathBuf::from("."),
changed: false,
base: None,
});
match command {
Command::Check {
path,
changed,
base,
} => cmd_check(&path, &ChangedMode::new(changed, base), &cli),
Command::List => cmd_list(&cli),
Command::Explain { rule_id } => cmd_explain(&rule_id, &cli),
Command::Fix {
path,
dry_run,
changed,
base,
} => cmd_fix(&path, dry_run, &ChangedMode::new(changed, base), &cli),
Command::Facts { path } => cmd_facts(&path, &cli),
Command::Init { path, monorepo } => cmd_init(&path, monorepo),
Command::ExportAgentsMd {
output,
inline,
section_title,
include_info,
format,
} => cmd_export_agents_md(
&ExportAgentsMdOptions {
output,
inline,
section_title,
include_info,
format,
},
&cli,
),
Command::Suggest {
path,
format,
confidence,
include_bundled,
explain,
} => cmd_suggest(
&path,
&SuggestOptions {
format,
confidence,
include_bundled,
explain,
},
&cli,
),
Command::ValidateConfig { path, format } => cmd_validate_config(path, &format, &cli),
}
}
#[derive(Debug)]
struct ExportAgentsMdOptions {
output: Option<PathBuf>,
inline: bool,
section_title: Option<String>,
include_info: bool,
format: String,
}
fn cmd_export_agents_md(opts: &ExportAgentsMdOptions, cli: &Cli) -> Result<ExitCode> {
use export_agents_md::{OutputFormat, RunOptions};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let section_title = opts
.section_title
.clone()
.unwrap_or_else(|| "Lint rules enforced by alint".to_string());
let run_opts = RunOptions {
format,
output: opts.output.clone(),
inline: opts.inline,
section_title,
include_info: opts.include_info,
};
export_agents_md::run(cli.config.first().map(PathBuf::as_path), &run_opts)
}
#[derive(Debug)]
struct SuggestOptions {
format: String,
confidence: String,
include_bundled: bool,
explain: bool,
}
fn cmd_suggest(path: &Path, opts: &SuggestOptions, cli: &Cli) -> Result<ExitCode> {
use suggest::{Confidence, OutputFormat};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let confidence: Confidence = opts
.confidence
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let progress_mode = if cli.quiet {
progress::ProgressMode::Never
} else {
cli.progress
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?
};
let progress = progress::Progress::new(progress_mode);
let (mut out, _opts) = render_env(cli)?;
suggest::run(
path,
&suggest::RunOptions {
format,
confidence,
include_bundled: opts.include_bundled,
explain: opts.explain,
quiet: cli.quiet,
width: cli.width,
},
&progress,
&mut out,
)
}
fn cmd_init(path: &Path, monorepo: bool) -> Result<ExitCode> {
for name in [".alint.yml", ".alint.yaml", "alint.yml", "alint.yaml"] {
let candidate = path.join(name);
if candidate.is_file() {
bail!(
"{} already exists; refusing to overwrite. Delete it first if you really \
want to regenerate, or edit it directly.",
candidate.display()
);
}
}
let detection = init::detect(path, monorepo);
let body = init::render(&detection);
let target = path.join(".alint.yml");
std::fs::write(&target, &body).with_context(|| format!("writing {}", target.display()))?;
let summary = init::render_summary(&detection);
if summary.is_empty() {
println!(
"Wrote {} — extends `oss-baseline@v1` only.",
target.display()
);
println!(
" No language manifests detected. Add an `extends:` line for your stack \
(`alint://bundled/rust@v1`, `node@v1`, …) when ready."
);
} else {
println!("Wrote {} — detected: {}.", target.display(), summary);
println!(" Run `alint check` to lint against the generated config.");
}
Ok(ExitCode::SUCCESS)
}
#[derive(Debug)]
struct ChangedMode {
enabled: bool,
base: Option<String>,
}
impl ChangedMode {
fn new(changed_flag: bool, base: Option<String>) -> Self {
let enabled = changed_flag || base.is_some();
Self { enabled, base }
}
fn resolve(&self, root: &Path) -> Result<Option<std::collections::HashSet<PathBuf>>> {
if !self.enabled {
return Ok(None);
}
let set = alint_core::git::collect_changed_paths(root, self.base.as_deref()).ok_or_else(
|| {
let what = self.base.as_deref().map_or_else(
|| "git ls-files --modified --others --exclude-standard".to_string(),
|r| format!("git diff --name-only {r}...HEAD"),
);
anyhow::anyhow!(
"--changed requires a git repository (and `git` on PATH); \
`{what}` failed at {}. Run without --changed for a full check.",
root.display()
)
},
)?;
Ok(Some(set))
}
}
fn cmd_check(path: &Path, changed: &ChangedMode, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let rule_count = loaded.entries.len();
let mut engine = Engine::from_entries(loaded.entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars);
if let Some(set) = changed.resolve(path)? {
engine = engine.with_changed_paths(set);
}
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
tracing::debug!(files = index.entries.len(), "walk complete");
let report = engine.run(path, &index).context("running rules")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let (mut out, opts) = render_env(cli)?;
format
.write_with_options(&report, &mut out, opts)
.context("writing output")?;
out.flush().ok();
tracing::debug!(rules = rule_count, "done");
let exit = if report.has_errors() || (cli.fail_on_warning && report.has_warnings()) {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
fn cmd_fix(path: &Path, dry_run: bool, changed: &ChangedMode, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let mut engine = Engine::from_entries(loaded.entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars)
.with_fix_size_limit(loaded.fix_size_limit);
if let Some(set) = changed.resolve(path)? {
engine = engine.with_changed_paths(set);
}
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let report = engine
.fix(path, &index, dry_run)
.context("applying fixes")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let (mut out, opts) = render_env(cli)?;
format
.write_fix_with_options(&report, &mut out, opts)
.context("writing output")?;
out.flush().ok();
let exit = if report.has_unfixable_errors()
|| (cli.fail_on_warning && report.has_unfixable_warnings())
{
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
fn cmd_list(cli: &Cli) -> Result<ExitCode> {
use alint_core::Level;
use alint_output::style;
let loaded = load_rules(Path::new("."), cli)?;
let (mut out, opts) = render_env(cli)?;
if loaded.entries.is_empty() {
writeln!(out, "(no rules loaded from config)")?;
out.flush().ok();
return Ok(ExitCode::SUCCESS);
}
let dim = style::DIM;
let docs = style::DOCS;
for entry in &loaded.entries {
let rule = &entry.rule;
let level_style = match rule.level() {
Level::Error => style::ERROR,
Level::Warning => style::WARNING,
Level::Info => style::INFO,
Level::Off => style::DIM,
};
let label = rule.level().as_str();
let pad = " ".repeat(8usize.saturating_sub(label.len()));
write!(
out,
"{level_style}{label}{level_style:#}{pad} {}",
rule.id()
)?;
if entry.when.is_some() {
write!(out, " {dim}[when]{dim:#}")?;
}
if opts.show_docs
&& let Some(url) = rule.policy_url()
{
write!(out, " {dim}({dim:#}{docs}{url}{docs:#}{dim}){dim:#}")?;
}
writeln!(out)?;
}
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn cmd_facts(path: &Path, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let values =
alint_core::evaluate_facts(&loaded.facts, path, &index).context("evaluating facts")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let (mut out, _opts) = render_env(cli)?;
render_facts(&loaded.facts, &values, format, &mut out)?;
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn render_facts(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
format: Format,
out: &mut dyn Write,
) -> Result<()> {
match format {
Format::Json => render_facts_json(facts, values, out),
_ => render_facts_human(facts, values, out),
}
}
fn render_facts_human(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
use alint_output::style;
if facts.is_empty() {
writeln!(out, "(no facts declared in config)")?;
return Ok(());
}
let id_width = facts.iter().map(|f| f.id.len()).max().unwrap_or(0);
let kind_width = facts.iter().map(|f| f.kind.name().len()).max().unwrap_or(0);
let dim = style::DIM;
for spec in facts {
let value_str = values
.get(&spec.id)
.map_or_else(|| "(unresolved)".to_string(), fact_value_display);
let value_style = match value_str.as_str() {
"true" => style::SUCCESS,
"false" | "(unresolved)" => style::DIM,
_ => style::PATH, };
let kind_name = spec.kind.name();
let kind_pad = " ".repeat(kind_width.saturating_sub(kind_name.len()));
writeln!(
out,
"{:<id_width$} {dim}{kind_name}{dim:#}{kind_pad} {value_style}{value_str}{value_style:#}",
spec.id,
)?;
}
Ok(())
}
fn render_facts_json(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
let entries: Vec<serde_json::Value> = facts
.iter()
.map(|spec| {
let value = values
.get(&spec.id)
.map_or(serde_json::Value::Null, fact_value_json);
serde_json::json!({
"id": spec.id,
"kind": spec.kind.name(),
"value": value,
})
})
.collect();
let doc = serde_json::json!({ "facts": entries });
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
Ok(())
}
fn fact_value_display(v: &alint_core::FactValue) -> String {
match v {
alint_core::FactValue::Bool(b) => b.to_string(),
alint_core::FactValue::Int(n) => n.to_string(),
alint_core::FactValue::String(s) => {
format!("{s:?}")
}
}
}
fn fact_value_json(v: &alint_core::FactValue) -> serde_json::Value {
match v {
alint_core::FactValue::Bool(b) => serde_json::Value::Bool(*b),
alint_core::FactValue::Int(n) => serde_json::Value::Number((*n).into()),
alint_core::FactValue::String(s) => serde_json::Value::String(s.clone()),
}
}
fn cmd_explain(rule_id: &str, cli: &Cli) -> Result<ExitCode> {
use alint_core::Level;
use alint_output::style;
let loaded = load_rules(Path::new("."), cli)?;
let Some(entry) = loaded.entries.iter().find(|e| e.rule.id() == rule_id) else {
bail!("no rule with id {rule_id:?} found in the effective config");
};
let rule = &entry.rule;
let (mut out, opts) = render_env(cli)?;
let dim = style::DIM;
let docs = style::DOCS;
let level_style = match rule.level() {
Level::Error => style::ERROR,
Level::Warning => style::WARNING,
Level::Info => style::INFO,
Level::Off => style::DIM,
};
writeln!(out, "{dim}id: {dim:#} {}", rule.id())?;
writeln!(
out,
"{dim}level: {dim:#} {level_style}{}{level_style:#}",
rule.level().as_str(),
)?;
if opts.show_docs
&& let Some(url) = rule.policy_url()
{
writeln!(out, "{dim}policy_url:{dim:#} {docs}{url}{docs:#}")?;
}
if let Some(when) = &entry.when {
writeln!(out, "{dim}when: {dim:#} {when:?}")?;
}
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn render_env(
cli: &Cli,
) -> Result<(
anstream::AutoStream<std::io::StdoutLock<'static>>,
HumanOptions,
)> {
let choice: ColorChoice = cli.color.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let choice = choice.resolve();
let stdout = io::stdout();
let is_tty = stdout.is_terminal();
let lock = stdout.lock();
let stream = anstream::AutoStream::new(lock, choice.to_anstream());
let hyperlinks = is_tty && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout);
let width = cli.width.or_else(|| {
if is_tty {
terminal_size::terminal_size().map(|(w, _)| usize::from(w.0))
} else {
None
}
});
let opts = HumanOptions {
glyphs: GlyphSet::detect(cli.ascii),
hyperlinks,
width,
compact: cli.compact,
show_docs: !cli.no_docs,
};
Ok((stream, opts))
}
struct LoadedConfig {
entries: Vec<alint_core::RuleEntry>,
registry: RuleRegistry,
facts: Vec<alint_core::FactSpec>,
vars: std::collections::HashMap<String, String>,
respect_gitignore: bool,
extra_ignores: Vec<String>,
fix_size_limit: Option<u64>,
}
fn cmd_validate_config(path: Option<PathBuf>, format: &str, cli: &Cli) -> Result<ExitCode> {
let config_path: PathBuf = if let Some(p) = path {
if p.is_dir() {
if let Some(found) = alint_dsl::discover(&p) {
found
} else {
let err = anyhow::anyhow!(
"no .alint.yml found under directory {} \
(run `alint init` there to scaffold one)",
p.display()
);
return emit_validate_failure(&err, None, format);
}
} else {
p
}
} else if let Some(first) = cli.config.first() {
first.clone()
} else if let Some(p) = alint_dsl::discover(Path::new(".")) {
p
} else {
let err = anyhow::anyhow!(
"no .alint.yml found (searched from {}) \
(run `alint init` to scaffold one)",
Path::new(".").display()
);
return emit_validate_failure(&err, None, format);
};
if !config_path.exists() {
let err = anyhow::anyhow!("config file not found: {}", config_path.display());
return emit_validate_failure(&err, Some(&config_path), format);
}
match validate_config_inner(&config_path) {
Ok(rule_count) => emit_validate_success(rule_count, &config_path, format),
Err(e) => emit_validate_failure(&e, Some(&config_path), format),
}
}
fn validate_config_inner(config_path: &Path) -> Result<usize> {
let config = alint_dsl::load(config_path)?;
let registry: alint_core::RuleRegistry = alint_rules::builtin_registry();
let mut count = 0usize;
for spec in &config.rules {
if matches!(spec.level, alint_core::Level::Off) {
continue;
}
registry
.build(spec)
.with_context(|| format!("building rule {:?}", spec.id))?;
if let Some(when_src) = &spec.when {
alint_core::when::parse(when_src)
.with_context(|| format!("rule {:?}: parsing `when`", spec.id))?;
}
count += 1;
}
Ok(count)
}
fn emit_validate_success(rule_count: usize, config_path: &Path, format: &str) -> Result<ExitCode> {
if format == "json" {
let envelope = serde_json::json!({
"valid": true,
"rule_count": rule_count,
"config_path": config_path.display().to_string(),
"error": serde_json::Value::Null,
});
println!("{}", serde_json::to_string(&envelope)?);
} else {
println!(
"✓ Config valid: {rule_count} rule(s) loaded from {}",
config_path.display()
);
}
Ok(ExitCode::SUCCESS)
}
fn emit_validate_failure(
err: &anyhow::Error,
config_path: Option<&Path>,
format: &str,
) -> Result<ExitCode> {
if format == "json" {
let chain = format!("{err:#}");
let envelope = serde_json::json!({
"valid": false,
"rule_count": 0,
"config_path": config_path.map(|p| p.display().to_string()),
"error": chain,
});
println!("{}", serde_json::to_string(&envelope)?);
} else {
eprintln!("alint: {err:#}");
println!("✗ Config invalid");
}
Ok(ExitCode::from(1))
}
fn load_rules(cwd: &Path, cli: &Cli) -> Result<LoadedConfig> {
let config_path = if let Some(first) = cli.config.first() {
first.clone()
} else {
alint_dsl::discover(cwd).ok_or_else(|| {
anyhow::anyhow!(
"no .alint.yml found (searched from {}) \
(run `alint init` to scaffold one)",
cwd.display()
)
})?
};
tracing::debug!(?config_path, "loading config");
let config = alint_dsl::load(&config_path)?;
let registry: RuleRegistry = alint_rules::builtin_registry();
let mut entries: Vec<alint_core::RuleEntry> = Vec::with_capacity(config.rules.len());
for spec in &config.rules {
if matches!(spec.level, alint_core::Level::Off) {
continue;
}
let rule = registry
.build(spec)
.with_context(|| format!("building rule {:?}", spec.id))?;
let mut entry = alint_core::RuleEntry::new(rule);
if let Some(when_src) = &spec.when {
let expr = alint_core::when::parse(when_src)
.with_context(|| format!("rule {:?}: parsing `when`", spec.id))?;
entry = entry.with_when(expr);
}
entries.push(entry);
}
Ok(LoadedConfig {
entries,
registry,
facts: config.facts,
vars: config.vars,
respect_gitignore: config.respect_gitignore,
extra_ignores: config.ignore,
fix_size_limit: config.fix_size_limit,
})
}
#[cfg(test)]
mod tests {
use super::*;
use alint_core::{FactKind, FactSpec, FactValue, FactValues, facts::OneOrMany};
use alint_output::Format;
#[test]
fn url_encode_passes_through_unreserved_chars() {
assert_eq!(url_encode("abcXYZ012-_.~"), "abcXYZ012-_.~");
}
#[test]
fn url_encode_percent_encodes_reserved_and_unsafe() {
assert_eq!(url_encode(" "), "%20");
assert_eq!(url_encode("foo bar"), "foo%20bar");
assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(url_encode("/?:@!$"), "%2F%3F%3A%40%21%24");
}
#[test]
fn url_encode_handles_unicode() {
assert_eq!(url_encode("ñ"), "%C3%B1");
}
fn fact_spec(id: &str, kind: FactKind) -> FactSpec {
FactSpec {
id: id.to_string(),
kind,
}
}
fn any_file_exists_kind(glob: &str) -> FactKind {
FactKind::AnyFileExists {
any_file_exists: OneOrMany::One(glob.to_string()),
}
}
fn count_files_kind(glob: &str) -> FactKind {
FactKind::CountFiles {
count_files: glob.to_string(),
}
}
fn git_branch_kind() -> FactKind {
FactKind::GitBranch {
git_branch: alint_core::facts::GitBranchFact {},
}
}
fn render_to_string<F>(render: F) -> String
where
F: FnOnce(&mut dyn Write) -> Result<()>,
{
let mut buf = Vec::new();
render(&mut buf).expect("render should succeed");
String::from_utf8(buf).expect("output should be UTF-8")
}
#[test]
fn fact_kind_name_covers_every_variant() {
assert_eq!(any_file_exists_kind("X").name(), "any_file_exists");
assert_eq!(count_files_kind("**/*.rs").name(), "count_files");
assert_eq!(git_branch_kind().name(), "git_branch");
assert_eq!(
FactKind::AllFilesExist {
all_files_exist: OneOrMany::One("X".into()),
}
.name(),
"all_files_exist"
);
assert_eq!(
FactKind::FileContentMatches {
file_content_matches: alint_core::facts::FileContentMatchesFact {
paths: OneOrMany::One("X".into()),
pattern: ".".into(),
},
}
.name(),
"file_content_matches"
);
assert_eq!(
FactKind::Custom {
custom: alint_core::facts::CustomFact { argv: vec![] },
}
.name(),
"custom"
);
}
#[test]
fn fact_value_display_renders_each_variant() {
assert_eq!(fact_value_display(&FactValue::Bool(true)), "true");
assert_eq!(fact_value_display(&FactValue::Bool(false)), "false");
assert_eq!(fact_value_display(&FactValue::Int(0)), "0");
assert_eq!(fact_value_display(&FactValue::Int(42)), "42");
assert_eq!(fact_value_display(&FactValue::Int(-1)), "-1");
assert_eq!(
fact_value_display(&FactValue::String("main".into())),
"\"main\""
);
assert_eq!(
fact_value_display(&FactValue::String(String::new())),
"\"\""
);
}
#[test]
fn fact_value_json_preserves_native_types() {
assert_eq!(
fact_value_json(&FactValue::Bool(true)),
serde_json::json!(true)
);
assert_eq!(fact_value_json(&FactValue::Int(42)), serde_json::json!(42));
assert_eq!(
fact_value_json(&FactValue::String("main".into())),
serde_json::json!("main")
);
}
#[test]
fn human_render_aligns_columns_and_covers_each_value_kind() {
let facts = vec![
fact_spec("is_python", any_file_exists_kind("pyproject.toml")),
fact_spec("n_rs_files", count_files_kind("**/*.rs")),
fact_spec("branch", git_branch_kind()),
];
let mut values = FactValues::new();
values.insert("is_python".into(), FactValue::Bool(true));
values.insert("n_rs_files".into(), FactValue::Int(42));
values.insert("branch".into(), FactValue::String("main".into()));
let out = render_to_string(|w| render_facts_human(&facts, &values, w));
assert!(out.contains("is_python"), "output: {out}");
assert!(out.contains("n_rs_files"), "output: {out}");
assert!(out.contains("branch"), "output: {out}");
assert!(out.contains("true"));
assert!(out.contains("42"));
assert!(out.contains("\"main\""));
assert!(out.contains("any_file_exists"));
assert!(out.contains("count_files"));
assert!(out.contains("git_branch"));
assert_eq!(out.lines().count(), 3);
}
#[test]
fn human_render_reports_no_facts_message() {
let out = render_to_string(|w| render_facts_human(&[], &FactValues::new(), w));
assert_eq!(out.trim(), "(no facts declared in config)");
}
#[test]
fn human_render_marks_unresolved_facts_when_value_is_missing() {
let facts = vec![fact_spec("orphan", any_file_exists_kind("X"))];
let out = render_to_string(|w| render_facts_human(&facts, &FactValues::new(), w));
assert!(out.contains("(unresolved)"), "output: {out}");
}
#[test]
fn json_render_emits_versioned_document_shape() {
let facts = vec![
fact_spec("is_go", any_file_exists_kind("go.mod")),
fact_spec("n_py", count_files_kind("**/*.py")),
];
let mut values = FactValues::new();
values.insert("is_go".into(), FactValue::Bool(false));
values.insert("n_py".into(), FactValue::Int(5));
let out = render_to_string(|w| render_facts_json(&facts, &values, w));
let parsed: serde_json::Value =
serde_json::from_str(&out).expect("render should emit valid JSON");
let arr = parsed
.get("facts")
.and_then(|v| v.as_array())
.expect("facts: [...]");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], serde_json::json!("is_go"));
assert_eq!(arr[0]["kind"], serde_json::json!("any_file_exists"));
assert_eq!(arr[0]["value"], serde_json::json!(false));
assert_eq!(arr[1]["id"], serde_json::json!("n_py"));
assert_eq!(arr[1]["kind"], serde_json::json!("count_files"));
assert_eq!(arr[1]["value"], serde_json::json!(5));
}
#[test]
fn json_render_empty_list_is_empty_array_not_null() {
let out = render_to_string(|w| render_facts_json(&[], &FactValues::new(), w));
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["facts"], serde_json::json!([]));
}
#[test]
fn json_render_missing_value_becomes_null() {
let facts = vec![fact_spec("orphan", any_file_exists_kind("X"))];
let out = render_to_string(|w| render_facts_json(&facts, &FactValues::new(), w));
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["facts"][0]["value"], serde_json::Value::Null);
}
#[test]
fn render_facts_dispatches_on_format() {
let facts = vec![fact_spec("is_py", any_file_exists_kind("py"))];
let mut values = FactValues::new();
values.insert("is_py".into(), FactValue::Bool(true));
let human_out = render_to_string(|w| render_facts(&facts, &values, Format::Human, w));
assert!(human_out.contains("is_py"));
assert!(!human_out.contains("\"facts\""));
let json_out = render_to_string(|w| render_facts(&facts, &values, Format::Json, w));
assert!(json_out.contains("\"facts\""));
let sarif_out = render_to_string(|w| render_facts(&facts, &values, Format::Sarif, w));
assert!(sarif_out.contains("is_py"));
assert!(!sarif_out.contains("\"facts\""));
}
}