markdownlint-rs 0.2.3

A fast, flexible, configuration-based command-line interface for linting Markdown/CommonMark files
Documentation
use crate::config::Config;
use crate::error::{MarkdownlintError, Result};
use std::fs;
use std::path::{Path, PathBuf};

const CONFIG_FILES: &[&str] = &[
    ".markdownlint-cli2.jsonc",
    ".markdownlint-cli2.yaml",
    ".markdownlint-cli2.yml",
    ".markdownlint.jsonc",
    ".markdownlint.json",
    ".markdownlint.yaml",
    ".markdownlint.yml",
    "package.json",
];

pub struct ConfigLoader;

impl ConfigLoader {
    pub fn load_from_file(path: &Path) -> Result<Config> {
        let content = fs::read_to_string(path).map_err(|e| {
            MarkdownlintError::Config(format!("Failed to read config file {:?}: {}", path, e))
        })?;

        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");

        Self::parse_config(&content, file_name, path)
    }

    pub fn discover_config(start_dir: &Path) -> Result<Option<Config>> {
        let mut current = start_dir.to_path_buf();

        loop {
            for config_file in CONFIG_FILES {
                let config_path = current.join(config_file);
                if config_path.exists() {
                    let config = Self::load_from_file(&config_path)?;
                    return Ok(Some(config));
                }
            }

            if !current.pop() {
                break;
            }
        }

        Ok(None)
    }

    pub fn find_all_configs(start_dir: &Path) -> Result<Vec<(PathBuf, Config)>> {
        let mut configs = Vec::new();
        let mut current = start_dir.to_path_buf();

        loop {
            for config_file in CONFIG_FILES {
                let config_path = current.join(config_file);
                if config_path.exists() {
                    let config = Self::load_from_file(&config_path)?;
                    configs.push((config_path, config));
                    break;
                }
            }

            if !current.pop() {
                break;
            }
        }

        configs.reverse();
        Ok(configs)
    }

    fn parse_config(content: &str, file_name: &str, path: &Path) -> Result<Config> {
        if file_name == "package.json" {
            Self::parse_package_json(content)
        } else if file_name.ends_with(".jsonc") || file_name.ends_with(".json") {
            Self::parse_jsonc(content)
        } else if file_name.ends_with(".yaml") || file_name.ends_with(".yml") {
            Self::parse_yaml(content)
        } else {
            Err(MarkdownlintError::Config(format!(
                "Unknown config file format: {:?}",
                path
            )))
        }
    }

    fn parse_jsonc(content: &str) -> Result<Config> {
        let parsed = jsonc_parser::parse_to_serde_value(content, &Default::default())
            .map_err(|e| MarkdownlintError::Config(format!("Failed to parse JSONC: {:?}", e)))?;

        let value =
            parsed.ok_or_else(|| MarkdownlintError::Config("JSONC config is empty".to_string()))?;

        serde_json::from_value(value)
            .map_err(|e| MarkdownlintError::Config(format!("Invalid config structure: {}", e)))
    }

    fn parse_yaml(content: &str) -> Result<Config> {
        serde_yaml::from_str(content)
            .map_err(|e| MarkdownlintError::Config(format!("Failed to parse YAML: {}", e)))
    }

    fn parse_package_json(content: &str) -> Result<Config> {
        let package: serde_json::Value = serde_json::from_str(content).map_err(|e| {
            MarkdownlintError::Config(format!("Failed to parse package.json: {}", e))
        })?;

        if let Some(config_value) = package.get("markdownlint-cli2") {
            serde_json::from_value(config_value.clone()).map_err(|e| {
                MarkdownlintError::Config(format!("Invalid config in package.json: {}", e))
            })
        } else {
            Ok(Config::default())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    #[test]
    fn test_parse_package_json() {
        let content = r#"{
            "name": "test-package",
            "version": "1.0.0",
            "markdownlint-cli2": {
                "fix": true,
                "globs": ["docs/*.md"]
            }
        }"#;

        let config = ConfigLoader::parse_package_json(content).unwrap();
        assert!(config.fix);
        assert_eq!(config.globs.len(), 1);
    }

    #[test]
    fn test_parse_package_json_no_config() {
        let content = r#"{
            "name": "test-package",
            "version": "1.0.0"
        }"#;

        let config = ConfigLoader::parse_package_json(content).unwrap();
        assert!(!config.fix);
        assert!(config.globs.is_empty());
    }

    #[test]
    fn test_load_from_file() {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join(".markdownlint-cli2.jsonc");

        let mut file = fs::File::create(&config_path).unwrap();
        write!(file, r#"{{ "fix": true, "globs": ["*.md"] }}"#).unwrap();

        let config = ConfigLoader::load_from_file(&config_path).unwrap();
        assert!(config.fix);
        assert_eq!(config.globs.len(), 1);
    }

    #[test]
    fn test_discover_config() {
        let temp_dir = TempDir::new().unwrap();
        let sub_dir = temp_dir.path().join("subdir");
        fs::create_dir(&sub_dir).unwrap();

        let config_path = temp_dir.path().join(".markdownlint-cli2.jsonc");
        let mut file = fs::File::create(&config_path).unwrap();
        write!(file, r#"{{ "fix": true }}"#).unwrap();

        let config = ConfigLoader::discover_config(&sub_dir).unwrap();
        assert!(config.is_some());
        assert!(config.unwrap().fix);
    }
}