use super::{Cli, VisContext};
use crate::config::{CompiledConfig, PostProcessStep};
use crate::paths;
use anyhow::bail;
use rayon::prelude::*;
use rust_llm_tidy_lint::{Severity, check};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
struct PerFileOut {
changes: Vec<(PathBuf, crate::changes::Change)>,
diagnostics: Vec<(PathBuf, rust_llm_tidy_lint::Diagnostic)>,
printed: Vec<String>,
error_count: usize,
failed: bool,
processed: bool,
}
impl PerFileOut {
fn record_changes(&mut self, path: &Path, found: Vec<crate::changes::Change>, json_mode: bool) {
for change in &found {
if !json_mode {
self.printed.push(format!("{}:{}", path.display(), change));
}
}
self.changes
.extend(found.into_iter().map(|c| (path.to_path_buf(), c)));
}
fn fail(&mut self, path: &Path, err: &anyhow::Error) {
self.printed
.push(format!("error processing {}: {err:?}", path.display()));
self.failed = true;
}
}
pub(crate) fn op_enabled(
name: &str,
enabled: &Option<HashSet<String>>,
disabled: &HashSet<String>,
) -> bool {
match enabled {
Some(set) => set.contains(name),
None => !disabled.contains(name),
}
}
pub(crate) fn run_pipeline(
cli: &Cli,
config: Option<&CompiledConfig>,
cli_include: Option<&HashSet<String>>,
cli_disabled: &HashSet<String>,
) -> anyhow::Result<()> {
let paths = dedup_inputs(paths::resolve_inputs(cli, &["rs", "md"])?);
if paths.is_empty() {
if cli.json_mode() {
crate::output::emit_json(&[], &[])?;
}
return Ok(());
}
let json_mode = cli.json_mode();
let mut error_count = 0usize;
let mut failed = Vec::new();
let mut processed: Vec<PathBuf> = Vec::new();
let mut diagnostics: Vec<(PathBuf, rust_llm_tidy_lint::Diagnostic)> = Vec::new();
let mut changes: Vec<(PathBuf, crate::changes::Change)> = Vec::new();
let vis_may_run = cli_include.as_ref().is_none_or(|s| s.contains("vis"));
let ctx = if vis_may_run {
super::resolve_vis_context(&paths)
} else {
None
};
let parallelize = should_parallelize(&paths);
let map_file = |path: &PathBuf| {
process_one(
path,
config,
cli_include,
cli_disabled,
ctx.as_ref(),
cli.dry_run,
json_mode,
)
};
let results: Vec<PerFileOut> = if parallelize {
paths.par_iter().map(map_file).collect()
} else {
paths.iter().map(map_file).collect()
};
for (path, out) in paths.iter().zip(results) {
for line in &out.printed {
eprintln!("{line}");
}
error_count += out.error_count;
changes.extend(out.changes);
diagnostics.extend(out.diagnostics);
if out.failed {
failed.push(path.clone());
}
if out.processed {
processed.push(path.clone());
}
}
if json_mode {
crate::output::emit_json(&diagnostics, &changes)?;
}
if let Some(c) = config
&& !cli.dry_run
{
let pp_failed = run_post_process(c.post_process_steps(), &processed);
if !pp_failed.is_empty() {
bail!("post_process failed on {} file(s)", pp_failed.len());
}
}
if !failed.is_empty() {
bail!("failed to process {} file(s)", failed.len());
}
if error_count > 0 {
bail!("found {} error(s)", error_count);
}
Ok(())
}
pub(crate) fn run_post_process(steps: &[PostProcessStep], files: &[PathBuf]) -> Vec<PathBuf> {
let mut failed = Vec::new();
for step in steps {
let exts: Vec<&str> = step.extensions.iter().map(String::as_str).collect();
for file in files {
if !step.extensions.is_empty() {
let ext_ok = crate::paths::ext_in(file.extension().and_then(|e| e.to_str()), &exts);
if !ext_ok {
continue;
}
}
let output = std::process::Command::new(&step.command)
.args(&step.args)
.arg(file)
.output();
match output {
Ok(out) if out.status.success() => {}
Ok(out) => {
eprintln!(
"post_process `{}` failed on {}: {}",
step.command,
file.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
failed.push(file.clone());
}
Err(e) => {
eprintln!(
"post_process `{}` failed to spawn on {}: {e}",
step.command,
file.display()
);
failed.push(file.clone());
}
}
}
}
failed
}
pub(crate) fn should_parallelize(paths: &[PathBuf]) -> bool {
const WEIGHT_SCALE: u64 = 1000;
const MARKDOWN_WEIGHT: u64 = WEIGHT_SCALE;
const RUST_WEIGHT: u64 = 120 * WEIGHT_SCALE;
const PARALLEL_SCORE: u64 = 600 * 1024 * WEIGHT_SCALE;
fn byte_weight(ext: Option<&str>) -> u64 {
if crate::paths::ext_in(ext, &["rs"]) {
RUST_WEIGHT
} else {
MARKDOWN_WEIGHT
}
}
if paths.len() < 2 {
return false;
}
let mut score = 0u64;
for p in paths {
let w = byte_weight(p.extension().and_then(|e| e.to_str()));
score = score.saturating_add(
std::fs::metadata(p)
.map(|m| m.len().saturating_mul(w))
.unwrap_or(0),
);
if score >= PARALLEL_SCORE {
return true;
}
}
false
}
fn dedup_inputs(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut by_path: HashSet<PathBuf> = HashSet::new();
#[cfg(unix)]
let mut by_inode: HashSet<(u64, u64)> = HashSet::new();
paths
.into_iter()
.filter(|p| {
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone());
if !by_path.insert(canon) {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match std::fs::metadata(p) {
Ok(m) => by_inode.insert((m.dev(), m.ino())),
Err(_) => true, }
}
#[cfg(not(unix))]
{
true
}
})
.collect()
}
fn process_one(
path: &Path,
config: Option<&CompiledConfig>,
cli_include: Option<&HashSet<String>>,
cli_disabled: &HashSet<String>,
ctx: Option<&VisContext>,
dry_run: bool,
json_mode: bool,
) -> PerFileOut {
let mut out = PerFileOut {
changes: Vec::new(),
diagnostics: Vec::new(),
printed: Vec::new(),
error_count: 0,
failed: false,
processed: false,
};
let mut policy = config.map(|c| c.policy_for(path)).unwrap_or_default();
if policy.skip {
return out;
}
if let Some(include) = cli_include {
policy.enabled = Some(include.clone());
policy.disabled.clear();
}
if !cli_disabled.is_empty() {
policy.disabled.extend(cli_disabled.iter().cloned());
if let Some(set) = &mut policy.enabled {
set.retain(|r| !cli_disabled.contains(r));
}
}
let enabled = &policy.enabled;
let disabled = &policy.disabled;
let should_post_process = ["tables", "fences", "links", "reorder", "vis"]
.iter()
.any(|op| op_enabled(op, enabled, disabled));
if op_enabled("tables", enabled, disabled)
|| op_enabled("fences", enabled, disabled)
|| op_enabled("links", enabled, disabled)
{
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let links_min = match config {
Some(c) => c.links_min_occurrences_for(ext),
None => 1,
};
match super::fix_file(path, dry_run, enabled, disabled, links_min) {
Ok(found) => out.record_changes(path, found, json_mode),
Err(e) => {
out.fail(path, &e);
return out;
}
}
}
let is_rust = crate::paths::ext_in(path.extension().and_then(|e| e.to_str()), &["rs"]);
if !is_rust {
if should_post_process {
out.processed = true;
}
return out;
}
if op_enabled("reorder", enabled, disabled) {
match super::reorder_file(path, dry_run, disabled) {
Ok(found) => out.record_changes(path, found, json_mode),
Err(e) => {
out.fail(path, &e);
return out;
}
}
}
if op_enabled("vis", enabled, disabled) {
match super::vis_file(path, dry_run, ctx, disabled) {
Ok(found) => out.record_changes(path, found, json_mode),
Err(e) => {
out.fail(path, &e);
return out;
}
}
}
let lints_on = !disabled.contains("lints")
&& match enabled {
Some(set) => {
set.contains("lints") || check::LINT_CODES.iter().any(|c| set.contains(*c))
}
None => true,
};
if lints_on {
let lint_disabled: HashSet<String> = match enabled {
Some(set) if !set.contains("lints") => check::LINT_CODES
.iter()
.filter(|c| !set.contains(**c))
.map(|c| c.to_string())
.chain(disabled.iter().cloned())
.collect(),
_ => disabled.clone(),
};
match super::check_file(path, &lint_disabled) {
Ok(found) => {
for (p, d) in &found {
if matches!(d.severity, Severity::Error) {
out.error_count += 1;
}
if !json_mode {
out.printed.push(format!("{}:{}", p.display(), d));
}
}
out.diagnostics.extend(found);
}
Err(e) => {
out.fail(path, &e);
return out;
}
}
}
if should_post_process {
out.processed = true;
}
out
}
#[cfg(test)]
mod tests {
use super::dedup_inputs;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let n = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
let d =
std::env::temp_dir().join(format!("rust-llm-tidy-dedup-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn cleanup(d: &PathBuf) {
let _ = fs::remove_dir_all(d);
}
#[test]
fn dedups_aliases_preserving_first_spelling() {
let dir = temp_dir();
fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
fs::write(dir.join("b.rs"), "fn b() {}\n").unwrap();
let input = vec![
dir.join("a.rs"),
dir.join(".").join("a.rs"),
dir.join("b.rs"),
dir.join("a.rs"),
];
assert_eq!(
dedup_inputs(input),
vec![dir.join("a.rs"), dir.join("b.rs")]
);
cleanup(&dir);
}
#[cfg(unix)]
#[test]
fn dedups_symlink_and_hardlink_aliases() {
let dir = temp_dir();
fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
std::os::unix::fs::symlink(dir.join("a.rs"), dir.join("link.rs")).unwrap();
fs::hard_link(dir.join("a.rs"), dir.join("hard.rs")).unwrap();
let out = dedup_inputs(vec![
dir.join("a.rs"),
dir.join("link.rs"),
dir.join("hard.rs"),
]);
assert_eq!(out, vec![dir.join("a.rs")]);
cleanup(&dir);
}
#[test]
fn keeps_distinct_files() {
let dir = temp_dir();
fs::write(dir.join("x.rs"), "fn x() {}\n").unwrap();
fs::write(dir.join("y.rs"), "fn y() {}\n").unwrap();
assert_eq!(
dedup_inputs(vec![dir.join("x.rs"), dir.join("y.rs")]),
vec![dir.join("x.rs"), dir.join("y.rs")]
);
cleanup(&dir);
}
}