Skip to main content

llmwiki_tooling/
config.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use serde::Deserialize;
5
6use crate::error::ConfigError;
7
8/// Severity level for a check or rule.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum Severity {
11    /// Causes non-zero exit code.
12    #[default]
13    Error,
14    /// Prints finding but does not affect exit code.
15    Warn,
16    /// Suppressed entirely.
17    Off,
18}
19
20impl std::fmt::Display for Severity {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Error => f.write_str("error"),
24            Self::Warn => f.write_str("warn"),
25            Self::Off => f.write_str("off"),
26        }
27    }
28}
29
30impl<'de> Deserialize<'de> for Severity {
31    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
32        let s = String::deserialize(deserializer)?;
33        match s.as_str() {
34            "error" => Ok(Self::Error),
35            "warn" => Ok(Self::Warn),
36            "off" => Ok(Self::Off),
37            other => Err(serde::de::Error::unknown_variant(
38                other,
39                &["error", "warn", "off"],
40            )),
41        }
42    }
43}
44
45/// Complete wiki configuration, parsed from `wiki.toml` or auto-detected.
46#[derive(Debug, Clone)]
47pub struct WikiConfig {
48    /// Path to the index file relative to wiki root. `None` means no index file.
49    pub index: Option<String>,
50    /// Content directories, sorted most-specific-first for resolution.
51    pub directories: Vec<DirectoryConfig>,
52    /// Markdown walk ignore settings.
53    pub ignore: IgnoreConfig,
54    /// Linking behavior settings.
55    pub linking: LinkingConfig,
56    /// Wiki-wide structural check severities.
57    pub checks: ChecksConfig,
58    /// Parameterized rules scoped to directories.
59    pub rules: Vec<RuleConfig>,
60}
61
62/// A directory containing wiki pages.
63#[derive(Debug, Clone)]
64pub struct DirectoryConfig {
65    /// Path relative to wiki root.
66    pub path: String,
67    /// Whether pages in this directory feed bare mention detection.
68    pub autolink: bool,
69}
70
71const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
72    ".agents/",
73    ".claude/",
74    ".clinerules/",
75    ".codex/",
76    ".continue/",
77    ".cursor/",
78    ".gemini/",
79    ".github/",
80    ".gsd/",
81    ".kiro/",
82    ".kilocode/",
83    ".opencode/",
84    ".openhands/",
85    ".pi/",
86    ".qwen/",
87    ".roo/",
88    ".windsurf/",
89];
90
91/// Markdown walk ignore settings.
92#[derive(Debug, Clone)]
93pub struct IgnoreConfig {
94    /// Whether built-in non-wiki tool directory patterns are included.
95    pub default_patterns: bool,
96    /// Extra gitignore-style patterns, additive with defaults.
97    pub patterns: Vec<String>,
98}
99
100impl Default for IgnoreConfig {
101    fn default() -> Self {
102        Self {
103            default_patterns: true,
104            patterns: Vec::new(),
105        }
106    }
107}
108
109impl IgnoreConfig {
110    pub(crate) fn effective_patterns(&self) -> impl Iterator<Item = &str> {
111        DEFAULT_IGNORE_PATTERNS
112            .iter()
113            .copied()
114            .filter(|_| self.default_patterns)
115            .chain(self.patterns.iter().map(String::as_str))
116    }
117}
118
119/// Global linking behavior.
120#[derive(Debug, Clone)]
121pub struct LinkingConfig {
122    /// Page names to never auto-link.
123    pub exclude: HashSet<String>,
124    /// Frontmatter field for per-page auto-link opt-out.
125    pub autolink_field: String,
126}
127
128/// Wiki-wide structural check severities.
129#[derive(Debug, Clone)]
130pub struct ChecksConfig {
131    pub broken_links: Severity,
132    pub orphan_pages: Severity,
133    pub index_coverage: Severity,
134}
135
136/// A parameterized rule scoped to specific directories.
137#[derive(Debug, Clone)]
138pub enum RuleConfig {
139    RequiredSections {
140        dirs: Vec<String>,
141        sections: Vec<String>,
142        severity: Severity,
143    },
144    RequiredFrontmatter {
145        dirs: Vec<String>,
146        fields: Vec<String>,
147        severity: Severity,
148    },
149    MirrorParity {
150        left: String,
151        right: String,
152        severity: Severity,
153    },
154    CitationPattern {
155        name: String,
156        dirs: Vec<String>,
157        pattern: String,
158        match_in: String,
159        match_mode: MatchMode,
160        severity: Severity,
161    },
162}
163
164impl RuleConfig {
165    pub fn severity(&self) -> Severity {
166        match self {
167            Self::RequiredSections { severity, .. }
168            | Self::RequiredFrontmatter { severity, .. }
169            | Self::MirrorParity { severity, .. }
170            | Self::CitationPattern { severity, .. } => *severity,
171        }
172    }
173}
174
175/// How a citation pattern match is verified against `match_in` pages.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum MatchMode {
178    /// Search page file contents for the captured ID string.
179    #[default]
180    Content,
181    /// Check if a page with the captured ID as its filename exists.
182    Filename,
183}
184
185impl<'de> Deserialize<'de> for MatchMode {
186    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187        let s = String::deserialize(deserializer)?;
188        match s.as_str() {
189            "content" => Ok(Self::Content),
190            "filename" => Ok(Self::Filename),
191            other => Err(serde::de::Error::unknown_variant(
192                other,
193                &["content", "filename"],
194            )),
195        }
196    }
197}
198
199// --- TOML deserialization types ---
200
201#[derive(Deserialize)]
202struct RawConfig {
203    index: Option<String>,
204    #[serde(default)]
205    directories: Vec<RawDirectoryConfig>,
206    #[serde(default)]
207    ignore: RawIgnoreConfig,
208    #[serde(default)]
209    linking: RawLinkingConfig,
210    #[serde(default)]
211    checks: RawChecksConfig,
212    #[serde(default)]
213    rules: Vec<RawRuleConfig>,
214}
215
216#[derive(Deserialize)]
217struct RawDirectoryConfig {
218    path: String,
219    #[serde(default = "default_true")]
220    autolink: bool,
221}
222
223fn default_true() -> bool {
224    true
225}
226
227#[derive(Deserialize)]
228struct RawIgnoreConfig {
229    #[serde(default = "default_true")]
230    default_patterns: bool,
231    #[serde(default)]
232    patterns: Vec<String>,
233}
234
235impl Default for RawIgnoreConfig {
236    fn default() -> Self {
237        Self {
238            default_patterns: true,
239            patterns: Vec::new(),
240        }
241    }
242}
243
244#[derive(Deserialize, Default)]
245struct RawLinkingConfig {
246    #[serde(default)]
247    exclude: Vec<String>,
248    #[serde(default = "default_autolink_field")]
249    autolink_field: String,
250}
251
252fn default_autolink_field() -> String {
253    "autolink".to_owned()
254}
255
256#[derive(Deserialize, Default)]
257struct RawChecksConfig {
258    #[serde(default)]
259    broken_links: Option<Severity>,
260    #[serde(default)]
261    orphan_pages: Option<Severity>,
262    #[serde(default)]
263    index_coverage: Option<Severity>,
264}
265
266#[derive(Deserialize)]
267#[serde(tag = "check")]
268enum RawRuleConfig {
269    #[serde(rename = "required-sections")]
270    RequiredSections {
271        dirs: Vec<String>,
272        sections: Vec<String>,
273        #[serde(default)]
274        severity: Option<Severity>,
275    },
276    #[serde(rename = "required-frontmatter")]
277    RequiredFrontmatter {
278        dirs: Vec<String>,
279        fields: Vec<String>,
280        #[serde(default)]
281        severity: Option<Severity>,
282    },
283    #[serde(rename = "mirror-parity")]
284    MirrorParity {
285        left: String,
286        right: String,
287        #[serde(default)]
288        severity: Option<Severity>,
289    },
290    #[serde(rename = "citation-pattern")]
291    CitationPattern {
292        name: String,
293        dirs: Vec<String>,
294        #[serde(default)]
295        pattern: Option<String>,
296        #[serde(default)]
297        preset: Option<String>,
298        match_in: String,
299        #[serde(default)]
300        match_mode: Option<MatchMode>,
301        #[serde(default)]
302        severity: Option<Severity>,
303    },
304}
305
306/// Built-in citation pattern presets.
307fn resolve_preset(name: &str) -> Result<(String, MatchMode), ConfigError> {
308    match name {
309        "bold-method-year" => Ok((
310            r"\*\*(?P<id>[A-Za-z][A-Za-z0-9-]+)\*\*\s*\([^)]*\d{4}[^)]*\)".to_owned(),
311            MatchMode::Filename,
312        )),
313        other => Err(ConfigError::UnknownPreset(other.to_owned())),
314    }
315}
316
317impl WikiConfig {
318    /// Load config from `wiki.toml` in the given root directory.
319    /// Returns `None` if `wiki.toml` doesn't exist.
320    pub fn load(root: &Path) -> Result<Option<Self>, ConfigError> {
321        let config_path = root.join("wiki.toml");
322        let content = match std::fs::read_to_string(&config_path) {
323            Ok(content) => content,
324            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
325            Err(e) => {
326                return Err(ConfigError::Read {
327                    path: config_path,
328                    source: e,
329                });
330            }
331        };
332        let raw: RawConfig = toml::from_str(&content).map_err(|e| ConfigError::Parse {
333            path: config_path,
334            source: e,
335        })?;
336        Self::from_raw(raw).map(Some)
337    }
338
339    /// Auto-detect config when no `wiki.toml` exists.
340    pub fn auto_detect(root: &Path) -> Self {
341        let has_wiki_dir = root.join("wiki").is_dir();
342        let dir_path = if has_wiki_dir { "wiki" } else { "." };
343
344        Self {
345            index: Some("index.md".to_owned()),
346            directories: vec![DirectoryConfig {
347                path: dir_path.to_owned(),
348                autolink: true,
349            }],
350            ignore: IgnoreConfig::default(),
351            linking: LinkingConfig {
352                exclude: HashSet::new(),
353                autolink_field: default_autolink_field(),
354            },
355            checks: ChecksConfig {
356                broken_links: Severity::Error,
357                orphan_pages: Severity::Error,
358                index_coverage: Severity::Error,
359            },
360            rules: Vec::new(),
361        }
362    }
363
364    /// Load config from wiki.toml if present, otherwise auto-detect.
365    pub fn load_or_detect(root: &Path) -> Result<Self, ConfigError> {
366        match Self::load(root)? {
367            Some(config) => Ok(config),
368            None => Ok(Self::auto_detect(root)),
369        }
370    }
371
372    fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
373        let mut directories: Vec<DirectoryConfig> = if raw.directories.is_empty() {
374            // No directories declared — auto-detect
375            vec![DirectoryConfig {
376                path: "wiki".to_owned(),
377                autolink: true,
378            }]
379        } else {
380            raw.directories
381                .into_iter()
382                .map(|d| DirectoryConfig {
383                    path: normalize_path(&d.path),
384                    autolink: d.autolink,
385                })
386                .collect()
387        };
388
389        // Sort most-specific first (longest path) for resolution
390        directories.sort_by_key(|dir| std::cmp::Reverse(dir.path.len()));
391
392        let ignore = IgnoreConfig {
393            default_patterns: raw.ignore.default_patterns,
394            patterns: raw.ignore.patterns,
395        };
396        validate_ignore_patterns(&ignore)?;
397
398        let linking = LinkingConfig {
399            exclude: raw.linking.exclude.into_iter().collect(),
400            autolink_field: raw.linking.autolink_field,
401        };
402
403        let checks = ChecksConfig {
404            broken_links: raw.checks.broken_links.unwrap_or(Severity::Error),
405            orphan_pages: raw.checks.orphan_pages.unwrap_or(Severity::Error),
406            index_coverage: raw.checks.index_coverage.unwrap_or(Severity::Error),
407        };
408
409        let mut rules = Vec::new();
410        for raw_rule in raw.rules {
411            rules.push(convert_rule(raw_rule)?);
412        }
413
414        // Validate citation patterns compile as regex
415        for rule in &rules {
416            if let RuleConfig::CitationPattern { pattern, name, .. } = rule {
417                regex_lite::Regex::new(pattern).map_err(|e| ConfigError::InvalidPattern {
418                    name: name.clone(),
419                    source: e,
420                })?;
421            }
422        }
423
424        Ok(Self {
425            index: match raw.index {
426                Some(s) if s.is_empty() => None,
427                Some(s) => Some(s),
428                None => Some("index.md".to_owned()),
429            },
430            directories,
431            ignore,
432            linking,
433            checks,
434            rules,
435        })
436    }
437
438    /// Get the directory config that applies to a given relative path.
439    /// Returns the most-specific matching directory (longest prefix match).
440    pub fn directory_for(&self, rel_path: &Path) -> Option<&DirectoryConfig> {
441        let rel_str = rel_path.to_str()?;
442        // Directories are sorted most-specific first
443        self.directories
444            .iter()
445            .find(|d| rel_str.starts_with(&d.path) || d.path == ".")
446    }
447
448    /// Check if a page at the given relative path should be auto-linked.
449    pub fn is_autolink_dir(&self, rel_path: &Path) -> bool {
450        self.directory_for(rel_path)
451            .map(|d| d.autolink)
452            .unwrap_or(false)
453    }
454
455    /// Check if a relative path matches a directory prefix from a rule's `dirs` list.
456    pub fn matches_dirs(rel_path: &Path, dirs: &[String]) -> bool {
457        let Some(rel_str) = rel_path.to_str() else {
458            return false;
459        };
460        dirs.iter().any(|d| rel_str.starts_with(d.as_str()))
461    }
462
463    /// All mirror-parity rules' `right` paths (non-wiki directories used for parity checks).
464    pub fn mirror_paths(&self) -> Vec<(&str, &str)> {
465        self.rules
466            .iter()
467            .filter_map(|r| match r {
468                RuleConfig::MirrorParity { left, right, .. } => {
469                    Some((left.as_str(), right.as_str()))
470                }
471                _ => None,
472            })
473            .collect()
474    }
475}
476
477fn validate_ignore_patterns(ignore: &IgnoreConfig) -> Result<(), ConfigError> {
478    let mut builder = ignore::gitignore::GitignoreBuilder::new(Path::new(""));
479    for pattern in ignore.effective_patterns() {
480        if pattern.starts_with('!') {
481            return Err(ConfigError::Validation(format!(
482                "ignore pattern '{pattern}' cannot start with '!'"
483            )));
484        }
485        builder
486            .add_line(None, pattern)
487            .map_err(|source| ConfigError::InvalidIgnorePattern {
488                pattern: pattern.to_owned(),
489                source,
490            })?;
491    }
492    builder
493        .build()
494        .map_err(|source| ConfigError::CompileIgnorePatterns { source })?;
495    Ok(())
496}
497
498fn convert_rule(raw: RawRuleConfig) -> Result<RuleConfig, ConfigError> {
499    match raw {
500        RawRuleConfig::RequiredSections {
501            dirs,
502            sections,
503            severity,
504        } => Ok(RuleConfig::RequiredSections {
505            dirs: dirs.into_iter().map(|d| normalize_path(&d)).collect(),
506            sections,
507            severity: severity.unwrap_or(Severity::Error),
508        }),
509        RawRuleConfig::RequiredFrontmatter {
510            dirs,
511            fields,
512            severity,
513        } => Ok(RuleConfig::RequiredFrontmatter {
514            dirs: dirs.into_iter().map(|d| normalize_path(&d)).collect(),
515            fields,
516            severity: severity.unwrap_or(Severity::Error),
517        }),
518        RawRuleConfig::MirrorParity {
519            left,
520            right,
521            severity,
522        } => Ok(RuleConfig::MirrorParity {
523            left: normalize_path(&left),
524            right: normalize_path(&right),
525            severity: severity.unwrap_or(Severity::Error),
526        }),
527        RawRuleConfig::CitationPattern {
528            name,
529            dirs,
530            pattern,
531            preset,
532            match_in,
533            match_mode,
534            severity,
535        } => {
536            let (resolved_pattern, resolved_mode) = match (pattern, preset) {
537                (Some(p), None) => (p, match_mode.unwrap_or(MatchMode::Content)),
538                (None, Some(preset_name)) => {
539                    let (p, m) = resolve_preset(&preset_name)?;
540                    (p, match_mode.unwrap_or(m))
541                }
542                (Some(_), Some(_)) => {
543                    return Err(ConfigError::Validation(format!(
544                        "citation-pattern '{name}': cannot specify both 'pattern' and 'preset'"
545                    )));
546                }
547                (None, None) => {
548                    return Err(ConfigError::Validation(format!(
549                        "citation-pattern '{name}': must specify either 'pattern' or 'preset'"
550                    )));
551                }
552            };
553            Ok(RuleConfig::CitationPattern {
554                name,
555                dirs: dirs.into_iter().map(|d| normalize_path(&d)).collect(),
556                pattern: resolved_pattern,
557                match_in: normalize_path(&match_in),
558                match_mode: resolved_mode,
559                severity: severity.unwrap_or(Severity::Warn),
560            })
561        }
562    }
563}
564
565/// Strip trailing slashes for consistent prefix matching.
566fn normalize_path(path: &str) -> String {
567    path.trim_end_matches('/').to_owned()
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn parses_minimal_config() {
576        let toml = r#"
577[[directories]]
578path = "wiki"
579"#;
580        let raw: RawConfig = toml::from_str(toml).unwrap();
581        let config = WikiConfig::from_raw(raw).unwrap();
582        assert_eq!(config.directories.len(), 1);
583        assert_eq!(config.directories[0].path, "wiki");
584        assert!(config.directories[0].autolink);
585        assert_eq!(config.checks.broken_links, Severity::Error);
586    }
587
588    #[test]
589    fn parses_full_config() {
590        let toml = r#"
591index = "contents.md"
592
593[[directories]]
594path = "wiki"
595
596[[directories]]
597path = "wiki/papers"
598autolink = false
599
600[linking]
601exclude = ["the", "a"]
602autolink_field = "auto"
603
604[checks]
605broken_links = "error"
606orphan_pages = "warn"
607index_coverage = "off"
608
609[[rules]]
610check = "required-sections"
611dirs = ["wiki/concepts"]
612sections = ["See also"]
613severity = "error"
614
615[[rules]]
616check = "mirror-parity"
617left = "wiki/papers"
618right = "raw/papers"
619severity = "warn"
620
621[[rules]]
622check = "citation-pattern"
623name = "arxiv"
624dirs = ["wiki"]
625pattern = 'arxiv\.org/abs/(?P<id>\d{4}\.\d{4,5})'
626match_in = "wiki/papers"
627severity = "warn"
628
629[[rules]]
630check = "citation-pattern"
631name = "bold-method"
632preset = "bold-method-year"
633dirs = ["wiki"]
634match_in = "wiki/papers"
635severity = "warn"
636"#;
637        let raw: RawConfig = toml::from_str(toml).unwrap();
638        let config = WikiConfig::from_raw(raw).unwrap();
639
640        assert_eq!(config.index.as_deref(), Some("contents.md"));
641        assert!(config.linking.exclude.contains("the"));
642        assert_eq!(config.linking.autolink_field, "auto");
643        assert_eq!(config.checks.orphan_pages, Severity::Warn);
644        assert_eq!(config.checks.index_coverage, Severity::Off);
645        assert_eq!(config.rules.len(), 4);
646
647        // Most specific directory first
648        assert_eq!(config.directories[0].path, "wiki/papers");
649        assert!(!config.directories[0].autolink);
650        assert_eq!(config.directories[1].path, "wiki");
651        assert!(config.directories[1].autolink);
652    }
653
654    #[test]
655    fn directory_resolution_most_specific_wins() {
656        let config = WikiConfig {
657            index: None,
658            directories: vec![
659                DirectoryConfig {
660                    path: "wiki/papers".to_owned(),
661                    autolink: false,
662                },
663                DirectoryConfig {
664                    path: "wiki".to_owned(),
665                    autolink: true,
666                },
667            ],
668            ignore: IgnoreConfig::default(),
669            linking: LinkingConfig {
670                exclude: HashSet::new(),
671                autolink_field: "autolink".to_owned(),
672            },
673            checks: ChecksConfig {
674                broken_links: Severity::Error,
675                orphan_pages: Severity::Error,
676                index_coverage: Severity::Error,
677            },
678            rules: Vec::new(),
679        };
680
681        assert!(config.is_autolink_dir(Path::new("wiki/concepts/GRPO.md")));
682        assert!(!config.is_autolink_dir(Path::new("wiki/papers/deepseek.md")));
683    }
684
685    #[test]
686    fn auto_detect_with_wiki_dir() {
687        let dir = tempfile::tempdir().unwrap();
688        std::fs::create_dir(dir.path().join("wiki")).unwrap();
689        std::fs::write(dir.path().join("index.md"), "# Index").unwrap();
690
691        let config = WikiConfig::auto_detect(dir.path());
692        assert_eq!(config.directories[0].path, "wiki");
693        assert_eq!(config.index.as_deref(), Some("index.md"));
694    }
695
696    #[test]
697    fn auto_detect_flat_wiki() {
698        let dir = tempfile::tempdir().unwrap();
699        std::fs::write(dir.path().join("index.md"), "# Index").unwrap();
700
701        let config = WikiConfig::auto_detect(dir.path());
702        assert_eq!(config.directories[0].path, ".");
703    }
704
705    #[test]
706    fn rejects_pattern_and_preset_together() {
707        let toml = r#"
708[[rules]]
709check = "citation-pattern"
710name = "test"
711dirs = ["wiki"]
712pattern = "foo"
713preset = "bold-method-year"
714match_in = "wiki"
715"#;
716        let raw: RawConfig = toml::from_str(toml).unwrap();
717        let err = WikiConfig::from_raw(raw).unwrap_err();
718        assert!(err.to_string().contains("cannot specify both"));
719    }
720
721    #[test]
722    fn rejects_unknown_preset() {
723        let toml = r#"
724[[rules]]
725check = "citation-pattern"
726name = "test"
727dirs = ["wiki"]
728preset = "nonexistent"
729match_in = "wiki"
730"#;
731        let raw: RawConfig = toml::from_str(toml).unwrap();
732        let err = WikiConfig::from_raw(raw).unwrap_err();
733        assert!(err.to_string().contains("nonexistent"));
734    }
735
736    #[test]
737    fn parses_ignore_config() {
738        let toml = r#"
739[ignore]
740default_patterns = false
741patterns = ["generated/"]
742"#;
743        let raw: RawConfig = toml::from_str(toml).unwrap();
744        let config = WikiConfig::from_raw(raw).unwrap();
745
746        assert!(!config.ignore.default_patterns);
747        assert_eq!(config.ignore.patterns, ["generated/"]);
748        assert_eq!(
749            config.ignore.effective_patterns().collect::<Vec<_>>(),
750            ["generated/"]
751        );
752    }
753
754    #[test]
755    fn rejects_unignore_patterns() {
756        let toml = r#"
757[ignore]
758patterns = ["!.claude/"]
759"#;
760        let raw: RawConfig = toml::from_str(toml).unwrap();
761        let err = WikiConfig::from_raw(raw).unwrap_err();
762        assert!(err.to_string().contains("cannot start with '!'"));
763    }
764
765    #[test]
766    fn matches_dirs_prefix() {
767        assert!(WikiConfig::matches_dirs(
768            Path::new("wiki/concepts/GRPO.md"),
769            &["wiki/concepts".to_owned()]
770        ));
771        assert!(WikiConfig::matches_dirs(
772            Path::new("wiki/concepts/GRPO.md"),
773            &["wiki".to_owned()]
774        ));
775        assert!(!WikiConfig::matches_dirs(
776            Path::new("wiki/papers/foo.md"),
777            &["wiki/concepts".to_owned()]
778        ));
779    }
780}