use std::path::Path;
use fallow_config::{CatalogPrecedingCommentPolicy, OutputFormat};
use fallow_core::results::{
EmptyCatalogGroup, EmptyCatalogGroupFinding, UnusedCatalogEntry, UnusedCatalogEntryFinding,
};
use super::plan::{CapturedHashes, FixPlan, read_source_with_hash_check};
#[expect(
clippy::too_many_arguments,
reason = "fix-layer signatures match the orchestrator's call shape: root + entries + policy + (hashes, plan) for issue #454 batch atomicity + output/dry_run/fixes for the per-fixer wire"
)]
pub(super) fn apply_catalog_entry_fixes(
root: &Path,
entries: &[UnusedCatalogEntryFinding],
preceding_comment_policy: CatalogPrecedingCommentPolicy,
hashes: &CapturedHashes,
plan: &mut FixPlan,
output: OutputFormat,
dry_run: bool,
fixes: &mut Vec<serde_json::Value>,
) -> CatalogFixSummary {
let mut summary = CatalogFixSummary::default();
if entries.is_empty() {
return summary;
}
let mut by_path: rustc_hash::FxHashMap<&Path, Vec<&UnusedCatalogEntry>> =
rustc_hash::FxHashMap::default();
for entry in entries {
let entry = &entry.entry;
by_path.entry(entry.path.as_path()).or_default().push(entry);
}
for (relative_path, file_entries) in by_path {
let absolute = root.join(relative_path);
let Some((content, meta)) = read_source_with_hash_check(root, &absolute, hashes, plan)
else {
continue;
};
if is_multi_document_yaml(&content) {
for entry in &file_entries {
summary.skipped += 1;
fixes.push(skip_record(
entry,
"multi_document_yaml",
"Skipped: pnpm-workspace.yaml contains a `---` document separator; fallow fix does not support multi-document YAML",
output,
relative_path,
));
}
continue;
}
let lines: Vec<&str> = content.split(meta.line_ending).collect();
let mut to_remove: Vec<(std::ops::Range<usize>, &UnusedCatalogEntry)> = Vec::new();
for entry in &file_entries {
if !entry.hardcoded_consumers.is_empty() {
summary.skipped += 1;
let consumer_summary = format_consumer_summary(&entry.hardcoded_consumers);
let description = format!(
"Skipped: {consumer_summary} still pin `{}` with a hardcoded version. Switch the consumer(s) to \"{}\": \"catalog:{}\" first, then rerun fallow fix.",
entry.entry_name,
entry.entry_name,
if entry.catalog_name == "default" {
String::new()
} else {
entry.catalog_name.clone()
},
);
fixes.push(skip_record(
entry,
"hardcoded_consumers",
&description,
output,
relative_path,
));
continue;
}
let line_idx = entry.line.saturating_sub(1) as usize;
if line_idx >= lines.len() {
summary.skipped += 1;
fixes.push(skip_record(
entry,
"line_out_of_range",
"Skipped: the reported line is past the end of pnpm-workspace.yaml; the file may have been edited since fallow check ran",
output,
relative_path,
));
continue;
}
let range = compute_deletion_range(&lines, line_idx, entry, preceding_comment_policy);
to_remove.push((range, entry));
}
if to_remove.is_empty() {
continue;
}
to_remove.sort_by(|a, b| {
b.0.start
.cmp(&a.0.start)
.then_with(|| b.0.end.cmp(&a.0.end))
});
let mut deduped: Vec<(std::ops::Range<usize>, &UnusedCatalogEntry)> = Vec::new();
for (range, entry) in to_remove {
if let Some((last_range, _)) = deduped.last()
&& last_range.start < range.end
&& range.start < last_range.end
{
continue;
}
deduped.push((range, entry));
}
if dry_run {
for (range, entry) in &deduped {
if !matches!(output, OutputFormat::Json) {
eprintln!(
"Would remove catalog entry from {}:{} `{}` (catalog: {})",
relative_path.display(),
range.start + 1,
entry.entry_name,
entry.catalog_name,
);
}
fixes.push(remove_record(entry, range, false, relative_path));
}
summary.applied += deduped.len();
continue;
}
let parent_header_indices: Vec<usize> = deduped
.iter()
.filter_map(|(_, entry)| find_parent_header_line(&lines, entry))
.collect();
let mut new_lines: Vec<String> = lines.iter().map(ToString::to_string).collect();
for (range, _) in &deduped {
new_lines.drain(range.clone());
}
rewrite_empty_catalog_parents(&mut new_lines, &parent_header_indices, &deduped);
let mut new_content = new_lines.join(meta.line_ending);
if content.ends_with(meta.line_ending) && !new_content.ends_with(meta.line_ending) {
new_content.push_str(meta.line_ending);
}
if serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&new_content).is_err() {
summary.write_error = true;
eprintln!(
"Error: refusing to write {}: post-edit content failed YAML reparse. The file was not modified.",
relative_path.display(),
);
continue;
}
plan.stage(
absolute.clone(),
super::io::bytes_with_optional_bom(new_content, &meta),
);
for (range, entry) in &deduped {
let mut record = remove_record(entry, range, true, relative_path);
record["__target"] = serde_json::json!(absolute.display().to_string());
fixes.push(record);
let entry_idx = entry.line.saturating_sub(1) as usize;
summary.comment_lines_removed += entry_idx.saturating_sub(range.start);
}
summary.applied += deduped.len();
}
summary
}
pub(super) fn apply_empty_catalog_group_fixes(
root: &Path,
groups: &[EmptyCatalogGroupFinding],
hashes: &CapturedHashes,
plan: &mut FixPlan,
output: OutputFormat,
dry_run: bool,
fixes: &mut Vec<serde_json::Value>,
) -> CatalogFixSummary {
let mut summary = CatalogFixSummary::default();
if groups.is_empty() {
return summary;
}
let mut by_path: rustc_hash::FxHashMap<&Path, Vec<&EmptyCatalogGroup>> =
rustc_hash::FxHashMap::default();
for group in groups {
let group = &group.group;
by_path.entry(group.path.as_path()).or_default().push(group);
}
for (relative_path, file_groups) in by_path {
let absolute = root.join(relative_path);
let Some((content, meta)) = read_source_with_hash_check(root, &absolute, hashes, plan)
else {
continue;
};
if is_multi_document_yaml(&content) {
for group in &file_groups {
summary.skipped += 1;
fixes.push(skip_group_record(
group,
"multi_document_yaml",
"Skipped: pnpm-workspace.yaml contains a `---` document separator; fallow fix does not support multi-document YAML",
output,
relative_path,
));
}
continue;
}
let lines: Vec<&str> = content.split(meta.line_ending).collect();
let mut to_remove: Vec<(usize, &EmptyCatalogGroup)> = Vec::new();
for group in &file_groups {
let line_idx = group.line.saturating_sub(1) as usize;
if line_idx >= lines.len() {
summary.skipped += 1;
fixes.push(skip_group_record(
group,
"line_out_of_range",
"Skipped: the reported line is past the end of pnpm-workspace.yaml; the file may have been edited since fallow check ran",
output,
relative_path,
));
continue;
}
to_remove.push((line_idx, group));
}
if to_remove.is_empty() {
continue;
}
to_remove.sort_by_key(|(line_idx, _)| std::cmp::Reverse(*line_idx));
to_remove.dedup_by_key(|(line_idx, _)| *line_idx);
if dry_run {
for (line_idx, group) in &to_remove {
if !matches!(output, OutputFormat::Json) {
eprintln!(
"Would remove empty catalog group from {}:{} `{}`",
relative_path.display(),
line_idx + 1,
group.catalog_name,
);
}
fixes.push(remove_group_record(group, *line_idx, false, relative_path));
}
summary.applied += to_remove.len();
continue;
}
let mut new_lines: Vec<String> = lines.iter().map(ToString::to_string).collect();
for (line_idx, _) in &to_remove {
new_lines.remove(*line_idx);
}
let mut new_content = new_lines.join(meta.line_ending);
if content.ends_with(meta.line_ending) && !new_content.ends_with(meta.line_ending) {
new_content.push_str(meta.line_ending);
}
if serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&new_content).is_err() {
summary.write_error = true;
eprintln!(
"Error: refusing to write {}: post-edit content failed YAML reparse. The file was not modified.",
relative_path.display(),
);
continue;
}
plan.stage(
absolute.clone(),
super::io::bytes_with_optional_bom(new_content, &meta),
);
for (line_idx, group) in &to_remove {
let mut record = remove_group_record(group, *line_idx, true, relative_path);
record["__target"] = serde_json::json!(absolute.display().to_string());
fixes.push(record);
}
summary.applied += to_remove.len();
}
summary
}
#[derive(Debug, Default)]
pub(super) struct CatalogFixSummary {
pub applied: usize,
pub skipped: usize,
pub write_error: bool,
pub comment_lines_removed: usize,
}
fn compute_deletion_range(
lines: &[&str],
entry_idx: usize,
entry: &UnusedCatalogEntry,
preceding_comment_policy: CatalogPrecedingCommentPolicy,
) -> std::ops::Range<usize> {
let start_idx =
comment_block_start(lines, entry_idx, entry, preceding_comment_policy).unwrap_or(entry_idx);
let entry_indent = leading_spaces(lines[entry_idx]);
let mut end_idx = entry_idx + 1;
while end_idx < lines.len() {
let line = lines[end_idx];
if line.trim().is_empty() {
break;
}
if leading_spaces(line) <= entry_indent {
break;
}
end_idx += 1;
}
start_idx..end_idx
}
fn comment_block_start(
lines: &[&str],
entry_idx: usize,
entry: &UnusedCatalogEntry,
policy: CatalogPrecedingCommentPolicy,
) -> Option<usize> {
if matches!(policy, CatalogPrecedingCommentPolicy::Never) || entry_idx == 0 {
return None;
}
let entry_indent = leading_spaces(lines[entry_idx]);
let mut comment_start = entry_idx;
while comment_start > 0 && is_entry_comment(lines[comment_start - 1], entry_indent) {
comment_start -= 1;
}
if comment_start == entry_idx {
return None;
}
let block = &lines[comment_start..entry_idx];
if block.iter().any(|line| line.contains("fallow-keep")) {
return None;
}
match policy {
CatalogPrecedingCommentPolicy::Always => Some(comment_start),
CatalogPrecedingCommentPolicy::Never => None,
CatalogPrecedingCommentPolicy::Auto => {
if block.iter().any(|line| is_section_banner_line(line)) {
return None;
}
let before_comment = comment_start.checked_sub(1)?;
if lines[before_comment].trim().is_empty()
|| find_parent_header_line(lines, entry) == Some(before_comment)
{
Some(comment_start)
} else {
None
}
}
}
}
fn is_entry_comment(line: &str, entry_indent: usize) -> bool {
leading_spaces(line) == entry_indent && line.trim_start().starts_with('#')
}
fn is_section_banner_line(line: &str) -> bool {
let Some(after_hash) = line.trim_start().strip_prefix('#') else {
return false;
};
let body = after_hash.trim_start();
let Some(first) = body.chars().next() else {
return false;
};
if !matches!(first, '=' | '-' | '*' | '_' | '~' | '+' | '#') {
return false;
}
body.chars().take(3).all(|c| c == first)
}
fn leading_spaces(line: &str) -> usize {
line.bytes().take_while(|&b| b == b' ').count()
}
fn is_multi_document_yaml(content: &str) -> bool {
content
.lines()
.any(|line| line.trim_end() == "---" || line.trim_end().starts_with("--- "))
}
fn find_parent_header_line(lines: &[&str], entry: &UnusedCatalogEntry) -> Option<usize> {
let entry_line_idx = entry.line.saturating_sub(1) as usize;
if entry_line_idx >= lines.len() {
return None;
}
let entry_indent = leading_spaces(lines[entry_line_idx]);
for idx in (0..entry_line_idx).rev() {
let line = lines[idx];
let stripped = line.trim_end();
let content = stripped.trim_start();
if content.is_empty() || content.starts_with('#') {
continue;
}
let indent = leading_spaces(stripped);
if indent >= entry_indent {
continue;
}
if entry.catalog_name == "default" {
return content.starts_with("catalog:").then_some(idx);
}
let key = content
.trim_start_matches(['"', '\''])
.split([':', '"', '\''])
.next()
.unwrap_or("");
return (key == entry.catalog_name).then_some(idx);
}
None
}
fn rewrite_empty_catalog_parents(
new_lines: &mut [String],
parent_indices: &[usize],
deleted_ranges: &[(std::ops::Range<usize>, &UnusedCatalogEntry)],
) {
let mut unique_parents: Vec<usize> = parent_indices.to_vec();
unique_parents.sort_unstable();
unique_parents.dedup();
for parent_pre_idx in unique_parents {
let deleted_before: usize = deleted_ranges
.iter()
.map(|(range, _)| {
if range.end <= parent_pre_idx {
range.end - range.start
} else if range.start <= parent_pre_idx {
0
} else {
0
}
})
.sum();
let new_idx = parent_pre_idx.saturating_sub(deleted_before);
if new_idx >= new_lines.len() {
continue;
}
if has_remaining_children(new_lines, new_idx) {
continue;
}
let original = new_lines[new_idx].clone();
let trimmed_end = original.trim_end();
let trailing = &original[trimmed_end.len()..];
new_lines[new_idx] = format!("{trimmed_end} {{}}{trailing}");
}
}
fn has_remaining_children(lines: &[String], parent_idx: usize) -> bool {
let parent_indent = leading_spaces(&lines[parent_idx]);
for line in lines.iter().skip(parent_idx + 1) {
let stripped = line.trim_end();
let content = stripped.trim_start();
if content.is_empty() || content.starts_with('#') {
continue;
}
let indent = leading_spaces(stripped);
return indent > parent_indent;
}
false
}
fn skip_record(
entry: &UnusedCatalogEntry,
skip_reason: &str,
description: &str,
output: OutputFormat,
relative_path: &Path,
) -> serde_json::Value {
if !matches!(output, OutputFormat::Json) {
eprintln!(
"Skipped catalog entry {}:{} `{}` ({skip_reason})",
relative_path.display(),
entry.line,
entry.entry_name,
);
}
let consumers: Option<serde_json::Value> =
if skip_reason == "hardcoded_consumers" && !entry.hardcoded_consumers.is_empty() {
Some(serde_json::Value::Array(
entry
.hardcoded_consumers
.iter()
.map(|p| {
serde_json::Value::String(p.to_string_lossy().replace('\\', "/"))
})
.collect(),
))
} else {
None
};
let mut value = serde_json::json!({
"type": "remove_catalog_entry",
"entry_name": entry.entry_name,
"catalog_name": entry.catalog_name,
"file": relative_path.to_string_lossy().replace('\\', "/"),
"line": entry.line,
"applied": false,
"skipped": true,
"skip_reason": skip_reason,
"description": description,
});
if let Some(consumers) = consumers
&& let serde_json::Value::Object(map) = &mut value
{
map.insert("consumers".to_string(), consumers);
}
value
}
fn remove_record(
entry: &UnusedCatalogEntry,
range: &std::ops::Range<usize>,
applied: bool,
relative_path: &Path,
) -> serde_json::Value {
let removed_lines = range.end - range.start;
let mut value = serde_json::json!({
"type": "remove_catalog_entry",
"entry_name": entry.entry_name,
"catalog_name": entry.catalog_name,
"file": relative_path.to_string_lossy().replace('\\', "/"),
"line": range.start + 1,
"entry_line": entry.line,
"removed_lines": removed_lines,
});
if applied && let serde_json::Value::Object(map) = &mut value {
map.insert("applied".to_string(), serde_json::Value::Bool(true));
}
value
}
fn skip_group_record(
group: &EmptyCatalogGroup,
skip_reason: &str,
description: &str,
output: OutputFormat,
relative_path: &Path,
) -> serde_json::Value {
if !matches!(output, OutputFormat::Json) {
eprintln!(
"Skipped empty catalog group {}:{} `{}` ({skip_reason})",
relative_path.display(),
group.line,
group.catalog_name,
);
}
serde_json::json!({
"type": "remove_empty_catalog_group",
"catalog_name": group.catalog_name,
"file": relative_path.to_string_lossy().replace('\\', "/"),
"line": group.line,
"applied": false,
"skipped": true,
"skip_reason": skip_reason,
"description": description,
})
}
fn remove_group_record(
group: &EmptyCatalogGroup,
line_idx: usize,
applied: bool,
relative_path: &Path,
) -> serde_json::Value {
let mut value = serde_json::json!({
"type": "remove_empty_catalog_group",
"catalog_name": group.catalog_name,
"file": relative_path.to_string_lossy().replace('\\', "/"),
"line": line_idx + 1,
"removed_lines": 1,
});
if applied && let serde_json::Value::Object(map) = &mut value {
map.insert("applied".to_string(), serde_json::Value::Bool(true));
}
value
}
fn format_consumer_summary(consumers: &[std::path::PathBuf]) -> String {
match consumers.len() {
0 => String::new(),
1 => format!("`{}`", consumers[0].display()),
2 => format!(
"`{}` and `{}`",
consumers[0].display(),
consumers[1].display()
),
_ => format!(
"`{}` and {} other consumer(s)",
consumers[0].display(),
consumers.len() - 1,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_entry(name: &str, catalog: &str, line: u32) -> UnusedCatalogEntryFinding {
UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
entry_name: name.to_string(),
catalog_name: catalog.to_string(),
path: PathBuf::from("pnpm-workspace.yaml"),
line,
hardcoded_consumers: vec![],
})
}
fn make_entry_with_consumers(
name: &str,
catalog: &str,
line: u32,
consumers: Vec<PathBuf>,
) -> UnusedCatalogEntryFinding {
UnusedCatalogEntryFinding::with_actions(UnusedCatalogEntry {
entry_name: name.to_string(),
catalog_name: catalog.to_string(),
path: PathBuf::from("pnpm-workspace.yaml"),
line,
hardcoded_consumers: consumers,
})
}
fn make_group(name: &str, line: u32) -> EmptyCatalogGroupFinding {
EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
catalog_name: name.to_string(),
path: PathBuf::from("pnpm-workspace.yaml"),
line,
})
}
fn seed_workspace_file(root: &Path, content: &str) {
let path = root.join("pnpm-workspace.yaml");
std::fs::write(&path, content).unwrap();
}
fn run_catalog_entry_fix(
root: &Path,
entries: &[UnusedCatalogEntryFinding],
policy: CatalogPrecedingCommentPolicy,
output: OutputFormat,
dry_run: bool,
fixes: &mut Vec<serde_json::Value>,
) -> CatalogFixSummary {
let mut plan = FixPlan::new();
let hashes = CapturedHashes::default();
let mut summary = apply_catalog_entry_fixes(
root, entries, policy, &hashes, &mut plan, output, dry_run, fixes,
);
if !dry_run && !plan.commit().failed.is_empty() {
summary.write_error = true;
}
summary
}
fn run_empty_catalog_group_fix(
root: &Path,
groups: &[EmptyCatalogGroupFinding],
output: OutputFormat,
dry_run: bool,
fixes: &mut Vec<serde_json::Value>,
) -> CatalogFixSummary {
let mut plan = FixPlan::new();
let hashes = CapturedHashes::default();
let mut summary = apply_empty_catalog_group_fixes(
root, groups, &hashes, &mut plan, output, dry_run, fixes,
);
if !dry_run && !plan.commit().failed.is_empty() {
summary.write_error = true;
}
summary
}
#[test]
fn removes_empty_named_catalog_group_header_only() {
let dir = tempfile::tempdir().unwrap();
let content = "catalogs:\n react17: {}\n # keep this note\n vue3:\n vue: ^3.4.0\n";
seed_workspace_file(dir.path(), content);
let groups = vec![make_group("react17", 2)];
let mut fixes = Vec::new();
let summary =
run_empty_catalog_group_fix(dir.path(), &groups, OutputFormat::Json, false, &mut fixes);
assert!(!summary.write_error);
assert_eq!(summary.applied, 1);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(
result,
"catalogs:\n # keep this note\n vue3:\n vue: ^3.4.0\n"
);
assert_eq!(fixes[0]["type"], "remove_empty_catalog_group");
assert_eq!(fixes[0]["applied"], true);
}
#[test]
fn removes_scalar_form_entry() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n is-even: ^1.0.0\n left-pad: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 1);
assert_eq!(summary.skipped, 0);
assert!(!summary.write_error);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n left-pad: ^1.0.0\n");
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0]["applied"], serde_json::json!(true));
assert_eq!(fixes[0]["removed_lines"], serde_json::json!(1));
}
#[test]
fn removes_object_form_entry_with_nested_keys() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n react:\n specifier: ^18.2.0\n publishConfig:\n access: public\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("react", "default", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 1);
assert_eq!(summary.skipped, 0);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n is-even: ^1.0.0\n");
assert_eq!(fixes[0]["removed_lines"], serde_json::json!(4));
}
#[test]
fn skips_entries_with_hardcoded_consumers() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry_with_consumers(
"is-even",
"default",
2,
vec![PathBuf::from("apps/web/package.json")],
)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 0);
assert_eq!(summary.skipped, 1);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, content, "file must not be modified when skipping");
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0]["skipped"], serde_json::json!(true));
assert_eq!(
fixes[0]["skip_reason"],
serde_json::json!("hardcoded_consumers")
);
assert!(
fixes[0]["description"]
.as_str()
.unwrap()
.contains("apps/web/package.json")
);
assert!(fixes[0]["consumers"].is_array());
}
#[test]
fn dry_run_does_not_modify_file() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 2)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
true,
&mut fixes,
);
assert_eq!(summary.applied, 1);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, content);
assert_eq!(fixes[0].get("applied"), None);
}
#[test]
fn removes_named_catalog_entry() {
let dir = tempfile::tempdir().unwrap();
let content = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("react", "react17", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 1);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalogs:\n react17:\n react-dom: ^17.0.2\n");
}
#[test]
fn preserves_trailing_inline_comment_on_sibling() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0 # keep me\n is-even: ^1.0.0 # remove me\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0 # keep me\n");
}
#[test]
fn auto_deletes_leading_comment_after_parent_header() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n # mention is-even\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n");
assert_eq!(fixes[0]["line"], serde_json::json!(2));
assert_eq!(fixes[0]["entry_line"], serde_json::json!(3));
assert_eq!(fixes[0]["removed_lines"], serde_json::json!(2));
assert_eq!(summary.comment_lines_removed, 1);
}
#[test]
fn auto_preserves_block_with_fallow_keep_marker() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n # fallow-keep: audit trail for CVE-2024-XXXX\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(
result,
"catalog:\n # fallow-keep: audit trail for CVE-2024-XXXX\n is-odd: ^1.0.0\n"
);
assert_eq!(summary.comment_lines_removed, 0);
}
#[test]
fn always_preserves_block_with_fallow_keep_marker() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n # fallow-keep\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Always,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n # fallow-keep\n is-odd: ^1.0.0\n");
}
#[test]
fn auto_preserves_section_banner_block() {
let dir = tempfile::tempdir().unwrap();
let content =
"catalog:\n # === React 18 production pins ===\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(
result,
"catalog:\n # === React 18 production pins ===\n is-odd: ^1.0.0\n"
);
assert_eq!(summary.comment_lines_removed, 0);
}
#[test]
fn always_deletes_section_banner_block() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n # ====\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Always,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n");
}
#[test]
fn section_banner_detector_recognizes_separator_runs() {
assert!(is_section_banner_line("# === banner ==="));
assert!(is_section_banner_line(" # ----"));
assert!(is_section_banner_line("# ***"));
assert!(is_section_banner_line("# ___"));
assert!(is_section_banner_line("#==="));
assert!(!is_section_banner_line("# mention is-even"));
assert!(!is_section_banner_line("# = single sep"));
assert!(!is_section_banner_line("# -- two seps only"));
assert!(!is_section_banner_line("not a comment"));
}
#[test]
fn auto_deletes_leading_comment_after_blank_separator() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n\n # mention is-even\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 5)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n\n");
}
#[test]
fn auto_preserves_leading_comment_after_sibling_entry() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n # shared note\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 4)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n # shared note\n");
}
#[test]
fn auto_deletes_named_catalog_leading_comment_after_named_header() {
let dir = tempfile::tempdir().unwrap();
let content = "catalogs:\n react17:\n # pinned for old peer deps\n react: ^17.0.2\n react-dom: ^17.0.2\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("react", "react17", 4)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalogs:\n react17:\n react-dom: ^17.0.2\n");
}
#[test]
fn always_deletes_leading_comment_after_sibling_entry() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n # force remove\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 4)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Always,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n");
}
#[test]
fn never_preserves_leading_comment_after_parent_header() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n # keep always\n is-even: ^1.0.0\n is-odd: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Never,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n # keep always\n is-odd: ^1.0.0\n");
}
#[test]
fn removes_multiple_adjacent_entries_in_one_pass() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n is-even: ^1.0.0\n left-pad: ^1.0.0\n right-pad: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![
make_entry("is-even", "default", 3),
make_entry("left-pad", "default", 4),
];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 2);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n right-pad: ^1.0.0\n");
}
#[test]
fn rejects_multi_document_yaml() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-even: ^1.0.0\n---\nfoo: bar\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 2)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 0);
assert_eq!(summary.skipped, 1);
assert_eq!(
fixes[0]["skip_reason"],
serde_json::json!("multi_document_yaml")
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, content);
}
#[test]
fn skips_when_line_out_of_range() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 99)];
let mut fixes = Vec::new();
let summary = run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
assert_eq!(summary.applied, 0);
assert_eq!(summary.skipped, 1);
assert_eq!(
fixes[0]["skip_reason"],
serde_json::json!("line_out_of_range")
);
}
#[test]
fn preserves_crlf_line_endings() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\r\n is-odd: ^1.0.0\r\n is-even: ^1.0.0\r\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\r\n is-odd: ^1.0.0\r\n");
}
#[test]
fn rewrites_emptied_default_catalog_to_empty_map() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 2)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog: {}\n");
let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&result).unwrap();
assert!(
parsed
.get("catalog")
.and_then(serde_yaml_ng::Value::as_mapping)
.is_some_and(serde_yaml_ng::Mapping::is_empty),
"catalog must be `{{}}`, not null"
);
}
#[test]
fn rewrites_emptied_named_catalog_to_empty_map() {
let dir = tempfile::tempdir().unwrap();
let content = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n legacy:\n is-odd: ^3.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![
make_entry("react", "react17", 3),
make_entry("react-dom", "react17", 4),
];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(
result,
"catalogs:\n react17: {}\n legacy:\n is-odd: ^3.0.0\n",
);
let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&result).unwrap();
let react17 = parsed.get("catalogs").and_then(|c| c.get("react17"));
assert!(
react17
.and_then(serde_yaml_ng::Value::as_mapping)
.is_some_and(serde_yaml_ng::Mapping::is_empty),
"react17 must be `{{}}`, not null. Got: {react17:?}"
);
}
#[test]
fn preserves_non_empty_sibling_named_catalogs() {
let dir = tempfile::tempdir().unwrap();
let content = "catalogs:\n react17:\n react: ^17.0.2\n vue3:\n vue: ^3.4.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("react", "react17", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(
result,
"catalogs:\n react17: {}\n vue3:\n vue: ^3.4.0\n"
);
}
#[test]
fn leaves_partially_populated_catalog_alone() {
let dir = tempfile::tempdir().unwrap();
let content = "catalog:\n is-odd: ^1.0.0\n is-even: ^1.0.0\n";
seed_workspace_file(dir.path(), content);
let entries = vec![make_entry("is-even", "default", 3)];
let mut fixes = Vec::new();
run_catalog_entry_fix(
dir.path(),
&entries,
CatalogPrecedingCommentPolicy::Auto,
OutputFormat::Json,
false,
&mut fixes,
);
let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap();
assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n");
}
#[test]
fn deletion_range_scalar_form_spans_one_line() {
let lines: Vec<&str> = "catalog:\n is-even: ^1.0.0\n is-odd: ^1.0.0\n"
.split('\n')
.collect();
let entry = make_entry("is-even", "default", 2).entry;
let range = compute_deletion_range(&lines, 1, &entry, CatalogPrecedingCommentPolicy::Auto);
assert_eq!(range, 1..2);
}
#[test]
fn deletion_range_object_form_spans_until_indent_drops() {
let content = "catalog:\n react:\n specifier: ^18.2.0\n publishConfig: {}\n is-even: ^1.0.0\n";
let lines: Vec<&str> = content.split('\n').collect();
let entry = make_entry("react", "default", 2).entry;
let range = compute_deletion_range(&lines, 1, &entry, CatalogPrecedingCommentPolicy::Auto);
assert_eq!(range, 1..4);
}
#[test]
fn deletion_range_stops_at_blank_line() {
let content = "catalog:\n is-even: ^1.0.0\n\n is-odd: ^1.0.0\n";
let lines: Vec<&str> = content.split('\n').collect();
let entry = make_entry("is-even", "default", 2).entry;
let range = compute_deletion_range(&lines, 1, &entry, CatalogPrecedingCommentPolicy::Auto);
assert_eq!(range, 1..2);
}
#[test]
fn is_multi_document_detects_separator() {
assert!(is_multi_document_yaml("foo: bar\n---\nbaz: qux\n"));
assert!(is_multi_document_yaml("---\nfoo: bar\n"));
assert!(!is_multi_document_yaml("catalog:\n is-even: ^1.0.0\n"));
assert!(!is_multi_document_yaml("catalog:\n foo: \"---\"\n"));
}
}