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::borrow::Cow;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
mod changes;
mod config;
mod diff;
mod output;
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,
#[arg(long, value_name = "MODE", default_value = "text")]
output_mode: output::OutputMode,
#[arg(long, conflicts_with = "output_mode")]
json: bool,
}
pub(crate) struct VisContext {
tree: ModuleTree,
reexports: ReexportSet,
}
impl Cli {
fn json_mode(&self) -> bool {
self.output_mode == output::OutputMode::Json || self.json
}
}
pub(crate) fn check_file(
path: &Path,
disabled: &HashSet<String>,
) -> anyhow::Result<Vec<(PathBuf, rust_llm_tidy_lint::Diagnostic)>> {
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));
Ok(diagnostics
.into_iter()
.map(|d| (path.to_path_buf(), d))
.collect())
}
pub(crate) fn fix_file(
path: &Path,
dry_run: bool,
enabled: &Option<HashSet<String>>,
disabled: &HashSet<String>,
links_min_occurrences: usize,
) -> anyhow::Result<Vec<changes::Change>> {
let source =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut out: String = source.clone();
let mut change_records = Vec::new();
if pipeline::op_enabled("tables", enabled, disabled) {
let prior = std::mem::take(&mut out);
match fix::fix_tables(&prior) {
Cow::Owned(after) => {
change_records.push(changes::table_changes());
out = after;
}
Cow::Borrowed(_) => out = prior,
}
}
if pipeline::op_enabled("fences", enabled, disabled) {
let prior = std::mem::take(&mut out);
let outcome = fix::fix_fences(&prior);
match outcome.text {
Cow::Owned(after) => {
change_records.extend(changes::fence_changes(&outcome.anchors));
out = after;
}
Cow::Borrowed(_) => out = prior,
}
}
if pipeline::op_enabled("links", enabled, disabled) {
let prior = std::mem::take(&mut out);
let result = if links_min_occurrences <= 1 {
fix::fix_links(&prior)
} else {
fix::fix_links_with_min(&prior, links_min_occurrences)
};
match result {
(Cow::Owned(after), pairs) => {
change_records.extend(changes::link_changes(&pairs));
out = after;
}
(Cow::Borrowed(_), _) => out = prior,
}
}
if !dry_run && out != source {
io::atomic_write(path, &out)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(change_records)
}
pub(crate) fn reorder_file(
path: &Path,
dry_run: bool,
disabled: &HashSet<String>,
) -> anyhow::Result<Vec<changes::Change>> {
if disabled.contains("reorder") {
return Ok(Vec::new());
}
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()
)
})?;
let mut change_records = Vec::new();
for mv in rust_llm_tidy_reorder::compute_moves(&parsed.items, &permutation) {
let item = &parsed.items[mv.from() - 1];
change_records.push(changes::Change {
line: std::num::NonZeroU32::new(item.start_line() as u32),
code: "REORDER",
message: mv.message().into_boxed_str(),
kind: changes::ChangeKind::Item(*mv.kind()),
name: mv.name().map(Box::from),
});
}
if !dry_run && output != source {
io::atomic_write(path, &output)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(change_records)
}
pub(crate) fn resolve_vis_context(paths: &[PathBuf]) -> Option<VisContext> {
let first = paths
.iter()
.find(|p| crate::paths::ext_in(p.extension().and_then(|e| e.to_str()), &["rs"]))?;
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,
ctx: Option<&VisContext>,
disabled: &HashSet<String>,
) -> anyhow::Result<Vec<changes::Change>> {
if disabled.contains("vis") {
return Ok(Vec::new());
}
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()))?;
let change_records = changes::vis_changes(&source, &output);
if !dry_run && output != source {
io::atomic_write(path, &output)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(change_records)
}
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)
}