use anyhow::{Context, bail};
use clap::Parser;
use config::CompiledConfig;
use rust_llm_tidy_fix as fix;
use rust_llm_tidy_lint::check;
use rust_llm_tidy_model::io;
use rust_llm_tidy_model::parse as model_parse;
use rust_llm_tidy_model::parse;
use rust_llm_tidy_model::safety;
use rust_llm_tidy_reorder::graph;
use rust_llm_tidy_reorder::reorder::Permutation;
use rust_llm_tidy_vis::{
ModuleTree, ParsedFile, ReexportSet, build_module_tree, collect_crate_reexports,
discover_crate_root, narrow_vis_in_tree,
};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
mod config;
mod diff;
mod paths;
mod pipeline;
#[derive(Parser)]
#[command(
name = "rust-llm-tidy",
about = "Fix, reorder, narrow visibility, and lint Rust source files"
)]
pub(crate) struct Cli {
paths: Vec<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
validate: bool,
#[arg(long, value_name = "RULE")]
include: Vec<String>,
#[arg(long, value_name = "RULE")]
exclude: Vec<String>,
#[arg(long, global = true)]
config: Option<PathBuf>,
#[arg(long, global = true, conflicts_with = "config")]
no_config: bool,
}
pub(crate) struct VisContext {
tree: ModuleTree,
reexports: ReexportSet,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let compiled: Option<CompiledConfig> =
config::discover_config_path(cli.config.as_deref(), cli.no_config)
.map(|p| config::load_and_compile(&p))
.transpose()?;
let config_ref = compiled.as_ref();
if cli.validate {
if cli.no_config {
bail!("--no-config was passed; no config to validate");
}
let path = config::discover_config_path(cli.config.as_deref(), false)
.context("no config file found; run from a directory with .rust-llm-tidy.yml")?;
config::load_and_compile(&path)?;
println!("config valid: {}", path.display());
return Ok(());
}
let valid = config::known_rules();
for op in cli.include.iter().chain(cli.exclude.iter()) {
if !valid.contains(&op.as_str()) {
bail!(
"unknown op/rule `{op}` in --include/--exclude; valid: {}",
valid.join(", ")
);
}
}
let cli_include: Option<HashSet<String>> = if cli.include.is_empty() {
None
} else {
Some(cli.include.iter().cloned().collect())
};
let cli_disabled: HashSet<String> = cli.exclude.iter().cloned().collect();
pipeline::run_pipeline(&cli, config_ref, cli_include.as_ref(), &cli_disabled)
}
pub(crate) fn check_file(path: &Path, disabled: &HashSet<String>) -> anyhow::Result<usize> {
let source =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let parsed = model_parse::parse_source(&source)
.with_context(|| format!("failed to parse {}", path.display()))?;
let mut diagnostics = check::run_all(&parsed);
diagnostics.retain(|d| !disabled.contains(d.code));
let error_count = diagnostics
.iter()
.filter(|d| matches!(d.severity, rust_llm_tidy_lint::Severity::Error))
.count();
if !diagnostics.is_empty() {
for diag in &diagnostics {
eprintln!("{}:{}", path.display(), diag);
}
}
Ok(error_count)
}
pub(crate) fn fix_file(
path: &Path,
dry_run: bool,
multiple_files: bool,
enabled: &Option<HashSet<String>>,
disabled: &HashSet<String>,
) -> anyhow::Result<()> {
let source =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut out: String = source.clone();
if pipeline::op_enabled("tables", enabled, disabled) {
out = fix::fix_tables(&out).into_owned();
}
if pipeline::op_enabled("fences", enabled, disabled) {
out = fix::fix_fences(&out).into_owned();
}
if pipeline::op_enabled("links", enabled, disabled) {
out = fix::fix_links(&out).into_owned();
}
if dry_run {
if multiple_files {
print!("<!-- {} -->\n{}", path.display(), out);
} else {
print!("{out}");
}
} else if out != source {
io::atomic_write(path, &out)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}
pub(crate) fn reorder_file(
path: &Path,
dry_run: bool,
multiple_files: bool,
disabled: &HashSet<String>,
) -> anyhow::Result<()> {
if disabled.contains("reorder") {
return Ok(());
}
let source =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let parsed = parse::parse_source(&source)
.with_context(|| format!("failed to parse {}", path.display()))?;
let order = graph::compute_order(&parsed).context("failed to compute item order")?;
let permutation =
Permutation::new(parsed.items.len(), order).context("failed to build permutation")?;
let output = rust_llm_tidy_reorder::reorder::emit(&parsed, &permutation)
.context("failed to emit reordered source")?;
safety::verify_line_preservation(&source, &output).with_context(|| {
format!(
"safety check failed for {} - reordered output does not preserve lines",
path.display()
)
})?;
if dry_run {
if multiple_files {
print!("// {}\n{}", path.display(), output);
} else {
print!("{output}");
}
} else {
io::atomic_write(path, &output)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}
pub(crate) fn resolve_vis_context(paths: &[PathBuf]) -> Option<VisContext> {
let first = paths.first()?;
match discover_crate_root(first) {
Ok(root) => {
let root = fs::canonicalize(&root).unwrap_or(root);
let crate_dir = root.parent().unwrap_or_else(|| Path::new("."));
let mut rs_files: Vec<PathBuf> = Vec::new();
let _ = paths::collect_files(crate_dir, &["rs"], &mut rs_files);
let mut files: Vec<ParsedFile> = Vec::new();
for f in &rs_files {
if let Ok(src) = fs::read_to_string(f) {
let path = fs::canonicalize(f).unwrap_or_else(|_| f.clone());
match ParsedFile::new(path, src) {
Ok(pf) => files.push(pf),
Err(e) => eprintln!("warning: could not parse {}: {e}", f.display()),
}
}
}
let tree = match build_module_tree(&root, &files) {
Ok(t) => t,
Err(e) => {
eprintln!("warning: failed to build module tree ({e:?})");
return None;
}
};
for w in tree.warnings() {
eprintln!("warning: {w}");
}
let reexports = collect_crate_reexports(&files);
Some(VisContext { tree, reexports })
}
Err(e) => {
eprintln!("warning: crate-aware vis unavailable ({e}); narrowing standalone");
None
}
}
}
pub(crate) fn vis_file(
path: &Path,
dry_run: bool,
multiple_files: bool,
ctx: Option<&VisContext>,
disabled: &HashSet<String>,
) -> anyhow::Result<()> {
if disabled.contains("vis") {
return Ok(());
}
let source =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let output = match ctx {
Some(VisContext { tree, reexports }) => {
let canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if tree.contains(&canon) {
let floor = tree.floor_for(&canon);
narrow_vis_in_tree(&source, floor, reexports)
} else {
let pf = ParsedFile::new(path.to_path_buf(), source.clone())?;
let per_file = collect_crate_reexports(std::iter::once(&pf));
narrow_vis_in_tree(&source, None, &per_file)
}
}
None => {
let pf = ParsedFile::new(path.to_path_buf(), source.clone())?;
let reexports = collect_crate_reexports(std::iter::once(&pf));
narrow_vis_in_tree(&source, None, &reexports)
}
}
.with_context(|| format!("failed to narrow {}", path.display()))?;
if dry_run {
if multiple_files {
print!("// {}\n{}", path.display(), output);
} else {
print!("{output}");
}
} else if output != source {
io::atomic_write(path, &output)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}