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 build_demoted_heading(
heading: &crate::lint_context::types::HeadingInfo,
line_info: &crate::lint_context::types::LineInfo,
content: &str,
delta: usize,
) -> (String, bool) {
let new_level = (heading.level as usize + delta).min(6);
let style = match heading.style {
crate::lint_context::HeadingStyle::ATX => {
if heading.has_closing_sequence {
crate::rules::heading_utils::HeadingStyle::AtxClosed
} else {
crate::rules::heading_utils::HeadingStyle::Atx
}
}
crate::lint_context::HeadingStyle::Setext1 => {
if new_level <= 2 {
if new_level == 1 {
crate::rules::heading_utils::HeadingStyle::Setext1
} else {
crate::rules::heading_utils::HeadingStyle::Setext2
}
} else {
crate::rules::heading_utils::HeadingStyle::Atx
}
}
crate::lint_context::HeadingStyle::Setext2 => {
if new_level <= 2 {
crate::rules::heading_utils::HeadingStyle::Setext2
} else {
crate::rules::heading_utils::HeadingStyle::Atx
}
}
};
let replacement = if heading.text.is_empty() {
match style {
crate::rules::heading_utils::HeadingStyle::Atx
| crate::rules::heading_utils::HeadingStyle::SetextWithAtx => "#".repeat(new_level),
crate::rules::heading_utils::HeadingStyle::AtxClosed
| crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
format!("{} {}", "#".repeat(new_level), "#".repeat(new_level))
}
crate::rules::heading_utils::HeadingStyle::Setext1
| crate::rules::heading_utils::HeadingStyle::Setext2
| crate::rules::heading_utils::HeadingStyle::Consistent => "#".repeat(new_level),
}
} else {
crate::rules::heading_utils::HeadingUtils::convert_heading_style(&heading.raw_text, new_level as u32, style)
};
let line = line_info.content(content);
let original_indent = &line[..line_info.indent];
let result = format!("{original_indent}{replacement}");
let should_skip_next = matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
);
(result, should_skip_next)
}
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 replacement = {
let leading_spaces = line_content.len() - line_content.trim_start().len();
let indentation = " ".repeat(leading_spaces);
let raw = &heading.raw_text;
if raw.is_empty() {
format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
} else {
format!(
"{}{} {}",
indentation,
"#".repeat(self.config.level.as_usize() + 1),
raw
)
}
};
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: Some(Fix {
range: fix_range,
replacement,
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let mut fixed_lines = Vec::new();
let mut found_first = self.has_front_matter_title(ctx);
let mut skip_next = false;
let mut current_delta: usize = 0;
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
if ctx.inline_config().is_rule_disabled(self.name(), line_num + 1) {
fixed_lines.push(line_info.content(ctx.content).to_string());
if let Some(heading) = &line_info.heading {
if heading.level as usize == self.config.level.as_usize() {
current_delta = 0;
}
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && line_num + 1 < ctx.lines.len()
{
fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
skip_next = true;
}
}
continue;
}
if let Some(heading) = &line_info.heading {
if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
if !found_first {
found_first = true;
current_delta = 0;
fixed_lines.push(line_info.content(ctx.content).to_string());
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && line_num + 1 < ctx.lines.len()
{
fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
skip_next = true;
}
} else {
let should_allow = self.is_document_section_heading(&heading.text)
|| self.has_separator_before_heading(ctx, line_num);
if should_allow {
current_delta = 0;
fixed_lines.push(line_info.content(ctx.content).to_string());
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && line_num + 1 < ctx.lines.len()
{
fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
skip_next = true;
}
} else {
current_delta = 1;
let (demoted, should_skip) =
Self::build_demoted_heading(heading, line_info, ctx.content, 1);
fixed_lines.push(demoted);
if should_skip && line_num + 1 < ctx.lines.len() {
skip_next = true;
}
}
}
} else if current_delta > 0 && !line_info.in_code_block {
let new_level = heading.level as usize + current_delta;
if new_level > 6 {
fixed_lines.push(line_info.content(ctx.content).to_string());
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && line_num + 1 < ctx.lines.len()
{
fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
skip_next = true;
}
} else {
let (demoted, should_skip) =
Self::build_demoted_heading(heading, line_info, ctx.content, current_delta);
fixed_lines.push(demoted);
if should_skip && line_num + 1 < ctx.lines.len() {
skip_next = true;
}
}
} else {
fixed_lines.push(line_info.content(ctx.content).to_string());
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && line_num + 1 < ctx.lines.len()
{
fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
skip_next = true;
}
}
} else {
fixed_lines.push(line_info.content(ctx.content).to_string());
}
}
let result = fixed_lines.join("\n");
if ctx.content.ends_with('\n') {
Ok(result + "\n")
} else {
Ok(result)
}
}
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_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}");
}
}
}