use anyhow::Result;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::{
fs,
path::{Path, PathBuf},
};
use crate::rules::ALL_RULES;
#[derive(Debug, PartialEq, Clone, Deserialize)]
pub enum RuleSeverity {
#[serde(rename = "err")]
Error,
#[serde(rename = "warn")]
Warning,
#[serde(rename = "off")]
Off,
}
pub use crate::rules::md003::{HeadingStyle, MD003HeadingStyleTable};
pub use crate::rules::md004::{MD004UlStyleTable, UlStyle};
pub use crate::rules::md007::MD007UlIndentTable;
pub use crate::rules::md009::MD009TrailingSpacesTable;
pub use crate::rules::md010::MD010HardTabsTable;
pub use crate::rules::md012::MD012MultipleBlankLinesTable;
pub use crate::rules::md013::MD013LineLengthTable;
pub use crate::rules::md022::MD022HeadingsBlanksTable;
pub use crate::rules::md024::MD024MultipleHeadingsTable;
pub use crate::rules::md025::MD025SingleH1Table;
pub use crate::rules::md026::MD026TrailingPunctuationTable;
pub use crate::rules::md027::MD027BlockquoteSpacesTable;
pub use crate::rules::md029::{MD029OlPrefixTable, OlPrefixStyle};
pub use crate::rules::md030::MD030ListMarkerSpaceTable;
pub use crate::rules::md031::MD031FencedCodeBlanksTable;
pub use crate::rules::md033::MD033InlineHtmlTable;
pub use crate::rules::md035::MD035HrStyleTable;
pub use crate::rules::md036::MD036EmphasisAsHeadingTable;
pub use crate::rules::md040::MD040FencedCodeLanguageTable;
pub use crate::rules::md041::MD041FirstLineHeadingTable;
pub use crate::rules::md043::MD043RequiredHeadingsTable;
pub use crate::rules::md044::MD044ProperNamesTable;
pub use crate::rules::md046::{CodeBlockStyle, MD046CodeBlockStyleTable};
pub use crate::rules::md048::{CodeFenceStyle, MD048CodeFenceStyleTable};
pub use crate::rules::md049::{EmphasisStyle, MD049EmphasisStyleTable};
pub use crate::rules::md050::{MD050StrongStyleTable, StrongStyle};
pub use crate::rules::md051::MD051LinkFragmentsTable;
pub use crate::rules::md052::MD052ReferenceLinksImagesTable;
pub use crate::rules::md053::MD053LinkImageReferenceDefinitionsTable;
pub use crate::rules::md054::MD054LinkImageStyleTable;
pub use crate::rules::md055::{MD055TablePipeStyleTable, TablePipeStyle};
pub use crate::rules::md059::MD059DescriptiveLinkTextTable;
#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
pub struct LintersSettingsTable {
#[serde(rename = "heading-style")]
#[serde(default)]
pub heading_style: MD003HeadingStyleTable,
#[serde(rename = "ul-style")]
#[serde(default)]
pub ul_style: MD004UlStyleTable,
#[serde(rename = "ol-prefix")]
#[serde(default)]
pub ol_prefix: MD029OlPrefixTable,
#[serde(rename = "ul-indent")]
#[serde(default)]
pub ul_indent: MD007UlIndentTable,
#[serde(rename = "no-trailing-spaces")]
#[serde(default)]
pub trailing_spaces: MD009TrailingSpacesTable,
#[serde(rename = "no-hard-tabs")]
#[serde(default)]
pub hard_tabs: MD010HardTabsTable,
#[serde(rename = "no-multiple-blanks")]
#[serde(default)]
pub multiple_blank_lines: MD012MultipleBlankLinesTable,
#[serde(rename = "line-length")]
#[serde(default)]
pub line_length: MD013LineLengthTable,
#[serde(rename = "blanks-around-headings")]
#[serde(default)]
pub headings_blanks: MD022HeadingsBlanksTable,
#[serde(rename = "single-h1")]
#[serde(default)]
pub single_h1: MD025SingleH1Table,
#[serde(rename = "first-line-heading")]
#[serde(default)]
pub first_line_heading: MD041FirstLineHeadingTable,
#[serde(rename = "no-trailing-punctuation")]
#[serde(default)]
pub trailing_punctuation: MD026TrailingPunctuationTable,
#[serde(rename = "no-multiple-space-blockquote")]
#[serde(default)]
pub blockquote_spaces: MD027BlockquoteSpacesTable,
#[serde(rename = "list-marker-space")]
#[serde(default)]
pub list_marker_space: MD030ListMarkerSpaceTable,
#[serde(rename = "blanks-around-fences")]
#[serde(default)]
pub fenced_code_blanks: MD031FencedCodeBlanksTable,
#[serde(rename = "no-inline-html")]
#[serde(default)]
pub inline_html: MD033InlineHtmlTable,
#[serde(rename = "hr-style")]
#[serde(default)]
pub hr_style: MD035HrStyleTable,
#[serde(rename = "no-emphasis-as-heading")]
#[serde(default)]
pub emphasis_as_heading: MD036EmphasisAsHeadingTable,
#[serde(rename = "fenced-code-language")]
#[serde(default)]
pub fenced_code_language: MD040FencedCodeLanguageTable,
#[serde(rename = "code-block-style")]
#[serde(default)]
pub code_block_style: MD046CodeBlockStyleTable,
#[serde(rename = "code-fence-style")]
#[serde(default)]
pub code_fence_style: MD048CodeFenceStyleTable,
#[serde(rename = "emphasis-style")]
#[serde(default)]
pub emphasis_style: MD049EmphasisStyleTable,
#[serde(rename = "strong-style")]
#[serde(default)]
pub strong_style: MD050StrongStyleTable,
#[serde(rename = "no-duplicate-heading")]
#[serde(default)]
pub multiple_headings: MD024MultipleHeadingsTable,
#[serde(rename = "required-headings")]
#[serde(default)]
pub required_headings: MD043RequiredHeadingsTable,
#[serde(rename = "proper-names")]
#[serde(default)]
pub proper_names: MD044ProperNamesTable,
#[serde(rename = "link-fragments")]
#[serde(default)]
pub link_fragments: MD051LinkFragmentsTable,
#[serde(rename = "reference-links-images")]
#[serde(default)]
pub reference_links_images: MD052ReferenceLinksImagesTable,
#[serde(rename = "link-image-reference-definitions")]
#[serde(default)]
pub link_image_reference_definitions: MD053LinkImageReferenceDefinitionsTable,
#[serde(rename = "link-image-style")]
#[serde(default)]
pub link_image_style: MD054LinkImageStyleTable,
#[serde(rename = "table-pipe-style")]
#[serde(default)]
pub table_pipe_style: MD055TablePipeStyleTable,
#[serde(rename = "descriptive-link-text")]
#[serde(default)]
pub descriptive_link_text: MD059DescriptiveLinkTextTable,
}
#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
pub struct LintersTable {
#[serde(default)]
pub severity: HashMap<String, RuleSeverity>,
#[serde(default)]
pub settings: LintersSettingsTable,
}
#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
pub struct QuickmarkConfig {
#[serde(default)]
pub linters: LintersTable,
}
pub fn normalize_severities(severities: &mut HashMap<String, RuleSeverity>) {
let rule_aliases: HashSet<&str> = ALL_RULES.iter().map(|r| r.alias).collect();
let default_severity = severities.remove("default").unwrap_or(RuleSeverity::Error);
severities.retain(|key, _| rule_aliases.contains(key.as_str()));
for &rule in &rule_aliases {
severities
.entry(rule.to_string())
.or_insert(default_severity.clone());
}
}
impl QuickmarkConfig {
pub fn new(linters: LintersTable) -> Self {
Self { linters }
}
pub fn default_with_normalized_severities() -> Self {
let mut config = Self::default();
normalize_severities(&mut config.linters.severity);
config
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum ConfigSearchResult {
Found {
path: PathBuf,
config: Box<QuickmarkConfig>,
},
NotFound { searched_paths: Vec<PathBuf> },
Error { path: PathBuf, error: String },
}
pub struct ConfigDiscovery {
workspace_roots: Vec<PathBuf>,
current_working_dir: Option<PathBuf>,
}
impl Default for ConfigDiscovery {
fn default() -> Self {
Self::new()
}
}
impl ConfigDiscovery {
pub fn new() -> Self {
let current_working_dir = std::env::current_dir().ok();
Self {
workspace_roots: Vec::new(),
current_working_dir,
}
}
pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
Self {
workspace_roots: roots,
current_working_dir: None,
}
}
pub fn find_config(&self, file_path: &Path) -> ConfigSearchResult {
let start_dir = if file_path.is_file() {
file_path.parent().unwrap_or(file_path)
} else {
file_path
};
let mut searched_paths = Vec::new();
let mut current_dir = start_dir;
loop {
let config_path = current_dir.join("quickmark.toml");
searched_paths.push(config_path.clone());
if config_path.is_file() {
match fs::read_to_string(&config_path) {
Ok(config_str) => match parse_toml_config(&config_str) {
Ok(config) => {
return ConfigSearchResult::Found {
path: config_path,
config: Box::new(config),
}
}
Err(e) => {
return ConfigSearchResult::Error {
path: config_path,
error: e.to_string(),
}
}
},
Err(e) => {
return ConfigSearchResult::Error {
path: config_path,
error: e.to_string(),
}
}
}
}
if self.should_stop_search(current_dir) {
break;
}
match current_dir.parent() {
Some(parent) => current_dir = parent,
None => break, }
}
ConfigSearchResult::NotFound { searched_paths }
}
fn should_stop_search(&self, dir: &Path) -> bool {
for workspace_root in &self.workspace_roots {
if dir == workspace_root.as_path() {
return true;
}
}
if let Some(cwd) = &self.current_working_dir {
if dir == cwd.as_path() {
return true;
}
}
if dir.join(".git").exists() {
return true;
}
false
}
}
pub fn parse_toml_config(config_str: &str) -> Result<QuickmarkConfig> {
let mut config: QuickmarkConfig = toml::from_str(config_str)?;
normalize_severities(&mut config.linters.severity);
Ok(config)
}
pub fn config_from_env_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
if let Ok(env_config_path) = std::env::var("QUICKMARK_CONFIG") {
let env_config_file = Path::new(&env_config_path);
if env_config_file.is_file() {
match fs::read_to_string(env_config_file) {
Ok(config) => return parse_toml_config(&config),
Err(e) => {
eprintln!(
"Error loading config from QUICKMARK_CONFIG path {env_config_path}: {e}. Default config will be used."
);
return Ok(QuickmarkConfig::default_with_normalized_severities());
}
}
} else {
eprintln!(
"Config file was not found at QUICKMARK_CONFIG path {env_config_path}. Default config will be used."
);
return Ok(QuickmarkConfig::default_with_normalized_severities());
}
}
config_in_path_or_default(path)
}
pub fn config_in_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
let config_file = path.join("quickmark.toml");
if config_file.is_file() {
let config = fs::read_to_string(config_file)?;
return parse_toml_config(&config);
}
eprintln!(
"Config file was not found at {}. Default config will be used.",
config_file.to_string_lossy()
);
Ok(QuickmarkConfig::default_with_normalized_severities())
}
pub fn discover_config_or_default(file_path: &Path) -> Result<QuickmarkConfig> {
let discovery = ConfigDiscovery::new();
match discovery.find_config(file_path) {
ConfigSearchResult::Found { config, .. } => Ok(*config),
ConfigSearchResult::NotFound { .. } => {
Ok(QuickmarkConfig::default_with_normalized_severities())
}
ConfigSearchResult::Error { path, error } => {
eprintln!(
"Error loading config from {}: {}. Default config will be used.",
path.to_string_lossy(),
error
);
Ok(QuickmarkConfig::default_with_normalized_severities())
}
}
}
pub fn discover_config_with_workspace_or_default(
file_path: &Path,
workspace_roots: Vec<PathBuf>,
) -> Result<QuickmarkConfig> {
let discovery = ConfigDiscovery::with_workspace_roots(workspace_roots);
match discovery.find_config(file_path) {
ConfigSearchResult::Found { config, .. } => Ok(*config),
ConfigSearchResult::NotFound { .. } => {
Ok(QuickmarkConfig::default_with_normalized_severities())
}
ConfigSearchResult::Error { path, error } => {
eprintln!(
"Error loading config from {}: {}. Default config will be used.",
path.to_string_lossy(),
error
);
Ok(QuickmarkConfig::default_with_normalized_severities())
}
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
use crate::config::{
config_from_env_path_or_default, discover_config_or_default,
discover_config_with_workspace_or_default, parse_toml_config, ConfigDiscovery,
ConfigSearchResult, HeadingStyle, LintersSettingsTable, LintersTable,
MD003HeadingStyleTable, MD004UlStyleTable, MD007UlIndentTable, MD009TrailingSpacesTable,
MD010HardTabsTable, MD012MultipleBlankLinesTable, MD013LineLengthTable,
MD022HeadingsBlanksTable, MD024MultipleHeadingsTable, MD025SingleH1Table,
MD026TrailingPunctuationTable, MD027BlockquoteSpacesTable, MD029OlPrefixTable,
MD030ListMarkerSpaceTable, MD031FencedCodeBlanksTable, MD033InlineHtmlTable,
MD035HrStyleTable, MD036EmphasisAsHeadingTable, MD040FencedCodeLanguageTable,
MD041FirstLineHeadingTable, MD043RequiredHeadingsTable, MD044ProperNamesTable,
MD046CodeBlockStyleTable, MD048CodeFenceStyleTable, MD049EmphasisStyleTable,
MD050StrongStyleTable, MD051LinkFragmentsTable, MD052ReferenceLinksImagesTable,
MD053LinkImageReferenceDefinitionsTable, MD054LinkImageStyleTable,
MD055TablePipeStyleTable, MD059DescriptiveLinkTextTable, RuleSeverity,
};
use super::{normalize_severities, QuickmarkConfig};
#[test]
pub fn test_normalize_severities() {
let mut severity: HashMap<String, RuleSeverity> = vec![
("heading-style".to_string(), RuleSeverity::Error),
("some-bullshit".to_string(), RuleSeverity::Warning),
]
.into_iter()
.collect();
normalize_severities(&mut severity);
assert_eq!(
RuleSeverity::Error,
*severity.get("heading-increment").unwrap()
);
assert_eq!(RuleSeverity::Error, *severity.get("heading-style").unwrap());
assert_eq!(RuleSeverity::Error, *severity.get("list-indent").unwrap());
assert_eq!(
RuleSeverity::Error,
*severity.get("no-reversed-links").unwrap()
);
assert_eq!(None, severity.get("some-bullshit"));
}
#[test]
pub fn test_default_with_normalized_severities() {
let config = QuickmarkConfig::default_with_normalized_severities();
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("heading-increment").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("list-indent").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("no-reversed-links").unwrap()
);
assert_eq!(
HeadingStyle::Consistent,
config.linters.settings.heading_style.style
);
}
#[test]
pub fn test_new_config() {
let severity: HashMap<String, RuleSeverity> = vec![
("heading-increment".to_string(), RuleSeverity::Warning),
("heading-style".to_string(), RuleSeverity::Off),
]
.into_iter()
.collect();
let config = QuickmarkConfig::new(LintersTable {
severity,
settings: LintersSettingsTable {
heading_style: MD003HeadingStyleTable {
style: HeadingStyle::ATX,
},
ul_style: MD004UlStyleTable::default(),
ol_prefix: MD029OlPrefixTable::default(),
list_marker_space: MD030ListMarkerSpaceTable::default(),
ul_indent: MD007UlIndentTable::default(),
trailing_spaces: MD009TrailingSpacesTable::default(),
hard_tabs: MD010HardTabsTable::default(),
multiple_blank_lines: MD012MultipleBlankLinesTable::default(),
line_length: MD013LineLengthTable::default(),
headings_blanks: MD022HeadingsBlanksTable::default(),
single_h1: MD025SingleH1Table::default(),
first_line_heading: MD041FirstLineHeadingTable::default(),
trailing_punctuation: MD026TrailingPunctuationTable::default(),
blockquote_spaces: MD027BlockquoteSpacesTable::default(),
fenced_code_blanks: MD031FencedCodeBlanksTable::default(),
inline_html: MD033InlineHtmlTable::default(),
hr_style: MD035HrStyleTable::default(),
emphasis_as_heading: MD036EmphasisAsHeadingTable::default(),
fenced_code_language: MD040FencedCodeLanguageTable::default(),
code_block_style: MD046CodeBlockStyleTable::default(),
code_fence_style: MD048CodeFenceStyleTable::default(),
emphasis_style: MD049EmphasisStyleTable::default(),
strong_style: MD050StrongStyleTable::default(),
multiple_headings: MD024MultipleHeadingsTable::default(),
required_headings: MD043RequiredHeadingsTable::default(),
proper_names: MD044ProperNamesTable::default(),
link_fragments: MD051LinkFragmentsTable::default(),
reference_links_images: MD052ReferenceLinksImagesTable::default(),
link_image_reference_definitions: MD053LinkImageReferenceDefinitionsTable::default(
),
link_image_style: MD054LinkImageStyleTable::default(),
table_pipe_style: MD055TablePipeStyleTable::default(),
descriptive_link_text: MD059DescriptiveLinkTextTable::default(),
},
});
assert_eq!(
RuleSeverity::Warning,
*config.linters.severity.get("heading-increment").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*config.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
HeadingStyle::ATX,
config.linters.settings.heading_style.style
);
}
#[test]
fn test_parse_toml_config_with_invalid_rules() {
let config_str = r#"
[linters.severity]
heading-style = 'err'
some-invalid-rule = 'warn'
[linters.settings.heading-style]
style = 'atx'
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-increment").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("some-invalid-rule"));
}
#[test]
fn test_config_from_env_fallback_to_local() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("quickmark.toml");
let config_content = r#"
[linters.severity]
heading-increment = 'err'
heading-style = 'off'
"#;
std::fs::write(&config_path, config_content).unwrap();
let config = config_from_env_path_or_default(temp_dir.path()).unwrap();
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("heading-increment").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*config.linters.severity.get("heading-style").unwrap()
);
}
#[test]
fn test_config_from_env_default_when_no_config() {
let dummy_path = Path::new("/tmp");
let config = config_from_env_path_or_default(dummy_path).unwrap();
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("heading-increment").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*config.linters.severity.get("heading-style").unwrap()
);
}
#[test]
fn test_parse_full_config_with_custom_parameters() {
let config_str = r#"
[linters.severity]
heading-style = 'warn'
ul-style = 'off'
line-length = 'err'
[linters.settings.heading-style]
style = 'atx'
[linters.settings.ul-style]
style = 'asterisk'
[linters.settings.ol-prefix]
style = 'one'
[linters.settings.ul-indent]
indent = 4
start_indent = 3
start_indented = true
[linters.settings.no-trailing-spaces]
br_spaces = 3
list_item_empty_lines = true
strict = true
[linters.settings.no-hard-tabs]
code_blocks = false
ignore_code_languages = ["python", "go"]
spaces_per_tab = 8
[linters.settings.no-multiple-blanks]
maximum = 3
[linters.settings.line-length]
line_length = 120
code_block_line_length = 100
heading_line_length = 90
code_blocks = false
headings = false
tables = false
strict = true
stern = true
[linters.settings.blanks-around-headings]
lines_above = [2, 1, 1, 1, 1, 1]
lines_below = [2, 1, 1, 1, 1, 1]
[linters.settings.single-h1]
level = 2
front_matter_title = "^title:"
[linters.settings.first-line-heading]
allow_preamble = true
[linters.settings.no-trailing-punctuation]
punctuation = ".,;:!?"
[linters.settings.no-multiple-space-blockquote]
list_items = false
[linters.settings.list-marker-space]
ul_single = 2
ol_single = 3
ul_multi = 3
ol_multi = 4
[linters.settings.blanks-around-fences]
list_items = false
[linters.settings.no-inline-html]
allowed_elements = ["br", "img"]
[linters.settings.hr-style]
style = "asterisk"
[linters.settings.no-emphasis-as-heading]
punctuation = ".,;:!?"
[linters.settings.fenced-code-language]
allowed_languages = ["rust", "python"]
language_only = true
[linters.settings.code-block-style]
style = 'fenced'
[linters.settings.code-fence-style]
style = 'backtick'
[linters.settings.emphasis-style]
style = 'asterisk'
[linters.settings.strong-style]
style = 'underscore'
[linters.settings.no-duplicate-heading]
siblings_only = false
allow_different_nesting = false
[linters.settings.required-headings]
headings = ["Introduction", "Usage", "Examples"]
match_case = true
[linters.settings.proper-names]
names = ["JavaScript", "GitHub", "API"]
code_blocks = false
html_elements = false
[linters.settings.link-fragments]
[linters.settings.reference-links-images]
ignored_labels = ["x", "skip"]
[linters.settings.link-image-reference-definitions]
ignored_definitions = ["//", "skip"]
[linters.settings.link-image-style]
autolink = false
inline = true
full = true
collapsed = false
shortcut = false
url_inline = false
[linters.settings.table-pipe-style]
style = 'leading_and_trailing'
[linters.settings.descriptive-link-text]
prohibited_texts = ["click here", "read more", "see here"]
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
HeadingStyle::ATX,
parsed.linters.settings.heading_style.style
);
use crate::rules::md004::UlStyle;
assert_eq!(UlStyle::Asterisk, parsed.linters.settings.ul_style.style);
assert_eq!(4, parsed.linters.settings.ul_indent.indent);
assert_eq!(3, parsed.linters.settings.ul_indent.start_indent);
assert!(parsed.linters.settings.ul_indent.start_indented);
assert_eq!(3, parsed.linters.settings.trailing_spaces.br_spaces);
assert!(
parsed
.linters
.settings
.trailing_spaces
.list_item_empty_lines
);
assert!(parsed.linters.settings.trailing_spaces.strict);
assert_eq!(120, parsed.linters.settings.line_length.line_length);
assert_eq!(
100,
parsed.linters.settings.line_length.code_block_line_length
);
assert_eq!(90, parsed.linters.settings.line_length.heading_line_length);
assert!(!parsed.linters.settings.line_length.code_blocks);
assert!(!parsed.linters.settings.line_length.headings);
assert!(!parsed.linters.settings.line_length.tables);
assert!(parsed.linters.settings.line_length.strict);
assert!(parsed.linters.settings.line_length.stern);
assert_eq!(2, parsed.linters.settings.single_h1.level);
assert_eq!(
"^title:",
parsed.linters.settings.single_h1.front_matter_title
);
use crate::rules::md029::OlPrefixStyle;
assert_eq!(OlPrefixStyle::One, parsed.linters.settings.ol_prefix.style);
assert!(!parsed.linters.settings.hard_tabs.code_blocks);
assert_eq!(
vec!["python", "go"],
parsed.linters.settings.hard_tabs.ignore_code_languages
);
assert_eq!(8, parsed.linters.settings.hard_tabs.spaces_per_tab);
assert_eq!(3, parsed.linters.settings.multiple_blank_lines.maximum);
assert_eq!(
vec![2, 1, 1, 1, 1, 1],
parsed.linters.settings.headings_blanks.lines_above
);
assert_eq!(
vec![2, 1, 1, 1, 1, 1],
parsed.linters.settings.headings_blanks.lines_below
);
assert!(parsed.linters.settings.first_line_heading.allow_preamble);
assert_eq!(
".,;:!?",
parsed.linters.settings.trailing_punctuation.punctuation
);
assert!(!parsed.linters.settings.blockquote_spaces.list_items);
assert_eq!(2, parsed.linters.settings.list_marker_space.ul_single);
assert_eq!(3, parsed.linters.settings.list_marker_space.ol_single);
assert_eq!(3, parsed.linters.settings.list_marker_space.ul_multi);
assert_eq!(4, parsed.linters.settings.list_marker_space.ol_multi);
assert!(!parsed.linters.settings.fenced_code_blanks.list_items);
assert_eq!(
vec!["br", "img"],
parsed.linters.settings.inline_html.allowed_elements
);
assert_eq!("asterisk", parsed.linters.settings.hr_style.style);
assert_eq!(
".,;:!?",
parsed.linters.settings.emphasis_as_heading.punctuation
);
assert_eq!(
vec!["rust", "python"],
parsed
.linters
.settings
.fenced_code_language
.allowed_languages
);
assert!(parsed.linters.settings.fenced_code_language.language_only);
use crate::rules::md046::CodeBlockStyle;
assert_eq!(
CodeBlockStyle::Fenced,
parsed.linters.settings.code_block_style.style
);
use crate::rules::md048::CodeFenceStyle;
assert_eq!(
CodeFenceStyle::Backtick,
parsed.linters.settings.code_fence_style.style
);
use crate::rules::md049::EmphasisStyle;
assert_eq!(
EmphasisStyle::Asterisk,
parsed.linters.settings.emphasis_style.style
);
use crate::rules::md050::StrongStyle;
assert_eq!(
StrongStyle::Underscore,
parsed.linters.settings.strong_style.style
);
assert!(!parsed.linters.settings.multiple_headings.siblings_only);
assert!(
!parsed
.linters
.settings
.multiple_headings
.allow_different_nesting
);
assert_eq!(
vec!["Introduction", "Usage", "Examples"],
parsed.linters.settings.required_headings.headings
);
assert!(parsed.linters.settings.required_headings.match_case);
assert_eq!(
vec!["JavaScript", "GitHub", "API"],
parsed.linters.settings.proper_names.names
);
assert!(!parsed.linters.settings.proper_names.code_blocks);
assert!(!parsed.linters.settings.proper_names.html_elements);
assert_eq!(
vec!["x", "skip"],
parsed
.linters
.settings
.reference_links_images
.ignored_labels
);
assert_eq!(
vec!["//", "skip"],
parsed
.linters
.settings
.link_image_reference_definitions
.ignored_definitions
);
assert!(!parsed.linters.settings.link_image_style.autolink);
assert!(parsed.linters.settings.link_image_style.inline);
assert!(parsed.linters.settings.link_image_style.full);
assert!(!parsed.linters.settings.link_image_style.collapsed);
assert!(!parsed.linters.settings.link_image_style.shortcut);
assert!(!parsed.linters.settings.link_image_style.url_inline);
use crate::rules::md055::TablePipeStyle;
assert_eq!(
TablePipeStyle::LeadingAndTrailing,
parsed.linters.settings.table_pipe_style.style
);
assert_eq!(
vec!["click here", "read more", "see here"],
parsed
.linters
.settings
.descriptive_link_text
.prohibited_texts
);
}
#[test]
fn test_parse_empty_config_uses_defaults() {
let config_str = r#"
# Empty config - should use all defaults
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("ul-indent").unwrap()
);
assert_eq!(
HeadingStyle::Consistent,
parsed.linters.settings.heading_style.style
);
use crate::rules::md004::UlStyle;
assert_eq!(UlStyle::Consistent, parsed.linters.settings.ul_style.style);
assert_eq!(2, parsed.linters.settings.ul_indent.indent);
assert_eq!(2, parsed.linters.settings.ul_indent.start_indent);
assert!(!parsed.linters.settings.ul_indent.start_indented);
assert_eq!(2, parsed.linters.settings.trailing_spaces.br_spaces);
assert!(
!parsed
.linters
.settings
.trailing_spaces
.list_item_empty_lines
);
assert!(!parsed.linters.settings.trailing_spaces.strict);
assert_eq!(80, parsed.linters.settings.line_length.line_length);
assert_eq!(
80,
parsed.linters.settings.line_length.code_block_line_length
);
assert_eq!(80, parsed.linters.settings.line_length.heading_line_length);
assert!(parsed.linters.settings.line_length.code_blocks);
assert!(parsed.linters.settings.line_length.headings);
assert!(parsed.linters.settings.line_length.tables);
assert!(!parsed.linters.settings.line_length.strict);
assert!(!parsed.linters.settings.line_length.stern);
assert_eq!(1, parsed.linters.settings.single_h1.level);
assert_eq!(
r"^\s*title\s*[:=]",
parsed.linters.settings.single_h1.front_matter_title
);
use crate::rules::md029::OlPrefixStyle;
assert_eq!(
OlPrefixStyle::OneOrOrdered,
parsed.linters.settings.ol_prefix.style
);
assert_eq!(1, parsed.linters.settings.multiple_blank_lines.maximum);
assert_eq!(1, parsed.linters.settings.hard_tabs.spaces_per_tab);
assert!(parsed.linters.settings.hard_tabs.code_blocks);
assert!(!parsed.linters.settings.first_line_heading.allow_preamble);
}
#[test]
fn test_default_severity_error() {
let config_str = r#"
[linters.severity]
default = "err"
heading-style = "warn"
ul-style = "off"
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("ul-indent").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("no-hard-tabs").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("default"));
}
#[test]
fn test_default_severity_warning() {
let config_str = r#"
[linters.severity]
default = "warn"
heading-style = "err"
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("ul-indent").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("default"));
}
#[test]
fn test_default_severity_off() {
let config_str = r#"
[linters.severity]
default = "off"
heading-style = "err"
line-length = "warn"
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-indent").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("no-hard-tabs").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("default"));
}
#[test]
fn test_default_severity_with_invalid_rules() {
let config_str = r#"
[linters.severity]
default = "warn"
heading-style = "err"
invalid-rule = "off"
another-invalid = "warn"
ul-style = "off"
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("invalid-rule"));
assert_eq!(None, parsed.linters.severity.get("another-invalid"));
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("ul-indent").unwrap()
);
assert_eq!(None, parsed.linters.severity.get("default"));
}
#[test]
fn test_no_default_uses_error() {
let config_str = r#"
[linters.severity]
heading-style = "warn"
ul-style = "off"
"#;
let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(
RuleSeverity::Warning,
*parsed.linters.severity.get("heading-style").unwrap()
);
assert_eq!(
RuleSeverity::Off,
*parsed.linters.severity.get("ul-style").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("line-length").unwrap()
);
assert_eq!(
RuleSeverity::Error,
*parsed.linters.severity.get("ul-indent").unwrap()
);
}
#[test]
fn test_config_discovery_not_found() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let discovery = ConfigDiscovery::new();
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::NotFound { searched_paths } => {
assert!(!searched_paths.is_empty());
assert!(searched_paths
.iter()
.any(|p| p.parent() == Some(temp_dir.path())));
}
_ => panic!("Expected NotFound result"),
}
}
#[test]
fn test_config_discovery_found() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("quickmark.toml");
let config_content = r#"
[linters.severity]
heading-style = 'warn'
"#;
std::fs::write(&config_path, config_content).unwrap();
let file_path = temp_dir.path().join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let discovery = ConfigDiscovery::new();
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::Found { path, config } => {
assert_eq!(path, config_path);
assert_eq!(
*config.linters.severity.get("heading-style").unwrap(),
RuleSeverity::Warning
);
}
_ => panic!("Expected Found result, got: {:?}", result),
}
}
#[test]
fn test_config_discovery_hierarchical_search() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
let src_dir = project_dir.join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let config_path = project_dir.join("quickmark.toml");
let config_content = r#"
[linters.severity]
heading-style = 'off'
"#;
std::fs::write(&config_path, config_content).unwrap();
let file_path = src_dir.join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let discovery = ConfigDiscovery::new();
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::Found { path, config } => {
assert_eq!(path, config_path);
assert_eq!(
*config.linters.severity.get("heading-style").unwrap(),
RuleSeverity::Off
);
}
_ => panic!("Expected Found result, got: {:?}", result),
}
}
#[test]
fn test_config_discovery_stops_at_git_root() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path().join("repo");
let src_dir = repo_dir.join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir(repo_dir.join(".git")).unwrap();
let outer_config = temp_dir.path().join("quickmark.toml");
std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
let file_path = src_dir.join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let discovery = ConfigDiscovery::new();
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::NotFound { searched_paths } => {
let searched_dirs: Vec<_> =
searched_paths.iter().filter_map(|p| p.parent()).collect();
assert!(searched_dirs.contains(&src_dir.as_path()));
assert!(searched_dirs.contains(&repo_dir.as_path()));
assert!(!searched_dirs.contains(&temp_dir.path()));
}
_ => panic!("Expected NotFound result, got: {:?}", result),
}
}
#[test]
fn test_config_discovery_stops_at_workspace_root() {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
let project_dir = workspace_dir.join("project");
let src_dir = project_dir.join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let outer_config = temp_dir.path().join("quickmark.toml");
std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
let file_path = src_dir.join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::NotFound { searched_paths } => {
let searched_dirs: Vec<_> =
searched_paths.iter().filter_map(|p| p.parent()).collect();
assert!(searched_dirs.contains(&src_dir.as_path()));
assert!(searched_dirs.contains(&project_dir.as_path()));
assert!(searched_dirs.contains(&workspace_dir.as_path()));
assert!(!searched_dirs.contains(&temp_dir.path()));
}
_ => panic!("Expected NotFound result, got: {:?}", result),
}
}
#[test]
fn test_config_discovery_error() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("quickmark.toml");
let invalid_config = "invalid toml content [[[";
std::fs::write(&config_path, invalid_config).unwrap();
let file_path = temp_dir.path().join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let discovery = ConfigDiscovery::new();
let result = discovery.find_config(&file_path);
match result {
ConfigSearchResult::Error { path, error } => {
assert_eq!(path, config_path);
assert!(error.contains("expected")); }
_ => panic!("Expected Error result, got: {:?}", result),
}
}
#[test]
fn test_discover_config_or_default_found() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("quickmark.toml");
let config_content = r#"
[linters.severity]
heading-style = 'warn'
"#;
std::fs::write(&config_path, config_content).unwrap();
let file_path = temp_dir.path().join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let result = discover_config_or_default(&file_path).unwrap();
assert_eq!(
*result.linters.severity.get("heading-style").unwrap(),
RuleSeverity::Warning
);
}
#[test]
fn test_discover_config_or_default_not_found() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let result = discover_config_or_default(&file_path).unwrap();
assert_eq!(
*result.linters.severity.get("heading-style").unwrap(),
RuleSeverity::Error
);
}
#[test]
fn test_discover_config_with_workspace_or_default() {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
let project_dir = workspace_dir.join("project");
std::fs::create_dir_all(&project_dir).unwrap();
let config_path = workspace_dir.join("quickmark.toml");
let config_content = r#"
[linters.severity]
heading-style = 'off'
"#;
std::fs::write(&config_path, config_content).unwrap();
let file_path = project_dir.join("test.md");
std::fs::write(&file_path, "# Test").unwrap();
let result =
discover_config_with_workspace_or_default(&file_path, vec![workspace_dir.clone()])
.unwrap();
assert_eq!(
*result.linters.severity.get("heading-style").unwrap(),
RuleSeverity::Off
);
}
#[test]
fn test_should_stop_search_workspace_priority() {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
let git_dir = workspace_dir.join(".git");
let project_dir = git_dir.join("project");
std::fs::create_dir_all(&project_dir).unwrap();
let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
assert!(discovery.should_stop_search(&workspace_dir));
assert!(!discovery.should_stop_search(&git_dir));
}
}