fn heading_levels(doc: &str) -> Vec<usize> {
let mut levels = Vec::new();
let mut in_code_block = false;
for line in doc.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block || !line.starts_with('#') {
continue;
}
let level = line.chars().take_while(|&c| c == '#').count();
if (1..=6).contains(&level) {
levels.push(level);
}
}
levels
}
#[cfg(test)]
pub(crate) fn check_monotonic_headings(doc: &str) -> Result<(), String> {
let mut previous_level: Option<usize> = None;
let mut in_code_block = false;
for line in doc.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block || !line.starts_with('#') {
continue;
}
let heading_level = line.chars().take_while(|&c| c == '#').count();
if heading_level == 0 || heading_level > 6 {
continue;
}
if let Some(prev) = previous_level {
let increment = heading_level.saturating_sub(prev);
if increment > 1 {
let heading_text = line.trim_start_matches('#').trim();
return Err(format!(
"Heading increment violation: H{} → H{} (skip of {})\nHeading: {}",
prev, heading_level, increment, heading_text
));
}
}
previous_level = Some(heading_level);
}
Ok(())
}
fn rewrite_heading_levels(doc: &str, remap: impl Fn(usize) -> usize) -> String {
let mut out = String::with_capacity(doc.len());
let mut in_code_block = false;
for line in doc.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
out.push_str(line);
out.push('\n');
continue;
}
if in_code_block || !line.starts_with('#') {
out.push_str(line);
out.push('\n');
continue;
}
let level = line.chars().take_while(|&c| c == '#').count();
if (1..=6).contains(&level) {
out.push_str(&"#".repeat(remap(level)));
out.push_str(&line[level..]);
} else {
out.push_str(line);
}
out.push('\n');
}
out.trim_end().to_string()
}
pub(crate) fn demote_headings_to_start_at(doc: &str, target_level: usize) -> String {
let target_level = target_level.clamp(1, 6);
let mut distinct_levels = heading_levels(doc);
distinct_levels.sort_unstable();
distinct_levels.dedup();
let Some(&shallowest) = distinct_levels.first() else {
return doc.to_string();
};
if shallowest >= target_level {
return doc.to_string();
}
rewrite_heading_levels(doc, |level| {
let rank = distinct_levels
.iter()
.position(|&candidate| candidate == level)
.unwrap_or(0);
std::cmp::min(target_level + rank, 6)
})
}