use rustc_hash::FxHashMap;
use std::path::{Path, PathBuf};
use fallow_config::OutputFormat;
use super::io::{read_source, write_fixed_content};
pub(super) struct EnumMemberFix {
line_idx: usize,
member_name: String,
parent_name: String,
}
pub(super) fn apply_enum_member_fixes(
root: &Path,
members_by_file: &FxHashMap<PathBuf, Vec<&fallow_core::results::UnusedMember>>,
output: OutputFormat,
dry_run: bool,
fixes: &mut Vec<serde_json::Value>,
) -> bool {
let mut had_write_error = false;
for (path, file_members) in members_by_file {
let Some((content, line_ending)) = read_source(root, path) else {
continue;
};
let lines: Vec<&str> = content.split(line_ending).collect();
let mut member_fixes: Vec<EnumMemberFix> = Vec::new();
for member in file_members {
let line_idx = member.line.saturating_sub(1) as usize;
if line_idx >= lines.len() {
continue;
}
let line = lines[line_idx];
if !line.contains(&member.member_name) {
continue;
}
member_fixes.push(EnumMemberFix {
line_idx,
member_name: member.member_name.clone(),
parent_name: member.parent_name.clone(),
});
}
if member_fixes.is_empty() {
continue;
}
member_fixes.sort_by_key(|f| std::cmp::Reverse(f.line_idx));
member_fixes.dedup_by_key(|f| f.line_idx);
let relative = path.strip_prefix(root).unwrap_or(path);
if dry_run {
for fix in &member_fixes {
if !matches!(output, OutputFormat::Json) {
eprintln!(
"Would remove enum member from {}:{} `{}.{}`",
relative.display(),
fix.line_idx + 1,
fix.parent_name,
fix.member_name,
);
}
fixes.push(serde_json::json!({
"type": "remove_enum_member",
"path": relative.display().to_string(),
"line": fix.line_idx + 1,
"parent": fix.parent_name,
"name": fix.member_name,
}));
}
} else {
let mut new_lines: Vec<String> = lines.iter().map(ToString::to_string).collect();
for fix in &member_fixes {
let line = &new_lines[fix.line_idx];
if line.contains('{') && line.contains('}') {
let new_line = remove_member_from_single_line(line, &fix.member_name);
new_lines[fix.line_idx] = new_line;
} else {
new_lines[fix.line_idx] = String::new();
}
}
let remove_indices: Vec<usize> = member_fixes
.iter()
.filter(|f| {
let orig_line = &lines[f.line_idx];
!(orig_line.contains('{') && orig_line.contains('}'))
})
.map(|f| f.line_idx)
.collect();
for &idx in &remove_indices {
new_lines.remove(idx);
}
let success = match write_fixed_content(path, &new_lines, line_ending, &content) {
Ok(()) => true,
Err(e) => {
had_write_error = true;
eprintln!("Error: failed to write {}: {e}", relative.display());
false
}
};
for fix in &member_fixes {
fixes.push(serde_json::json!({
"type": "remove_enum_member",
"path": relative.display().to_string(),
"line": fix.line_idx + 1,
"parent": fix.parent_name,
"name": fix.member_name,
"applied": success,
}));
}
}
}
had_write_error
}
fn remove_member_from_single_line(line: &str, member_name: &str) -> String {
let Some(open) = line.find('{') else {
return line.to_string();
};
let Some(close) = line.rfind('}') else {
return line.to_string();
};
if open >= close {
return line.to_string();
}
let prefix = &line[..=open];
let suffix = &line[close..];
let inner = &line[open + 1..close];
let parts: Vec<&str> = inner.split(',').collect();
let filtered: Vec<String> = parts
.iter()
.filter(|part| {
let trimmed = part.trim();
if trimmed.is_empty() {
return false;
}
let ident = trimmed.split('=').next().unwrap_or(trimmed).trim();
ident != member_name
})
.map(|part| part.trim().to_string())
.collect();
if filtered.is_empty() {
format!("{}{}", prefix.trim_end(), suffix.trim_start())
} else {
let members_str = filtered.join(", ");
format!("{prefix} {members_str} {suffix}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_core::extract::MemberKind;
use fallow_core::results::UnusedMember;
fn make_enum_member(path: &Path, parent: &str, name: &str, line: u32) -> UnusedMember {
UnusedMember {
path: path.to_path_buf(),
parent_name: parent.to_string(),
member_name: name.to_string(),
kind: MemberKind::EnumMember,
line,
col: 0,
}
}
fn fix_single_member(
root: &Path,
file: &Path,
enum_name: &str,
member_name: &str,
line: u32,
dry_run: bool,
) -> Vec<serde_json::Value> {
let member = make_enum_member(file, enum_name, member_name, line);
let mut map: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
map.insert(file.to_path_buf(), vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(root, &map, OutputFormat::Human, dry_run, &mut fixes);
fixes
}
#[test]
fn enum_fix_removes_single_member_from_multi_member_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"export enum Status {\n Active,\n Inactive,\n Pending,\n}\n",
)
.unwrap();
let fixes = fix_single_member(root, &file, "Status", "Inactive", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "export enum Status {\n Active,\n Pending,\n}\n");
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0]["type"], "remove_enum_member");
assert_eq!(fixes[0]["parent"], "Status");
assert_eq!(fixes[0]["name"], "Inactive");
assert_eq!(fixes[0]["applied"], true);
}
#[test]
fn enum_fix_removes_multiple_members_from_same_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"export enum Status {\n Active,\n Inactive,\n Pending,\n}\n",
)
.unwrap();
let m1 = make_enum_member(&file, "Status", "Active", 2);
let m2 = make_enum_member(&file, "Status", "Pending", 4);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&m1, &m2]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "export enum Status {\n Inactive,\n}\n");
assert_eq!(fixes.len(), 2);
}
#[test]
fn enum_fix_removes_all_members_leaves_empty_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "export enum Status {\n Active,\n Inactive,\n}\n").unwrap();
let m1 = make_enum_member(&file, "Status", "Active", 2);
let m2 = make_enum_member(&file, "Status", "Inactive", 3);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&m1, &m2]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "export enum Status {\n}\n");
assert_eq!(fixes.len(), 2);
}
#[test]
fn enum_fix_handles_members_with_values() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"export enum Status {\n Active = \"active\",\n Inactive = \"inactive\",\n Pending = 2,\n}\n",
)
.unwrap();
fix_single_member(root, &file, "Status", "Inactive", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(
content,
"export enum Status {\n Active = \"active\",\n Pending = 2,\n}\n"
);
}
#[test]
fn enum_fix_single_line_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "enum Status { Active, Inactive, Pending }\n").unwrap();
fix_single_member(root, &file, "Status", "Inactive", 1, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status { Active, Pending }\n");
}
#[test]
fn enum_fix_single_line_removes_all_members() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "enum Status { Active }\n").unwrap();
fix_single_member(root, &file, "Status", "Active", 1, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status {}\n");
}
#[test]
fn enum_fix_single_line_with_values() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"enum Status { Active = \"active\", Inactive = \"inactive\" }\n",
)
.unwrap();
fix_single_member(root, &file, "Status", "Active", 1, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status { Inactive = \"inactive\" }\n");
}
#[test]
fn enum_fix_dry_run_does_not_modify_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
let original = "export enum Status {\n Active,\n Inactive,\n}\n";
std::fs::write(&file, original).unwrap();
let member = make_enum_member(&file, "Status", "Active", 2);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(root, &members_by_file, OutputFormat::Json, true, &mut fixes);
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0]["type"], "remove_enum_member");
assert_eq!(fixes[0]["name"], "Active");
assert!(fixes[0].get("applied").is_none());
}
#[test]
fn enum_fix_preserves_crlf_line_endings() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"export enum Status {\r\n Active,\r\n Inactive,\r\n Pending,\r\n}\r\n",
)
.unwrap();
fix_single_member(root, &file, "Status", "Inactive", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(
content,
"export enum Status {\r\n Active,\r\n Pending,\r\n}\r\n"
);
}
#[test]
fn enum_fix_preserves_indentation() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
" export enum Status {\n Active,\n Inactive,\n }\n",
)
.unwrap();
fix_single_member(root, &file, "Status", "Active", 2, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(
content,
" export enum Status {\n Inactive,\n }\n"
);
}
#[test]
fn enum_fix_skips_path_outside_project_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let outside_file = dir.path().join("outside.ts");
let original = "enum Status {\n Active,\n Inactive,\n}\n";
std::fs::write(&outside_file, original).unwrap();
let fixes = fix_single_member(&root, &outside_file, "Status", "Active", 2, false);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), original);
assert!(fixes.is_empty());
}
#[test]
fn enum_fix_skips_line_without_member_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
let original = "enum Status {\n Active,\n Inactive,\n}\n";
std::fs::write(&file, original).unwrap();
let fixes = fix_single_member(root, &file, "Status", "Missing", 2, false);
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
assert!(fixes.is_empty());
}
#[test]
fn enum_fix_skips_out_of_bounds_line() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
let original = "enum Status {\n Active,\n}\n";
std::fs::write(&file, original).unwrap();
let fixes = fix_single_member(root, &file, "Status", "Active", 999, false);
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
assert!(fixes.is_empty());
}
#[test]
fn enum_fix_removes_last_member_of_multi_line_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "enum Status {\n Active,\n Inactive,\n}\n").unwrap();
fix_single_member(root, &file, "Status", "Inactive", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status {\n Active,\n}\n");
}
#[test]
fn enum_fix_handles_numeric_values() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("priority.ts");
std::fs::write(
&file,
"enum Priority {\n Low = 0,\n Medium = 1,\n High = 2,\n}\n",
)
.unwrap();
fix_single_member(root, &file, "Priority", "Medium", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Priority {\n Low = 0,\n High = 2,\n}\n");
}
#[test]
fn single_line_remove_first_member() {
let result = remove_member_from_single_line("enum Foo { A, B, C }", "A");
assert_eq!(result, "enum Foo { B, C }");
}
#[test]
fn single_line_remove_middle_member() {
let result = remove_member_from_single_line("enum Foo { A, B, C }", "B");
assert_eq!(result, "enum Foo { A, C }");
}
#[test]
fn single_line_remove_last_member() {
let result = remove_member_from_single_line("enum Foo { A, B, C }", "C");
assert_eq!(result, "enum Foo { A, B }");
}
#[test]
fn single_line_remove_only_member() {
let result = remove_member_from_single_line("enum Foo { A }", "A");
assert_eq!(result, "enum Foo {}");
}
#[test]
fn single_line_remove_member_with_value() {
let result = remove_member_from_single_line("enum Foo { A = 1, B = 2, C = 3 }", "B");
assert_eq!(result, "enum Foo { A = 1, C = 3 }");
}
#[test]
fn single_line_remove_member_with_string_value() {
let result = remove_member_from_single_line("enum Foo { A = \"a\", B = \"b\" }", "A");
assert_eq!(result, "enum Foo { B = \"b\" }");
}
#[test]
fn single_line_remove_two_members_sequentially() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "enum Status { A, B, C, D }\n").unwrap();
let m1 = make_enum_member(&file, "Status", "B", 1);
let m2 = make_enum_member(&file, "Status", "D", 1);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&m1, &m2]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(fixes.len(), 1);
assert!(!content.contains("enum Status { A, B, C, D }"));
}
#[test]
fn enum_fix_removes_first_member_of_multi_line_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(
&file,
"enum Status {\n Active,\n Inactive,\n Pending,\n}\n",
)
.unwrap();
fix_single_member(root, &file, "Status", "Active", 2, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status {\n Inactive,\n Pending,\n}\n");
}
#[test]
fn enum_fix_nonexistent_file_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("missing.ts");
let fixes = fix_single_member(root, &file, "Status", "Active", 2, false);
assert!(fixes.is_empty());
}
#[test]
fn enum_fix_member_with_computed_value() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("computed.ts");
std::fs::write(
&file,
"enum Bits {\n A = 1 << 0,\n B = 1 << 1,\n C = 1 << 2,\n}\n",
)
.unwrap();
fix_single_member(root, &file, "Bits", "B", 3, false);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Bits {\n A = 1 << 0,\n C = 1 << 2,\n}\n");
}
#[test]
fn enum_fix_single_line_with_trailing_comma() {
let result = remove_member_from_single_line("enum Foo { A, B, C, }", "B");
assert_eq!(result, "enum Foo { A, C }");
}
#[test]
fn enum_fix_single_line_no_braces() {
let result = remove_member_from_single_line("enum Foo A, B, C", "B");
assert_eq!(result, "enum Foo A, B, C");
}
#[test]
fn enum_fix_single_line_close_before_open() {
let result = remove_member_from_single_line("} enum Foo { A }", "A");
assert!(!result.is_empty());
}
#[test]
fn enum_fix_returns_relative_path_in_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("src").join("status.ts");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(&file, "enum Status {\n Active,\n Inactive,\n}\n").unwrap();
let member = make_enum_member(&file, "Status", "Active", 2);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file, vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let path_str = fixes[0]["path"].as_str().unwrap().replace('\\', "/");
assert_eq!(path_str, "src/status.ts");
}
#[test]
fn dry_run_enum_fix_with_human_output() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
let original = "enum Status {\n Active,\n Inactive,\n}\n";
std::fs::write(&file, original).unwrap();
let member = make_enum_member(&file, "Status", "Active", 2);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
true,
&mut fixes,
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0]["type"], "remove_enum_member");
assert!(fixes[0].get("applied").is_none());
}
#[test]
fn enum_fix_line_zero_saturating_sub() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("status.ts");
std::fs::write(&file, "enum Status { Active }\n").unwrap();
let member = make_enum_member(&file, "Status", "Active", 0);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "enum Status {}\n");
}
#[test]
fn enum_fix_const_enum() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let file = root.join("direction.ts");
std::fs::write(
&file,
"const enum Direction {\n Up,\n Down,\n Left,\n Right,\n}\n",
)
.unwrap();
let member = make_enum_member(&file, "Direction", "Left", 4);
let mut members_by_file: FxHashMap<PathBuf, Vec<&UnusedMember>> = FxHashMap::default();
members_by_file.insert(file.clone(), vec![&member]);
let mut fixes = Vec::new();
apply_enum_member_fixes(
root,
&members_by_file,
OutputFormat::Human,
false,
&mut fixes,
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(
content,
"const enum Direction {\n Up,\n Down,\n Right,\n}\n"
);
}
#[test]
fn single_line_remove_member_preserves_export_keyword() {
let result =
remove_member_from_single_line("export enum Status { Active, Inactive }", "Active");
assert_eq!(result, "export enum Status { Inactive }");
}
}