use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::types::HeadingLevel;
use crate::utils::range_utils::calculate_match_range;
use crate::utils::thematic_break;
use toml;
mod md025_config;
use md025_config::MD025Config;
#[derive(Clone, Default)]
pub struct MD025SingleTitle {
config: MD025Config,
}
impl MD025SingleTitle {
pub fn new(level: usize, front_matter_title: &str) -> Self {
Self {
config: MD025Config {
level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
front_matter_title: front_matter_title.to_string(),
allow_document_sections: true,
allow_with_separators: true,
},
}
}
pub fn strict() -> Self {
Self {
config: MD025Config {
level: HeadingLevel::new(1).unwrap(),
front_matter_title: "title".to_string(),
allow_document_sections: false,
allow_with_separators: false,
},
}
}
pub fn from_config_struct(config: MD025Config) -> Self {
Self { config }
}
fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
if self.config.front_matter_title.is_empty() {
return false;
}
let content_lines = ctx.raw_lines();
if content_lines.first().map(|l| l.trim()) != Some("---") {
return false;
}
for (idx, line) in content_lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
let front_matter_content = content_lines[1..idx].join("\n");
return front_matter_content
.lines()
.any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
}
}
false
}
fn is_document_section_heading(&self, heading_text: &str) -> bool {
if !self.config.allow_document_sections {
return false;
}
let lower_text = heading_text.to_lowercase();
let section_indicators = [
"appendix",
"appendices",
"reference",
"references",
"bibliography",
"index",
"indices",
"glossary",
"glossaries",
"conclusion",
"conclusions",
"summary",
"executive summary",
"acknowledgment",
"acknowledgments",
"acknowledgement",
"acknowledgements",
"about",
"contact",
"license",
"legal",
"changelog",
"change log",
"history",
"faq",
"frequently asked questions",
"troubleshooting",
"support",
"installation",
"setup",
"getting started",
"api reference",
"api documentation",
"examples",
"tutorials",
"guides",
];
let words: Vec<&str> = lower_text.split_whitespace().collect();
section_indicators.iter().any(|&indicator| {
let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
let starts_with_indicator = if indicator_words.len() == 1 {
words.first() == Some(&indicator)
} else {
words.len() >= indicator_words.len()
&& words[..indicator_words.len()] == indicator_words[..]
};
starts_with_indicator ||
lower_text.starts_with(&format!("{indicator}:")) ||
words.contains(&indicator) ||
(indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
(indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
})
})
}
fn is_horizontal_rule(line: &str) -> bool {
thematic_break::is_thematic_break(line)
}
fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
if line_num == 0 || line_num >= ctx.lines.len() {
return false;
}
let line = ctx.lines[line_num].content(ctx.content).trim();
let prev_line = if line_num > 0 {
ctx.lines[line_num - 1].content(ctx.content).trim()
} else {
""
};
let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
(is_dash_line || is_equals_line) && prev_line_has_content
}
fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
if !self.config.allow_with_separators || heading_line == 0 {
return false;
}
let search_start = heading_line.saturating_sub(5);
for line_num in search_start..heading_line {
if line_num >= ctx.lines.len() {
continue;
}
let line = &ctx.lines[line_num].content(ctx.content);
if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
let has_intermediate_heading =
((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
if !has_intermediate_heading {
return true;
}
}
}
false
}
}
impl Rule for MD025SingleTitle {
fn name(&self) -> &'static str {
"MD025"
}
fn description(&self) -> &'static str {
"Multiple top-level headings in the same document"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if ctx.lines.is_empty() {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
let found_title_in_front_matter = self.has_front_matter_title(ctx);
let mut target_level_headings = Vec::new();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading
&& heading.level as usize == self.config.level.as_usize()
&& heading.is_valid
{
if line_info.visual_indent >= 4 || line_info.in_code_block {
continue;
}
target_level_headings.push(line_num);
}
}
let headings_to_flag: &[usize] = if found_title_in_front_matter {
&target_level_headings
} else if target_level_headings.len() > 1 {
&target_level_headings[1..]
} else {
&[]
};
if !headings_to_flag.is_empty() {
for &line_num in headings_to_flag {
if let Some(heading) = &ctx.lines[line_num].heading {
let heading_text = &heading.text;
let should_allow = self.is_document_section_heading(heading_text)
|| self.has_separator_before_heading(ctx, line_num);
if should_allow {
continue; }
let line_content = &ctx.lines[line_num].content(ctx.content);
let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
pos
} else {
if line_content.trim_start().starts_with('#') {
let trimmed = line_content.trim_start();
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
let after_hashes = &trimmed[hash_count..];
let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
(line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
} else {
0 }
};
let (start_line, start_col, end_line, end_col) = calculate_match_range(
line_num + 1, line_content,
text_start_in_line,
heading_text.len(),
);
let is_setext = matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
);
let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
let text_range = ctx.line_index.line_content_range(line_num + 1);
let underline_range = ctx.line_index.line_content_range(line_num + 2);
text_range.start..underline_range.end
} else {
ctx.line_index.line_content_range(line_num + 1)
};
let demoted_level = self.config.level.as_usize() + 1;
let fix = if demoted_level > 6 {
None
} else {
let leading_spaces = line_content.len() - line_content.trim_start().len();
let indentation = " ".repeat(leading_spaces);
let raw = &heading.raw_text;
let hashes = "#".repeat(demoted_level);
let closing = if heading.has_closing_sequence {
format!(" {}", "#".repeat(demoted_level))
} else {
String::new()
};
let replacement = if raw.is_empty() {
format!("{indentation}{hashes}{closing}")
} else {
format!("{indentation}{hashes} {raw}{closing}")
};
Some(Fix {
range: fix_range,
replacement,
})
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!(
"Multiple top-level headings (level {}) in the same document",
self.config.level.as_usize()
),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Error,
fix,
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
let mut all_warnings = warnings.clone();
let target_level = self.config.level.as_usize();
for warning in &warnings {
let heading_line = warning.line - 1;
let section_end = ctx
.lines
.iter()
.enumerate()
.skip(heading_line + 1)
.find(|(_, li)| {
li.heading.as_ref().is_some_and(|h| {
h.level as usize <= target_level && h.is_valid && !li.in_code_block && li.visual_indent < 4
})
})
.map_or(ctx.lines.len(), |(i, _)| i);
for line_num in (heading_line + 1)..section_end {
let line_info = &ctx.lines[line_num];
let Some(heading) = &line_info.heading else {
continue;
};
if !heading.is_valid || line_info.in_code_block || line_info.visual_indent >= 4 {
continue;
}
let new_level = heading.level as usize + 1;
if new_level > 6 {
continue;
}
let line_content = line_info.content(ctx.content);
let is_setext = matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
);
let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
let text_range = ctx.line_index.line_content_range(line_num + 1);
let underline_range = ctx.line_index.line_content_range(line_num + 2);
text_range.start..underline_range.end
} else {
ctx.line_index.line_content_range(line_num + 1)
};
let leading_spaces = line_content.len() - line_content.trim_start().len();
let indentation = " ".repeat(leading_spaces);
let hashes = "#".repeat(new_level);
let raw = &heading.raw_text;
let closing = if heading.has_closing_sequence {
format!(" {}", "#".repeat(new_level))
} else {
String::new()
};
let replacement = if raw.is_empty() {
format!("{indentation}{hashes}{closing}")
} else {
format!("{indentation}{hashes} {raw}{closing}")
};
all_warnings.push(crate::rule::LintWarning {
rule_name: Some(self.name().to_string()),
message: String::new(),
line: line_num + 1,
column: 1,
end_line: line_num + 1,
end_column: line_content.chars().count(),
severity: crate::rule::Severity::Error,
fix: Some(Fix {
range: fix_range,
replacement,
}),
});
}
}
let all_warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
.map_err(crate::rule::LintError::InvalidInput)
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if ctx.content.is_empty() {
return true;
}
if !ctx.likely_has_headings() {
return true;
}
let has_fm_title = self.has_front_matter_title(ctx);
let mut target_level_count = 0;
for line_info in &ctx.lines {
if let Some(heading) = &line_info.heading
&& heading.level as usize == self.config.level.as_usize()
{
if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
continue;
}
target_level_count += 1;
if has_fm_title {
return false;
}
if target_level_count > 1 {
return false;
}
}
}
target_level_count <= 1
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let json_value = serde_json::to_value(&self.config).ok()?;
Some((
self.name().to_string(),
crate::rule_config_serde::json_to_toml_value(&json_value)?,
))
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_cached_headings() {
let rule = MD025SingleTitle::default();
let content = "# Title\n\n## Section 1\n\n## Section 2";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
assert_eq!(result[0].line, 5);
}
#[test]
fn test_allow_document_sections() {
let config = md025_config::MD025Config {
allow_document_sections: true,
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let valid_cases = vec![
"# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
"# Introduction\n\nContent here\n\n# References\n\nRef content",
"# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
"# Manual\n\nContent\n\n# Index\n\nIndex content",
"# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
"# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
"# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
];
for case in valid_cases {
let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag document sections in: {case}");
}
let invalid_cases = vec![
"# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
"# First\n\nContent\n\n# Second Title\n\nMore content",
];
for case in invalid_cases {
let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
}
}
#[test]
fn test_strict_mode() {
let rule = MD025SingleTitle::strict();
let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
}
#[test]
fn test_bounds_checking_bug() {
let rule = MD025SingleTitle::default();
let content = "# First\n#";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok());
let fix_result = rule.fix(&ctx);
assert!(fix_result.is_ok());
}
#[test]
fn test_bounds_checking_edge_case() {
let rule = MD025SingleTitle::default();
let content = "# First Title\n#";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok());
if let Ok(warnings) = result
&& !warnings.is_empty()
{
let fix_result = rule.fix(&ctx);
assert!(fix_result.is_ok());
if let Ok(fixed_content) = fix_result {
assert!(!fixed_content.is_empty());
assert!(fixed_content.contains("##"));
}
}
}
#[test]
fn test_horizontal_rule_separators() {
let config = md025_config::MD025Config {
allow_with_separators: true,
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag headings separated by horizontal rules"
);
let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading without separator");
assert_eq!(result[0].line, 11);
let strict_rule = MD025SingleTitle::strict();
let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = strict_rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Strict mode should flag all multiple H1s regardless of separators"
);
}
#[test]
fn test_python_comments_in_code_blocks() {
let rule = MD025SingleTitle::default();
let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag Python comments in code blocks as headings"
);
let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("# Python comment"),
"Fix should preserve Python comments in code blocks"
);
assert!(
fixed.contains("## Second Title"),
"Fix should demote the actual second heading"
);
}
#[test]
fn test_fix_preserves_attribute_lists() {
let rule = MD025SingleTitle::strict();
let content = "# First Title\n\n# Second Title { #custom-id .special }";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some());
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("## Second Title { #custom-id .special }"),
"fix() should demote to H2 while preserving attribute list, got: {fixed}"
);
}
#[test]
fn test_frontmatter_title_counts_as_h1() {
let rule = MD025SingleTitle::default();
let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
assert_eq!(result[0].line, 5);
}
#[test]
fn test_frontmatter_title_with_multiple_body_h1s() {
let config = md025_config::MD025Config {
front_matter_title: "title".to_string(),
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
assert_eq!(result[0].line, 5);
assert_eq!(result[1].line, 9);
}
#[test]
fn test_frontmatter_without_title_no_warning() {
let rule = MD025SingleTitle::default();
let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag when frontmatter has no title");
}
#[test]
fn test_no_frontmatter_single_h1_no_warning() {
let rule = MD025SingleTitle::default();
let content = "# Only Heading\n\nSome content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
}
#[test]
fn test_frontmatter_custom_title_key() {
let config = md025_config::MD025Config {
front_matter_title: "heading".to_string(),
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag body H1 when custom frontmatter key matches"
);
assert_eq!(result[0].line, 5);
let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag when frontmatter key doesn't match config"
);
}
#[test]
fn test_frontmatter_title_empty_config_disables() {
let rule = MD025SingleTitle::new(1, "");
let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
}
#[test]
fn test_frontmatter_title_with_level_config() {
let config = md025_config::MD025Config {
level: HeadingLevel::new(2).unwrap(),
front_matter_title: "title".to_string(),
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag body H2 when level=2 and frontmatter has title"
);
assert_eq!(result[0].line, 7);
}
#[test]
fn test_frontmatter_title_fix_demotes_body_heading() {
let config = md025_config::MD025Config {
front_matter_title: "title".to_string(),
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("## Body Heading"),
"Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
);
assert!(fixed.contains("---\ntitle: FM Title\n---"));
}
#[test]
fn test_frontmatter_title_should_skip_respects_frontmatter() {
let rule = MD025SingleTitle::default();
let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!rule.should_skip(&ctx),
"should_skip must return false when frontmatter has title and body has H1"
);
let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.should_skip(&ctx),
"should_skip should return true with no frontmatter title and single H1"
);
}
#[test]
fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
let rule = MD025SingleTitle::default();
let content = "abcd\n\n# 1_1\n\n# 1_2\n\n## 1_2-2_1\n\n# 1_3\n\n## 1_3-2_1\n\n### 1_3-2_1-3_1\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
assert!(
fixed.contains("## 1_2\n"),
"Duplicate H1 must be demoted to H2: {fixed}"
);
assert!(
fixed.contains("### 1_2-2_1"),
"H2 under demoted H1 must cascade to H3: {fixed}"
);
assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
assert!(
fixed.contains("### 1_3-2_1"),
"H2 under third demoted H1 must cascade to H3: {fixed}"
);
assert!(
fixed.contains("#### 1_3-2_1-3_1"),
"H3 under third demoted H1 must cascade to H4: {fixed}"
);
}
#[test]
fn test_fix_cascades_single_section_only() {
let rule = MD025SingleTitle::default();
let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
}
#[test]
fn test_fix_cascade_stops_at_next_same_level() {
let rule = MD025SingleTitle::default();
let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
}
#[test]
fn test_fix_cascade_does_not_exceed_level_6() {
let rule = MD025SingleTitle::default();
let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
}
#[test]
fn test_fix_cascade_respects_inline_disable_on_subordinate() {
let rule = MD025SingleTitle::default();
let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
assert!(
skip_line.starts_with("## Skip"),
"Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
);
assert!(
fixed.contains("### Cascade"),
"Non-disabled subordinate should cascade to level 3: {fixed}"
);
}
#[test]
fn test_section_indicator_whole_word_matching() {
let config = md025_config::MD025Config {
allow_document_sections: true,
..Default::default()
};
let rule = MD025SingleTitle::from_config_struct(config);
let false_positive_cases = vec![
"# Main Title\n\n# Understanding Reindex Operations",
"# Main Title\n\n# The Summarization Pipeline",
"# Main Title\n\n# Data Indexing Strategy",
"# Main Title\n\n# Unsupported Browsers",
];
for case in false_positive_cases {
let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag duplicate H1 (not a section indicator): {case}"
);
}
let true_positive_cases = vec![
"# Main Title\n\n# Index",
"# Main Title\n\n# Summary",
"# Main Title\n\n# About",
"# Main Title\n\n# References",
];
for case in true_positive_cases {
let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should allow section indicator heading: {case}");
}
}
}