1use std::collections::HashSet;
2use std::path::Path;
3
4use serde::Deserialize;
5
6use crate::error::ConfigError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum Severity {
11 #[default]
13 Error,
14 Warn,
16 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#[derive(Debug, Clone)]
47pub struct WikiConfig {
48 pub index: Option<String>,
50 pub directories: Vec<DirectoryConfig>,
52 pub ignore: IgnoreConfig,
54 pub linking: LinkingConfig,
56 pub checks: ChecksConfig,
58 pub rules: Vec<RuleConfig>,
60}
61
62#[derive(Debug, Clone)]
64pub struct DirectoryConfig {
65 pub path: String,
67 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#[derive(Debug, Clone)]
93pub struct IgnoreConfig {
94 pub default_patterns: bool,
96 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#[derive(Debug, Clone)]
121pub struct LinkingConfig {
122 pub exclude: HashSet<String>,
124 pub autolink_field: String,
126}
127
128#[derive(Debug, Clone)]
130pub struct ChecksConfig {
131 pub broken_links: Severity,
132 pub orphan_pages: Severity,
133 pub index_coverage: Severity,
134}
135
136#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum MatchMode {
178 #[default]
180 Content,
181 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#[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
306fn 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 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 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 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 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 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 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 pub fn directory_for(&self, rel_path: &Path) -> Option<&DirectoryConfig> {
441 let rel_str = rel_path.to_str()?;
442 self.directories
444 .iter()
445 .find(|d| rel_str.starts_with(&d.path) || d.path == ".")
446 }
447
448 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 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 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
565fn 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 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}