use rustc_hash::FxHashMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fallow_config::OutputFormat;
mod catalog;
mod config;
mod deps;
mod enum_helpers;
mod enum_members;
mod exports;
mod io;
mod plan;
pub use config::is_config_fixable;
use plan::{CapturedHashes, CommitOutcome, FixPlan, SkippedFile};
fn run_analyze(
config: &fallow_config::ResolvedConfig,
output: OutputFormat,
) -> Result<(fallow_core::results::AnalysisResults, CapturedHashes), ExitCode> {
#[expect(
deprecated,
reason = "ADR-008 deprecates fallow_core::analyze_with_file_hashes externally; the CLI still uses the workspace path dependency"
)]
let output_struct = fallow_core::analyze_with_file_hashes(config)
.map_err(|e| crate::error::emit_error(&format!("Analysis error: {e}"), 2, output))?;
Ok((output_struct.results, output_struct.file_hashes))
}
pub struct FixOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub dry_run: bool,
pub yes: bool,
pub production: bool,
pub no_create_config: bool,
}
#[expect(
clippy::too_many_lines,
reason = "orchestrator threads results across 5 per-issue-type fixers + the post-#454 commit + envelope assembly; splitting harms locality of the wire-format authoring"
)]
pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode {
if !opts.dry_run && !opts.yes && !std::io::stdin().is_terminal() {
let msg = "fix command requires --yes (or --force) in non-interactive environments. \
Use --dry-run to preview changes first, then pass --yes to confirm.";
return crate::error::emit_error(msg, 2, opts.output);
}
let config = match crate::runtime_support::load_config(
opts.root,
opts.config_path,
opts.output,
opts.no_cache,
opts.threads,
opts.production,
opts.quiet,
) {
Ok(c) => c,
Err(code) => return code,
};
let (results, file_hashes) = match run_analyze(&config, opts.output) {
Ok(r) => r,
Err(code) => return code,
};
if results.total_issues() == 0 {
if matches!(opts.output, OutputFormat::Json) {
match serde_json::to_string_pretty(&serde_json::json!({
"dry_run": opts.dry_run,
"fixes": [],
"total_fixed": 0,
"skipped": 0,
"skipped_content_changed": 0,
"skipped_mixed_line_endings": 0,
})) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("Error: failed to serialize fix output: {e}");
return ExitCode::from(2);
}
}
} else if !opts.quiet {
eprintln!("No issues to fix.");
}
return ExitCode::SUCCESS;
}
let mut fixes: Vec<serde_json::Value> = Vec::new();
let mut plan = FixPlan::new();
let mut exports_by_file: FxHashMap<PathBuf, Vec<&fallow_core::results::UnusedExport>> =
FxHashMap::default();
for finding in &results.unused_exports {
exports_by_file
.entry(finding.export.path.clone())
.or_default()
.push(&finding.export);
}
exports::apply_export_fixes(
opts.root,
&exports_by_file,
&file_hashes,
&mut plan,
opts.output,
opts.dry_run,
&mut fixes,
);
deps::apply_dependency_fixes(
opts.root,
&results,
&file_hashes,
&mut plan,
opts.output,
opts.dry_run,
&mut fixes,
);
let mut had_write_error = config::apply_config_fixes(
opts.root,
opts.config_path.as_ref(),
&results,
opts.output,
opts.dry_run,
opts.no_create_config,
&mut fixes,
);
if !results.unused_enum_members.is_empty() {
let mut enum_members_by_file: FxHashMap<PathBuf, Vec<&fallow_core::results::UnusedMember>> =
FxHashMap::default();
for finding in &results.unused_enum_members {
enum_members_by_file
.entry(finding.member.path.clone())
.or_default()
.push(&finding.member);
}
enum_members::apply_enum_member_fixes(
opts.root,
&enum_members_by_file,
&file_hashes,
&mut plan,
opts.output,
opts.dry_run,
&mut fixes,
);
}
let catalog_summary = catalog::apply_catalog_entry_fixes(
opts.root,
&results.unused_catalog_entries,
config.fix.catalog.delete_preceding_comments,
&file_hashes,
&mut plan,
opts.output,
opts.dry_run,
&mut fixes,
);
had_write_error |= catalog_summary.write_error;
let empty_catalog_summary = catalog::apply_empty_catalog_group_fixes(
opts.root,
&results.empty_catalog_groups,
&file_hashes,
&mut plan,
opts.output,
opts.dry_run,
&mut fixes,
);
had_write_error |= empty_catalog_summary.write_error;
let catalog_applied = catalog_summary.applied + empty_catalog_summary.applied;
let catalog_skipped = catalog_summary.skipped + empty_catalog_summary.skipped;
let catalog_comment_lines_removed = catalog_summary.comment_lines_removed;
let plan_skip_records = build_skipped_records(opts.root, plan.skipped(), opts.quiet);
fixes.extend(plan_skip_records.iter().cloned());
let commit_outcome = if opts.dry_run {
CommitOutcome::empty_for_dry_run()
} else {
let outcome = plan.commit();
patch_applied_field_on_failure(&mut fixes, opts.root, &outcome.failed);
outcome
};
strip_target_sidechannel(&mut fixes);
let content_changed_count = plan_skip_records
.iter()
.filter(|r| {
r.get("skip_reason").and_then(serde_json::Value::as_str) == Some("content_changed")
})
.count();
let mixed_line_endings_count = plan_skip_records
.iter()
.filter(|r| {
r.get("skip_reason").and_then(serde_json::Value::as_str) == Some("mixed_line_endings")
})
.count();
if commit_outcome.had_failures() {
had_write_error = true;
}
if content_changed_count > 0 || mixed_line_endings_count > 0 {
had_write_error = true;
}
if matches!(opts.output, OutputFormat::Json) {
let applied_count = fixes
.iter()
.filter(|f| {
f.get("applied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.count();
let skipped_count = fixes
.iter()
.filter(|f| {
let is_skipped = f
.get("skipped")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let reason = f.get("skip_reason").and_then(serde_json::Value::as_str);
let is_plan_skip = matches!(reason, Some("content_changed" | "mixed_line_endings"));
is_skipped && !is_plan_skip
})
.count();
match serde_json::to_string_pretty(&serde_json::json!({
"dry_run": opts.dry_run,
"fixes": fixes,
"total_fixed": applied_count,
"skipped": skipped_count,
"skipped_content_changed": content_changed_count,
"skipped_mixed_line_endings": mixed_line_endings_count,
})) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("Error: failed to serialize fix output: {e}");
return ExitCode::from(2);
}
}
} else if !opts.quiet {
emit_human_summary(
opts.dry_run,
&fixes,
catalog_applied,
catalog_skipped,
catalog_comment_lines_removed,
content_changed_count,
mixed_line_endings_count,
);
}
if had_write_error {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
}
}
impl CommitOutcome {
pub(super) fn empty_for_dry_run() -> Self {
Self {
written: rustc_hash::FxHashSet::default(),
failed: Vec::new(),
}
}
pub(super) fn had_failures(&self) -> bool {
!self.failed.is_empty()
}
}
fn build_skipped_records(
root: &Path,
skipped: &[SkippedFile],
quiet: bool,
) -> Vec<serde_json::Value> {
skipped
.iter()
.map(|skip| {
let relative = skip.path.strip_prefix(root).unwrap_or(&skip.path);
if !quiet {
eprintln!("{}", skip.reason.human_message(relative));
}
serde_json::json!({
"type": "skipped",
"path": relative.display().to_string(),
"skipped": true,
"skip_reason": skip.reason.as_wire_str(),
})
})
.collect()
}
fn patch_applied_field_on_failure(
fixes: &mut [serde_json::Value],
root: &Path,
failed: &[(PathBuf, std::io::Error)],
) {
if failed.is_empty() {
return;
}
let failed_paths: rustc_hash::FxHashSet<PathBuf> =
failed.iter().map(|(p, _)| p.clone()).collect();
for (path, err) in failed {
let relative = path.strip_prefix(root).unwrap_or(path);
eprintln!("Error: failed to write {}: {err}", relative.display());
}
for entry in fixes.iter_mut() {
let target = entry.get("__target").and_then(|v| v.as_str());
let Some(target_str) = target else { continue };
if failed_paths.contains(&PathBuf::from(target_str)) {
entry["applied"] = serde_json::json!(false);
}
}
}
fn strip_target_sidechannel(fixes: &mut [serde_json::Value]) {
for entry in fixes.iter_mut() {
if let Some(obj) = entry.as_object_mut() {
obj.remove("__target");
}
}
}
fn emit_human_summary(
dry_run: bool,
fixes: &[serde_json::Value],
catalog_applied: usize,
catalog_skipped: usize,
catalog_comment_lines_removed: usize,
content_changed_count: usize,
mixed_line_endings_count: usize,
) {
if dry_run {
eprintln!("Dry run complete. No files were modified.");
} else {
let fixed_count = fixes
.iter()
.filter(|f| {
f.get("applied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.count();
if catalog_comment_lines_removed > 0 {
let line_word = if catalog_comment_lines_removed == 1 {
"line"
} else {
"lines"
};
eprintln!(
"Fixed {fixed_count} issue(s) (+{catalog_comment_lines_removed} catalog comment {line_word})."
);
} else {
eprintln!("Fixed {fixed_count} issue(s).");
}
}
if !dry_run && catalog_applied > 0 {
eprintln!(
"Catalog entries were removed from pnpm-workspace.yaml. Run `pnpm install` to refresh pnpm-lock.yaml.",
);
}
if catalog_skipped > 0 {
let entries_word = if catalog_skipped == 1 {
"entry"
} else {
"entries"
};
eprintln!(
"Skipped {catalog_skipped} catalog {entries_word} with hardcoded consumers or other guards (run with --format json for details).",
);
}
if content_changed_count > 0 {
let files_word = if content_changed_count == 1 {
"file"
} else {
"files"
};
eprintln!(
"Skipped {content_changed_count} {files_word} that changed since `fallow check` ran. Re-run `fallow fix` to refresh the analysis."
);
}
if mixed_line_endings_count > 0 {
let files_word = if mixed_line_endings_count == 1 {
"file"
} else {
"files"
};
eprintln!(
"Skipped {mixed_line_endings_count} {files_word} with mixed CRLF/LF line endings. Normalize each file (`dos2unix <path>` or `git config core.autocrlf input` + re-checkout) before re-running.",
);
}
}