Skip to main content

rumdl_lib/utils/
mkdocs_config.rs

1//! Shared MkDocs configuration utilities.
2//!
3//! Provides discovery and parsing of mkdocs.yml/mkdocs.yaml files,
4//! with caching for efficient repeated lookups.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::{LazyLock, Mutex};
10
11/// Cache: canonicalized mkdocs.yml path -> resolved docs_dir (absolute)
12static DOCS_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, PathBuf>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
13
14/// Find mkdocs.yml or mkdocs.yaml by walking up from `start_path`.
15///
16/// Returns the canonicalized path to the mkdocs config file, or None if not found.
17pub fn find_mkdocs_yml(start_path: &Path) -> Option<PathBuf> {
18    let mut current = if start_path.is_file() {
19        start_path.parent()?.to_path_buf()
20    } else {
21        start_path.to_path_buf()
22    };
23
24    loop {
25        for filename in &["mkdocs.yml", "mkdocs.yaml"] {
26            let mkdocs_path = current.join(filename);
27            if mkdocs_path.exists() {
28                return mkdocs_path.canonicalize().ok();
29            }
30        }
31
32        if !current.pop() {
33            break;
34        }
35    }
36
37    None
38}
39
40/// Minimal mkdocs.yml structure for extracting docs_dir.
41#[derive(Debug, Deserialize)]
42struct MkDocsYmlPartial {
43    #[serde(default = "default_docs_dir")]
44    docs_dir: String,
45}
46
47fn default_docs_dir() -> String {
48    "docs".to_string()
49}
50
51/// Resolve the `docs_dir` for a project by finding and parsing mkdocs.yml.
52///
53/// Results are cached by the canonicalized mkdocs.yml path to avoid
54/// repeated filesystem operations and YAML parsing.
55///
56/// `start_path` should be the markdown file being checked or its parent directory.
57/// Returns the absolute path to the docs directory, or None if no mkdocs.yml is found.
58pub fn resolve_docs_dir(start_path: &Path) -> Option<PathBuf> {
59    let mkdocs_path = find_mkdocs_yml(start_path)?;
60
61    // Check cache first
62    if let Ok(cache) = DOCS_DIR_CACHE.lock()
63        && let Some(docs_dir) = cache.get(&mkdocs_path)
64    {
65        return Some(docs_dir.clone());
66    }
67
68    // Parse mkdocs.yml to get docs_dir
69    let content = std::fs::read_to_string(&mkdocs_path).ok()?;
70    let config: MkDocsYmlPartial = serde_yml::from_str(&content).ok()?;
71
72    // Resolve docs_dir relative to mkdocs.yml location
73    let mkdocs_dir = mkdocs_path.parent()?;
74    let docs_dir = if Path::new(&config.docs_dir).is_absolute() {
75        PathBuf::from(&config.docs_dir)
76    } else {
77        mkdocs_dir.join(&config.docs_dir)
78    };
79
80    // Only cache if the docs_dir actually exists
81    if docs_dir.exists()
82        && let Ok(mut cache) = DOCS_DIR_CACHE.lock()
83    {
84        cache.insert(mkdocs_path, docs_dir.clone());
85    }
86
87    Some(docs_dir)
88}
89
90/// Clear the docs_dir cache. Useful for testing.
91#[cfg(test)]
92pub fn clear_docs_dir_cache() {
93    if let Ok(mut cache) = DOCS_DIR_CACHE.lock() {
94        cache.clear();
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::fs;
102    use tempfile::tempdir;
103
104    #[test]
105    fn test_find_mkdocs_yml() {
106        let temp_dir = tempdir().unwrap();
107        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
108        fs::write(&mkdocs_path, "site_name: test\n").unwrap();
109
110        let sub_dir = temp_dir.path().join("docs");
111        fs::create_dir_all(&sub_dir).unwrap();
112
113        let result = find_mkdocs_yml(&sub_dir);
114        assert!(result.is_some());
115    }
116
117    #[test]
118    fn test_find_mkdocs_yaml_extension() {
119        let temp_dir = tempdir().unwrap();
120        let mkdocs_path = temp_dir.path().join("mkdocs.yaml");
121        fs::write(&mkdocs_path, "site_name: test\n").unwrap();
122
123        let result = find_mkdocs_yml(temp_dir.path());
124        assert!(result.is_some());
125    }
126
127    #[test]
128    fn test_resolve_docs_dir_default() {
129        clear_docs_dir_cache();
130        let temp_dir = tempdir().unwrap();
131        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
132        fs::write(&mkdocs_path, "site_name: test\n").unwrap();
133
134        let docs_dir = temp_dir.path().join("docs");
135        fs::create_dir_all(&docs_dir).unwrap();
136
137        let result = resolve_docs_dir(temp_dir.path());
138        assert!(result.is_some());
139        let result_path = result.unwrap();
140        assert!(result_path.ends_with("docs"));
141    }
142
143    #[test]
144    fn test_resolve_docs_dir_custom() {
145        clear_docs_dir_cache();
146        let temp_dir = tempdir().unwrap();
147        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
148        fs::write(&mkdocs_path, "site_name: test\ndocs_dir: documentation\n").unwrap();
149
150        let docs_dir = temp_dir.path().join("documentation");
151        fs::create_dir_all(&docs_dir).unwrap();
152
153        let result = resolve_docs_dir(temp_dir.path());
154        assert!(result.is_some());
155        let result_path = result.unwrap();
156        assert!(result_path.ends_with("documentation"));
157    }
158
159    #[test]
160    fn test_resolve_docs_dir_no_mkdocs_yml() {
161        clear_docs_dir_cache();
162        let temp_dir = tempdir().unwrap();
163        let result = resolve_docs_dir(temp_dir.path());
164        assert!(result.is_none());
165    }
166}