use crate::dead_code_report;
use camino::{Utf8Path, Utf8PathBuf};
use mollify_types::Confidence;
use rustc_hash::FxHashMap;
#[derive(Debug, Clone)]
pub struct FixEdit {
pub path: Utf8PathBuf,
pub start_line: u32,
pub end_line: u32,
pub description: String,
}
pub fn plan(root: &Utf8Path) -> Vec<FixEdit> {
let report = dead_code_report(root);
let mut edits: Vec<FixEdit> = report
.findings
.into_iter()
.filter(|f| {
(f.rule == "unused-export" || f.rule == "unused-import")
&& f.confidence == Confidence::Certain
&& f.actions.first().is_some_and(|a| a.auto_fixable)
})
.map(|f| FixEdit {
start_line: f.location.line,
end_line: f.location.end_line.unwrap_or(f.location.line),
path: f.location.path,
description: f
.actions
.into_iter()
.next()
.map(|a| a.description)
.unwrap_or_default(),
})
.collect();
edits.sort_by(|a, b| a.path.cmp(&b.path).then(a.start_line.cmp(&b.start_line)));
edits
}
pub fn apply(edits: &[FixEdit]) -> std::io::Result<usize> {
let mut by_file: FxHashMap<&Utf8Path, Vec<&FixEdit>> = FxHashMap::default();
for e in edits {
by_file.entry(e.path.as_path()).or_default().push(e);
}
let mut applied = 0;
for (path, mut file_edits) in by_file {
file_edits.sort_by_key(|e| std::cmp::Reverse(e.start_line));
let content = std::fs::read_to_string(path)?;
let mut lines: Vec<&str> = content.lines().collect();
let mut last_removed_start = u32::MAX;
for e in file_edits {
let start = e.start_line.saturating_sub(1) as usize;
let end = (e.end_line as usize).min(lines.len());
if start >= lines.len() || e.end_line >= last_removed_start {
continue; }
lines.drain(start..end);
last_removed_start = e.start_line;
applied += 1;
}
let mut out = lines.join("\n");
if content.ends_with('\n') {
out.push('\n');
}
std::fs::write(path, out)?;
}
Ok(applied)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> Utf8PathBuf {
let base =
std::env::temp_dir().join(format!("mollify-core-fix-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
Utf8PathBuf::from_path_buf(base).unwrap()
}
#[test]
fn plan_targets_only_certain_unused() {
let d = temp("plan");
std::fs::write(d.join("__main__.py"), "print('hi')\n").unwrap();
std::fs::write(
d.join("lib.py"),
"def _priv():\n return 1\n\ndef pub():\n return 2\n",
)
.unwrap();
let edits = plan(&d);
assert_eq!(edits.len(), 1, "got {edits:?}");
assert!(edits[0].path.as_str().ends_with("lib.py"));
assert_eq!(edits[0].start_line, 1);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn apply_removes_the_symbol() {
let d = temp("apply");
std::fs::write(d.join("__main__.py"), "print('hi')\n").unwrap();
let lib = d.join("lib.py");
std::fs::write(
&lib,
"def _priv():\n return 1\n\ndef keep():\n return 2\n",
)
.unwrap();
let edits = plan(&d);
let n = apply(&edits).unwrap();
assert_eq!(n, 1);
let after = std::fs::read_to_string(&lib).unwrap();
assert!(!after.contains("_priv"), "after: {after:?}");
assert!(after.contains("keep"));
std::fs::remove_dir_all(&d).ok();
}
}