Skip to main content

llmwiki_tooling/cmd/
lint.rs

1use crate::config::{MatchMode, RuleConfig, Severity, WikiConfig};
2use crate::error::WikiError;
3use crate::link_index::LinkIndex;
4use crate::resolve;
5use crate::walk::{is_markdown_file, wiki_walk_builder};
6use crate::wiki::Wiki;
7
8/// Severity filter for lint output.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SeverityFilter {
11    /// Show all non-off checks (default).
12    All,
13    /// Show only errors.
14    ErrorOnly,
15    /// Show only warnings.
16    WarnOnly,
17}
18
19struct LintResult {
20    errors: usize,
21    warnings: usize,
22}
23
24impl LintResult {
25    fn new() -> Self {
26        Self {
27            errors: 0,
28            warnings: 0,
29        }
30    }
31
32    fn tally(&mut self, count: usize, severity: Severity) {
33        match severity {
34            Severity::Error => self.errors += count,
35            Severity::Warn => self.warnings += count,
36            Severity::Off => {}
37        }
38    }
39}
40
41fn should_show(severity: Severity, filter: SeverityFilter) -> bool {
42    match (severity, filter) {
43        (Severity::Off, _) => false,
44        (_, SeverityFilter::All) => true,
45        (Severity::Error, SeverityFilter::ErrorOnly) => true,
46        (Severity::Warn, SeverityFilter::WarnOnly) => true,
47        _ => false,
48    }
49}
50
51/// Run all lint checks. Returns the number of errors (not warnings).
52pub fn lint(wiki: &Wiki, filter: SeverityFilter) -> Result<usize, WikiError> {
53    let mut result = LintResult::new();
54    let config = wiki.config();
55
56    // Wiki-wide structural checks
57    if should_show(config.checks.broken_links, filter) {
58        let count = run_broken_links(wiki)?;
59        result.tally(count, config.checks.broken_links);
60    }
61
62    if should_show(config.checks.orphan_pages, filter) {
63        let link_index = LinkIndex::build(wiki)?;
64        let count = run_orphan_pages(wiki, &link_index);
65        result.tally(count, config.checks.orphan_pages);
66    }
67
68    if should_show(config.checks.index_coverage, filter)
69        && let Some(index_path) = wiki.index_path()
70    {
71        if index_path.is_file() {
72            let count = run_index_coverage(wiki, &index_path)?;
73            result.tally(count, config.checks.index_coverage);
74        } else {
75            eprintln!(
76                "warn: index file '{}' not found, skipping index coverage",
77                index_path.display()
78            );
79        }
80    }
81
82    // Parameterized rules
83    for rule in &config.rules {
84        let severity = rule.severity();
85        if !should_show(severity, filter) {
86            continue;
87        }
88        let count = run_rule(wiki, rule)?;
89        result.tally(count, severity);
90    }
91
92    if result.errors > 0 || result.warnings > 0 {
93        let mut parts = Vec::new();
94        if result.errors > 0 {
95            parts.push(format!("{} error(s)", result.errors));
96        }
97        if result.warnings > 0 {
98            parts.push(format!("{} warning(s)", result.warnings));
99        }
100        eprintln!("{}", parts.join(", "));
101    }
102
103    Ok(result.errors)
104}
105
106fn run_rule(wiki: &Wiki, rule: &RuleConfig) -> Result<usize, WikiError> {
107    match rule {
108        RuleConfig::RequiredSections {
109            dirs,
110            sections,
111            severity,
112            ..
113        } => run_required_sections(wiki, dirs, sections, *severity),
114        RuleConfig::RequiredFrontmatter {
115            dirs,
116            fields,
117            severity,
118            ..
119        } => run_required_frontmatter(wiki, dirs, fields, *severity),
120        RuleConfig::MirrorParity {
121            left,
122            right,
123            severity,
124            ..
125        } => run_mirror_parity(wiki, left, right, *severity),
126        RuleConfig::CitationPattern {
127            name,
128            dirs,
129            pattern,
130            match_in,
131            match_mode,
132            severity,
133        } => run_citation_pattern(wiki, name, dirs, pattern, match_in, *match_mode, *severity),
134    }
135}
136
137fn run_broken_links(wiki: &Wiki) -> Result<usize, WikiError> {
138    let severity = wiki.config().checks.broken_links;
139    let mut count = 0;
140    for file_path in wiki.all_scannable_files() {
141        let broken = resolve::find_broken_links(wiki, &file_path)?;
142        let source = wiki.source(&file_path)?;
143        let rel_path = wiki.rel_path(&file_path);
144        for (wl, reason) in &broken {
145            let ref_text = &source[wl.byte_range.clone()];
146            eprintln!(
147                "{severity}[broken-link]: {} in {}",
148                ref_text.trim(),
149                rel_path.display(),
150            );
151            eprintln!("  -> {reason}");
152            count += 1;
153        }
154    }
155    Ok(count)
156}
157
158fn run_orphan_pages(wiki: &Wiki, link_index: &LinkIndex) -> usize {
159    let orphans = link_index.orphans(wiki);
160    for page_id in &orphans {
161        if let Some(entry) = wiki.get(page_id) {
162            eprintln!(
163                "error[orphan]: {} has no inbound wikilinks",
164                entry.rel_path.display(),
165            );
166        }
167    }
168    orphans.len()
169}
170
171fn run_index_coverage(wiki: &Wiki, index_path: &std::path::Path) -> Result<usize, WikiError> {
172    let index_wikilinks = wiki.wikilinks(index_path)?;
173    let referenced: std::collections::HashSet<&str> =
174        index_wikilinks.iter().map(|wl| wl.page.as_str()).collect();
175
176    let mut count = 0;
177    for (page_id, entry) in wiki.pages() {
178        if !referenced.contains(page_id.as_str()) {
179            eprintln!(
180                "error[not-in-index]: {} is not listed in index",
181                entry.rel_path.display(),
182            );
183            count += 1;
184        }
185    }
186    Ok(count)
187}
188
189fn run_required_sections(
190    wiki: &Wiki,
191    dirs: &[String],
192    sections: &[String],
193    severity: Severity,
194) -> Result<usize, WikiError> {
195    let mut count = 0;
196    for entry in wiki.pages().values() {
197        if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
198            continue;
199        }
200        let file_path = wiki.entry_path(entry);
201        let headings = wiki.headings(&file_path)?;
202        for required in sections {
203            if !headings
204                .iter()
205                .any(|h| h.text.eq_ignore_ascii_case(required))
206            {
207                eprintln!(
208                    "{severity}[missing-section]: {} is missing '## {required}'",
209                    entry.rel_path.display(),
210                );
211                count += 1;
212            }
213        }
214    }
215    Ok(count)
216}
217
218fn run_required_frontmatter(
219    wiki: &Wiki,
220    dirs: &[String],
221    fields: &[String],
222    severity: Severity,
223) -> Result<usize, WikiError> {
224    let mut count = 0;
225    for entry in wiki.pages().values() {
226        if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
227            continue;
228        }
229        let file_path = wiki.entry_path(entry);
230        match wiki.frontmatter(&file_path)? {
231            Ok(Some(fm)) => {
232                for field in fields {
233                    if !fm.has_field(field) {
234                        eprintln!(
235                            "{severity}[missing-frontmatter]: {} is missing '{field}'",
236                            entry.rel_path.display(),
237                        );
238                        count += 1;
239                    }
240                }
241            }
242            Ok(None) => {
243                eprintln!(
244                    "{severity}[no-frontmatter]: {} has no frontmatter",
245                    entry.rel_path.display(),
246                );
247                count += 1;
248            }
249            Err(e) => {
250                eprintln!(
251                    "{severity}[bad-frontmatter]: {}: {e}",
252                    entry.rel_path.display(),
253                );
254                count += 1;
255            }
256        }
257    }
258    Ok(count)
259}
260
261fn run_mirror_parity(
262    wiki: &Wiki,
263    left: &str,
264    right: &str,
265    severity: Severity,
266) -> Result<usize, WikiError> {
267    let mut count = 0;
268
269    let left_dir = wiki.root().path().join(left);
270    let right_dir = wiki.root().path().join(right);
271
272    let left_stems = collect_md_stems(wiki, &left_dir)?;
273    let right_stems = collect_md_stems(wiki, &right_dir)?;
274
275    for stem in &left_stems {
276        if !right_stems.contains(stem) {
277            eprintln!("{severity}[missing-mirror]: {left}/{stem}.md has no {right}/{stem}.md",);
278            count += 1;
279        }
280    }
281    for stem in &right_stems {
282        if !left_stems.contains(stem) {
283            eprintln!("{severity}[missing-mirror]: {right}/{stem}.md has no {left}/{stem}.md",);
284            count += 1;
285        }
286    }
287
288    Ok(count)
289}
290
291fn collect_md_stems(
292    wiki: &Wiki,
293    dir: &std::path::Path,
294) -> Result<std::collections::HashSet<String>, WikiError> {
295    let mut stems = std::collections::HashSet::new();
296    if !dir.is_dir() {
297        return Ok(stems);
298    }
299    for entry in wiki_walk_builder(dir, wiki.root().path(), &wiki.config().ignore)?.build() {
300        let entry = entry.map_err(|e| WikiError::Walk {
301            path: dir.to_path_buf(),
302            source: e,
303        })?;
304        let path = entry.path();
305        if is_markdown_file(path)
306            && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
307        {
308            stems.insert(stem.to_owned());
309        }
310    }
311    Ok(stems)
312}
313
314/// Pre-loaded match directory data to avoid re-walking per citation capture.
315enum MatchDirCache {
316    Contents(Vec<String>),
317    /// Stems stored lowercase for O(1) case-insensitive lookup.
318    Stems(std::collections::HashSet<String>),
319}
320
321impl MatchDirCache {
322    fn load(wiki: &Wiki, dir: &std::path::Path, mode: MatchMode) -> Result<Self, WikiError> {
323        if !dir.is_dir() {
324            return Ok(match mode {
325                MatchMode::Content => Self::Contents(Vec::new()),
326                MatchMode::Filename => Self::Stems(std::collections::HashSet::new()),
327            });
328        }
329        let mut contents = Vec::new();
330        let mut stems = std::collections::HashSet::new();
331        for entry in wiki_walk_builder(dir, wiki.root().path(), &wiki.config().ignore)?.build() {
332            let entry = entry.map_err(|e| WikiError::Walk {
333                path: dir.to_path_buf(),
334                source: e,
335            })?;
336            let path = entry.path();
337            if !is_markdown_file(path) {
338                continue;
339            }
340            match mode {
341                MatchMode::Content => {
342                    let content =
343                        std::fs::read_to_string(path).map_err(|e| WikiError::ReadFile {
344                            path: path.to_path_buf(),
345                            source: e,
346                        })?;
347                    contents.push(content);
348                }
349                MatchMode::Filename => {
350                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
351                        stems.insert(stem.to_lowercase());
352                    }
353                }
354            }
355        }
356        Ok(match mode {
357            MatchMode::Content => Self::Contents(contents),
358            MatchMode::Filename => Self::Stems(stems),
359        })
360    }
361
362    fn contains(&self, needle: &str) -> bool {
363        match self {
364            Self::Contents(pages) => pages.iter().any(|c| c.contains(needle)),
365            Self::Stems(stems) => stems.contains(&needle.to_lowercase()),
366        }
367    }
368}
369
370#[allow(clippy::too_many_arguments)]
371fn run_citation_pattern(
372    wiki: &Wiki,
373    name: &str,
374    dirs: &[String],
375    pattern: &str,
376    match_in: &str,
377    match_mode: MatchMode,
378    severity: Severity,
379) -> Result<usize, WikiError> {
380    let regex = regex_lite::Regex::new(pattern).expect("regex pre-validated at config load");
381
382    let match_dir = wiki.root().path().join(match_in);
383    let cache = MatchDirCache::load(wiki, &match_dir, match_mode)?;
384
385    let mut count = 0;
386
387    for entry in wiki.pages().values() {
388        if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
389            continue;
390        }
391        let file_path = wiki.entry_path(entry);
392        let source = wiki.source(&file_path)?;
393
394        for cap in regex.captures_iter(source) {
395            let Some(id) = cap.name("id").map(|m| m.as_str()) else {
396                continue;
397            };
398
399            if !cache.contains(id) {
400                eprintln!(
401                    "{severity}[{name}]: {} references '{id}' but no matching page in {match_in}",
402                    entry.rel_path.display(),
403                );
404                count += 1;
405            }
406        }
407    }
408
409    Ok(count)
410}