Skip to main content

llm_wiki/ops/
lint.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::sync::Arc;
4
5use anyhow::Result;
6use petgraph::graph::{NodeIndex, UnGraph};
7use serde::Serialize;
8use tantivy::schema::Value;
9use tantivy::{
10    Term,
11    query::{AllQuery, TermQuery},
12    schema::IndexRecordOption,
13};
14
15use crate::engine::EngineState;
16use crate::graph::{GraphFilter, WikiGraph, get_or_build_graph};
17use crate::index_schema::IndexSchema;
18use crate::slug::Slug;
19
20/// Severity level of a lint finding.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Severity {
24    /// A definite problem that should be fixed.
25    Error,
26    /// A potential issue that may warrant attention.
27    Warning,
28}
29
30impl std::fmt::Display for Severity {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Severity::Error => write!(f, "error"),
34            Severity::Warning => write!(f, "warning"),
35        }
36    }
37}
38
39/// A single lint finding for a wiki page.
40#[derive(Debug, Clone, Serialize)]
41pub struct LintFinding {
42    /// Slug of the page with the finding.
43    pub slug: String,
44    /// Name of the lint rule that produced this finding.
45    pub rule: &'static str,
46    /// Severity of the finding.
47    pub severity: Severity,
48    /// Human-readable description of the issue.
49    pub message: String,
50    /// Filesystem path of the page file.
51    pub path: String,
52}
53
54/// Aggregate results of a lint run against a wiki.
55#[derive(Debug, Clone, Serialize)]
56pub struct LintReport {
57    /// Name of the wiki that was linted.
58    pub wiki: String,
59    /// Total number of findings (errors + warnings).
60    pub total: usize,
61    /// Number of error-severity findings.
62    pub errors: usize,
63    /// Number of warning-severity findings.
64    pub warnings: usize,
65    /// Individual lint findings, sorted by slug then rule.
66    pub findings: Vec<LintFinding>,
67}
68
69/// Run lint rules against a wiki. `rules` is a comma-separated list; `None` runs all rules.
70/// `severity_filter` restricts output to `"error"` or `"warning"`.
71pub fn run_lint(
72    engine: &EngineState,
73    wiki_name: &str,
74    rules: Option<&str>,
75    severity_filter: Option<&str>,
76) -> Result<LintReport> {
77    let active_rules: HashSet<&str> = match rules {
78        None | Some("") => [
79            "orphan",
80            "broken-link",
81            "broken-cross-wiki-link",
82            "missing-fields",
83            "stale",
84            "unknown-type",
85            "articulation-point",
86            "bridge",
87            "periphery",
88        ]
89        .iter()
90        .copied()
91        .collect(),
92        Some(s) => s.split(',').map(str::trim).collect(),
93    };
94
95    let space = engine.space(wiki_name)?;
96    let searcher = space.index_manager.searcher()?;
97    let is = &space.index_schema;
98    let resolved = space.resolved_config(&engine.config);
99    let lint_cfg = &resolved.lint;
100    let wiki_root = &space.wiki_root;
101
102    let mut findings: Vec<LintFinding> = Vec::new();
103
104    if active_rules.contains("orphan") {
105        findings.extend(rule_orphan(&searcher, is, wiki_root)?);
106    }
107    if active_rules.contains("broken-link") || active_rules.contains("broken-cross-wiki-link") {
108        let mounted: HashSet<String> = engine.spaces.keys().cloned().collect();
109        findings.extend(rule_broken_link(
110            &searcher,
111            is,
112            wiki_root,
113            active_rules.contains("broken-cross-wiki-link"),
114            &mounted,
115        )?);
116    }
117    if active_rules.contains("missing-fields") {
118        findings.extend(rule_missing_fields(
119            &searcher,
120            is,
121            wiki_root,
122            &space.type_registry,
123        )?);
124    }
125    if active_rules.contains("stale") {
126        findings.extend(rule_stale(
127            &searcher,
128            is,
129            wiki_root,
130            lint_cfg.stale_days,
131            lint_cfg.stale_confidence_threshold,
132        )?);
133    }
134    if active_rules.contains("unknown-type") {
135        findings.extend(rule_unknown_type(
136            &searcher,
137            is,
138            wiki_root,
139            &space.type_registry,
140        )?);
141    }
142
143    let needs_graph = active_rules.contains("articulation-point")
144        || active_rules.contains("bridge")
145        || active_rules.contains("periphery");
146
147    if needs_graph {
148        let wiki_graph = get_or_build_graph(
149            &space.index_schema,
150            &space.type_registry,
151            &space.index_manager,
152            &space.graph_cache,
153            &searcher,
154            &GraphFilter::default(),
155        )?;
156        if active_rules.contains("articulation-point") {
157            findings.extend(rule_articulation_point(&wiki_graph, wiki_root));
158        }
159        if active_rules.contains("bridge") {
160            findings.extend(rule_bridge(&wiki_graph, wiki_root));
161        }
162        if active_rules.contains("periphery") {
163            findings.extend(rule_periphery(
164                &wiki_graph,
165                wiki_root,
166                resolved.graph.max_nodes_for_diameter,
167            ));
168        }
169    }
170
171    // Apply severity filter
172    if let Some(sev) = severity_filter {
173        let sev = sev.trim().to_lowercase();
174        findings.retain(|f| f.severity.to_string() == sev);
175    }
176
177    findings.sort_by(|a, b| a.slug.cmp(&b.slug).then(a.rule.cmp(b.rule)));
178
179    let errors = findings
180        .iter()
181        .filter(|f| f.severity == Severity::Error)
182        .count();
183    let warnings = findings
184        .iter()
185        .filter(|f| f.severity == Severity::Warning)
186        .count();
187    let total = findings.len();
188
189    Ok(LintReport {
190        wiki: wiki_name.to_string(),
191        total,
192        errors,
193        warnings,
194        findings,
195    })
196}
197
198/// Resolve a slug to its filesystem path string. Probes flat then bundle;
199/// falls back to the would-be flat path if the file doesn't exist yet.
200fn slug_path(slug: &str, wiki_root: &Path) -> String {
201    Slug::try_from(slug)
202        .ok()
203        .and_then(|s| s.resolve(wiki_root).ok())
204        .unwrap_or_else(|| wiki_root.join(format!("{slug}.md")))
205        .to_string_lossy()
206        .into_owned()
207}
208
209// ── Rule: orphan ──────────────────────────────────────────────────────────────
210
211fn rule_orphan(
212    searcher: &tantivy::Searcher,
213    is: &IndexSchema,
214    wiki_root: &Path,
215) -> Result<Vec<LintFinding>> {
216    let f_slug = is.field("slug");
217    let f_type = is.field("type");
218
219    // Collect all slugs referenced in body_links across all docs
220    let mut all_linked: HashSet<String> = HashSet::new();
221    let f_body_links = is.field("body_links");
222
223    let all_addrs = searcher.search(&AllQuery, &tantivy::collector::DocSetCollector)?;
224
225    for addr in &all_addrs {
226        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
227        for val in doc.get_all(f_body_links) {
228            if let Some(s) = val.as_str() {
229                all_linked.insert(s.to_string());
230            }
231        }
232        // Also count frontmatter edge fields as incoming-link evidence
233        for field_name in &["sources", "concepts", "document_refs", "superseded_by"] {
234            if let Some(f) = is.try_field(field_name) {
235                for val in doc.get_all(f) {
236                    if let Some(s) = val.as_str() {
237                        all_linked.insert(s.to_string());
238                    }
239                }
240            }
241        }
242    }
243
244    let mut findings = Vec::new();
245    for addr in &all_addrs {
246        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
247        let slug = doc
248            .get_first(f_slug)
249            .and_then(|v| v.as_str())
250            .unwrap_or("")
251            .to_string();
252        if slug.is_empty() {
253            continue;
254        }
255        let page_type = doc
256            .get_first(f_type)
257            .and_then(|v| v.as_str())
258            .unwrap_or("")
259            .to_string();
260
261        // Sections are structural — not flagged as orphans
262        if page_type == "section" {
263            continue;
264        }
265        // Root/index pages are exempt
266        if slug == "index" || slug.ends_with("/index") {
267            continue;
268        }
269
270        if !all_linked.contains(&slug) {
271            findings.push(LintFinding {
272                path: slug_path(&slug, wiki_root),
273                slug,
274                rule: "orphan",
275                severity: Severity::Warning,
276                message: "no incoming links".to_string(),
277            });
278        }
279    }
280
281    Ok(findings)
282}
283
284// ── Rule: broken-link ─────────────────────────────────────────────────────────
285
286fn slug_exists(searcher: &tantivy::Searcher, is: &IndexSchema, slug: &str) -> Result<bool> {
287    let f_slug = is.field("slug");
288    let term = Term::from_field_text(f_slug, slug);
289    let query = TermQuery::new(term, IndexRecordOption::Basic);
290    let results = searcher.search(&query, &tantivy::collector::DocSetCollector)?;
291    Ok(!results.is_empty())
292}
293
294fn rule_broken_link(
295    searcher: &tantivy::Searcher,
296    is: &IndexSchema,
297    wiki_root: &Path,
298    check_cross_wiki: bool,
299    mounted_wiki_names: &HashSet<String>,
300) -> Result<Vec<LintFinding>> {
301    let f_slug = is.field("slug");
302    let link_fields = [
303        "body_links",
304        "sources",
305        "concepts",
306        "document_refs",
307        "superseded_by",
308    ];
309
310    let all_addrs = searcher.search(&AllQuery, &tantivy::collector::DocSetCollector)?;
311
312    let mut findings = Vec::new();
313
314    for addr in &all_addrs {
315        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
316        let slug = doc
317            .get_first(f_slug)
318            .and_then(|v| v.as_str())
319            .unwrap_or("")
320            .to_string();
321        if slug.is_empty() {
322            continue;
323        }
324
325        for field_name in &link_fields {
326            let f = match is.try_field(field_name) {
327                Some(f) => f,
328                None => continue,
329            };
330            for val in doc.get_all(f) {
331                let target = match val.as_str() {
332                    Some(s) => s,
333                    None => continue,
334                };
335                if target.starts_with("wiki://") {
336                    if check_cross_wiki
337                        && let Some(wiki_name) = target
338                            .strip_prefix("wiki://")
339                            .and_then(|r| r.split('/').next())
340                        && !mounted_wiki_names.contains(wiki_name)
341                    {
342                        findings.push(LintFinding {
343                            path: slug_path(&slug, wiki_root),
344                            slug: slug.clone(),
345                            rule: "broken-cross-wiki-link",
346                            severity: Severity::Warning,
347                            message: format!("cross-wiki link to unmounted wiki: {target}"),
348                        });
349                    }
350                    continue;
351                }
352                if !slug_exists(searcher, is, target)? {
353                    findings.push(LintFinding {
354                        path: slug_path(&slug, wiki_root),
355                        slug: slug.clone(),
356                        rule: "broken-link",
357                        severity: Severity::Error,
358                        message: format!("broken link in {field_name}: {target}"),
359                    });
360                }
361            }
362        }
363    }
364
365    Ok(findings)
366}
367
368// ── Rule: missing-fields ──────────────────────────────────────────────────────
369
370fn rule_missing_fields(
371    searcher: &tantivy::Searcher,
372    is: &IndexSchema,
373    wiki_root: &Path,
374    registry: &crate::type_registry::SpaceTypeRegistry,
375) -> Result<Vec<LintFinding>> {
376    let f_slug = is.field("slug");
377    let f_type = is.field("type");
378
379    let all_addrs = searcher.search(&AllQuery, &tantivy::collector::DocSetCollector)?;
380
381    let mut findings = Vec::new();
382
383    for addr in &all_addrs {
384        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
385        let slug = doc
386            .get_first(f_slug)
387            .and_then(|v| v.as_str())
388            .unwrap_or("")
389            .to_string();
390        if slug.is_empty() {
391            continue;
392        }
393        let page_type = doc
394            .get_first(f_type)
395            .and_then(|v| v.as_str())
396            .unwrap_or("")
397            .to_string();
398        if page_type.is_empty() || !registry.is_known(&page_type) {
399            continue;
400        }
401
402        // Get required fields from JSON schema
403        let required = registry.required_fields(&page_type);
404        for field_name in &required {
405            // Check via index field presence
406            let present = if let Some(f) = is.try_field(field_name) {
407                doc.get_first(f).is_some()
408            } else {
409                // Field not in index schema — can't check, skip
410                true
411            };
412            if !present {
413                findings.push(LintFinding {
414                    path: slug_path(&slug, wiki_root),
415                    slug: slug.clone(),
416                    rule: "missing-fields",
417                    severity: Severity::Error,
418                    message: format!("required field missing: {field_name}"),
419                });
420            }
421        }
422    }
423
424    Ok(findings)
425}
426
427// ── Rule: stale ───────────────────────────────────────────────────────────────
428
429fn rule_stale(
430    searcher: &tantivy::Searcher,
431    is: &IndexSchema,
432    wiki_root: &Path,
433    stale_days: u32,
434    stale_confidence_threshold: f32,
435) -> Result<Vec<LintFinding>> {
436    let f_slug = is.field("slug");
437    let f_last_updated = match is.try_field("last_updated") {
438        Some(f) => f,
439        None => return Ok(vec![]),
440    };
441    let f_confidence = is.try_field("confidence");
442
443    let today = chrono::Utc::now().date_naive();
444    let threshold_date = today - chrono::Duration::days(stale_days as i64);
445
446    let all_addrs = searcher.search(&AllQuery, &tantivy::collector::DocSetCollector)?;
447
448    let mut findings = Vec::new();
449
450    for addr in &all_addrs {
451        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
452        let slug = doc
453            .get_first(f_slug)
454            .and_then(|v| v.as_str())
455            .unwrap_or("")
456            .to_string();
457        if slug.is_empty() {
458            continue;
459        }
460
461        let date_str = doc
462            .get_first(f_last_updated)
463            .and_then(|v| v.as_str())
464            .unwrap_or("");
465
466        let is_old = if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
467            date < threshold_date
468        } else {
469            // No valid date — treat as old
470            true
471        };
472
473        if !is_old {
474            continue;
475        }
476
477        // Check confidence if the field is indexed
478        let is_low_confidence = if let Some(f_conf) = f_confidence {
479            match doc.get_first(f_conf).and_then(|v| v.as_f64()) {
480                Some(v) => (v as f32) < stale_confidence_threshold,
481                None => true, // No confidence value — treat as low
482            }
483        } else {
484            // Field not indexed — fall back to date-only
485            true
486        };
487
488        if is_old && is_low_confidence {
489            let age_note = if date_str.is_empty() {
490                "no last_updated date".to_string()
491            } else {
492                format!("last updated {date_str}")
493            };
494            findings.push(LintFinding {
495                path: slug_path(&slug, wiki_root),
496                slug,
497                rule: "stale",
498                severity: Severity::Warning,
499                message: format!("stale page: {age_note}"),
500            });
501        }
502    }
503
504    Ok(findings)
505}
506
507// ── Graph helper ─────────────────────────────────────────────────────────────
508
509fn build_undirected(
510    graph: &WikiGraph,
511) -> (
512    UnGraph<NodeIndex, ()>,
513    std::collections::HashMap<petgraph::graph::NodeIndex<u32>, NodeIndex>,
514) {
515    let mut ug: UnGraph<NodeIndex, ()> = UnGraph::new_undirected();
516    let mut node_map: std::collections::HashMap<NodeIndex, petgraph::graph::NodeIndex<u32>> =
517        std::collections::HashMap::new();
518    let mut reverse_map: std::collections::HashMap<petgraph::graph::NodeIndex<u32>, NodeIndex> =
519        std::collections::HashMap::new();
520    for idx in graph.node_indices() {
521        if !graph[idx].external {
522            let ug_idx = ug.add_node(idx);
523            node_map.insert(idx, ug_idx);
524            reverse_map.insert(ug_idx, idx);
525        }
526    }
527    for edge in graph.edge_indices() {
528        let (a, b) = graph.edge_endpoints(edge).unwrap();
529        if graph[a].external || graph[b].external {
530            continue;
531        }
532        if let (Some(&ua), Some(&ub)) = (node_map.get(&a), node_map.get(&b))
533            && ug.find_edge(ua, ub).is_none()
534        {
535            ug.add_edge(ua, ub, ());
536        }
537    }
538    (ug, reverse_map)
539}
540
541// ── Rule: unknown-type ────────────────────────────────────────────────────────
542
543fn rule_unknown_type(
544    searcher: &tantivy::Searcher,
545    is: &IndexSchema,
546    wiki_root: &Path,
547    registry: &crate::type_registry::SpaceTypeRegistry,
548) -> Result<Vec<LintFinding>> {
549    let f_slug = is.field("slug");
550    let f_type = is.field("type");
551
552    let all_addrs = searcher.search(&AllQuery, &tantivy::collector::DocSetCollector)?;
553
554    let mut findings = Vec::new();
555
556    for addr in &all_addrs {
557        let doc: tantivy::TantivyDocument = searcher.doc(*addr)?;
558        let slug = doc
559            .get_first(f_slug)
560            .and_then(|v| v.as_str())
561            .unwrap_or("")
562            .to_string();
563        if slug.is_empty() {
564            continue;
565        }
566        let page_type = doc.get_first(f_type).and_then(|v| v.as_str()).unwrap_or("");
567        if page_type.is_empty() {
568            continue;
569        }
570        if !registry.is_known(page_type) {
571            findings.push(LintFinding {
572                path: slug_path(&slug, wiki_root),
573                slug,
574                rule: "unknown-type",
575                severity: Severity::Error,
576                message: format!("unknown type: {page_type}"),
577            });
578        }
579    }
580
581    Ok(findings)
582}
583
584// ── Rule: articulation-point ──────────────────────────────────────────────────
585
586fn rule_articulation_point(wiki_graph: &Arc<WikiGraph>, wiki_root: &Path) -> Vec<LintFinding> {
587    let (ug, reverse_map) = build_undirected(wiki_graph);
588    let aps = petgraph_live::connect::articulation_points(&ug);
589    aps.iter()
590        .filter_map(|&ug_idx| reverse_map.get(&ug_idx))
591        .map(|&orig_idx| {
592            let slug = wiki_graph[orig_idx].slug.clone();
593            LintFinding {
594                path: slug_path(&slug, wiki_root),
595                slug,
596                rule: "articulation-point",
597                severity: Severity::Warning,
598                message:
599                    "removing this page would disconnect the graph — add alternative link paths"
600                        .to_string(),
601            }
602        })
603        .collect()
604}
605
606// ── Rule: bridge ──────────────────────────────────────────────────────────────
607
608fn rule_bridge(wiki_graph: &Arc<WikiGraph>, wiki_root: &Path) -> Vec<LintFinding> {
609    let (ug, reverse_map) = build_undirected(wiki_graph);
610    let bridges = petgraph_live::connect::find_bridges(&ug);
611    bridges
612        .iter()
613        .filter_map(|&(ua, ub)| {
614            let a = reverse_map.get(&ua)?;
615            let b = reverse_map.get(&ub)?;
616            Some((*a, *b))
617        })
618        .map(|(a, b)| {
619            let slug_a = wiki_graph[a].slug.clone();
620            let slug_b = wiki_graph[b].slug.clone();
621            LintFinding {
622                path: slug_path(&slug_a, wiki_root),
623                slug: slug_a.clone(),
624                rule: "bridge",
625                severity: Severity::Warning,
626                message: format!(
627                    "link {slug_a} → {slug_b} is a bridge — its removal disconnects the graph"
628                ),
629            }
630        })
631        .collect()
632}
633
634// ── Rule: periphery ───────────────────────────────────────────────────────────
635
636fn rule_periphery(
637    wiki_graph: &Arc<WikiGraph>,
638    wiki_root: &Path,
639    max_nodes: usize,
640) -> Vec<LintFinding> {
641    let local_count = wiki_graph
642        .node_indices()
643        .filter(|&idx| !wiki_graph[idx].external)
644        .count();
645    if local_count > max_nodes {
646        return vec![];
647    }
648    let periph = petgraph_live::metrics::periphery(&**wiki_graph);
649    periph
650        .iter()
651        .filter(|&&idx| !wiki_graph[idx].external)
652        .map(|&idx| {
653            let slug = wiki_graph[idx].slug.clone();
654            LintFinding {
655                path: slug_path(&slug, wiki_root),
656                slug,
657                rule: "periphery",
658                severity: Severity::Warning,
659                message: "most structurally isolated page — furthest from all others in the graph"
660                    .to_string(),
661            }
662        })
663        .collect()
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::graph::{LabeledEdge, PageNode};
670    use petgraph::graph::DiGraph;
671
672    fn make_graph(slugs: &[&str], edges: &[(&str, &str)]) -> WikiGraph {
673        let mut g = DiGraph::new();
674        let indices: std::collections::HashMap<&str, petgraph::graph::NodeIndex> = slugs
675            .iter()
676            .map(|&s| {
677                (
678                    s,
679                    g.add_node(PageNode {
680                        slug: s.to_string(),
681                        title: s.to_string(),
682                        r#type: "page".to_string(),
683                        external: false,
684                    }),
685                )
686            })
687            .collect();
688        for &(a, b) in edges {
689            g.add_edge(
690                indices[a],
691                indices[b],
692                LabeledEdge {
693                    relation: "links-to".to_string(),
694                },
695            );
696        }
697        g
698    }
699
700    #[test]
701    fn build_undirected_excludes_external() {
702        let mut g = DiGraph::new();
703        let local = g.add_node(PageNode {
704            slug: "a".into(),
705            title: "a".into(),
706            r#type: "page".into(),
707            external: false,
708        });
709        let ext = g.add_node(PageNode {
710            slug: "b".into(),
711            title: "b".into(),
712            r#type: "page".into(),
713            external: true,
714        });
715        g.add_edge(
716            local,
717            ext,
718            LabeledEdge {
719                relation: "links-to".into(),
720            },
721        );
722        let (ug, _) = build_undirected(&g);
723        assert_eq!(ug.node_count(), 1);
724        assert_eq!(ug.edge_count(), 0);
725    }
726
727    #[test]
728    fn articulation_point_detected() {
729        // a -- b -- c  →  b is articulation point
730        let g = make_graph(&["a", "b", "c"], &[("a", "b"), ("b", "c")]);
731        let (ug, rev) = build_undirected(&g);
732        let aps = petgraph_live::connect::articulation_points(&ug);
733        let slugs: Vec<String> = aps
734            .iter()
735            .filter_map(|&ui| rev.get(&ui))
736            .map(|&idx| g[idx].slug.clone())
737            .collect();
738        assert!(
739            slugs.contains(&"b".to_string()),
740            "b must be AP, got: {slugs:?}"
741        );
742    }
743
744    #[test]
745    fn no_articulation_points_in_cycle() {
746        let g = make_graph(&["a", "b", "c"], &[("a", "b"), ("b", "c"), ("c", "a")]);
747        let (ug, _) = build_undirected(&g);
748        assert!(petgraph_live::connect::articulation_points(&ug).is_empty());
749    }
750
751    #[test]
752    fn bridge_detected() {
753        // a -- b -- c  →  both edges are bridges
754        let g = make_graph(&["a", "b", "c"], &[("a", "b"), ("b", "c")]);
755        let (ug, rev) = build_undirected(&g);
756        let bridges = petgraph_live::connect::find_bridges(&ug);
757        assert_eq!(bridges.len(), 2);
758        let pairs: Vec<(String, String)> = bridges
759            .iter()
760            .filter_map(|&(ua, ub)| {
761                Some((
762                    g[*rev.get(&ua)?].slug.clone(),
763                    g[*rev.get(&ub)?].slug.clone(),
764                ))
765            })
766            .collect();
767        let has_ab = pairs
768            .iter()
769            .any(|(a, b)| (a == "a" && b == "b") || (a == "b" && b == "a"));
770        let has_bc = pairs
771            .iter()
772            .any(|(a, b)| (a == "b" && b == "c") || (a == "c" && b == "b"));
773        assert!(has_ab && has_bc);
774    }
775
776    #[test]
777    fn rule_articulation_point_produces_finding_for_connector() {
778        // a -- b -- c: b is the only articulation point
779        let g = Arc::new(make_graph(&["a", "b", "c"], &[("a", "b"), ("b", "c")]));
780        let findings = rule_articulation_point(&g, Path::new("/wiki"));
781        assert_eq!(findings.len(), 1);
782        assert_eq!(findings[0].slug, "b");
783        assert_eq!(findings[0].rule, "articulation-point");
784        assert_eq!(findings[0].severity, Severity::Warning);
785        assert!(findings[0].message.contains("disconnect"));
786    }
787
788    #[test]
789    fn rule_articulation_point_empty_for_cycle() {
790        let g = Arc::new(make_graph(
791            &["a", "b", "c"],
792            &[("a", "b"), ("b", "c"), ("c", "a")],
793        ));
794        assert!(rule_articulation_point(&g, Path::new("/wiki")).is_empty());
795    }
796
797    #[test]
798    fn rule_bridge_produces_findings_with_correct_fields() {
799        // a -- b -- c: both edges are bridges
800        let g = Arc::new(make_graph(&["a", "b", "c"], &[("a", "b"), ("b", "c")]));
801        let findings = rule_bridge(&g, Path::new("/wiki"));
802        assert_eq!(findings.len(), 2);
803        for f in &findings {
804            assert_eq!(f.rule, "bridge");
805            assert_eq!(f.severity, Severity::Warning);
806            assert!(
807                f.message.contains("→"),
808                "message must contain arrow, got: {}",
809                f.message
810            );
811            assert!(f.message.contains("is a bridge"));
812        }
813        let slugs: Vec<&str> = findings.iter().map(|f| f.slug.as_str()).collect();
814        assert!(slugs.contains(&"a") || slugs.contains(&"b"));
815    }
816
817    #[test]
818    fn rule_bridge_empty_for_cycle() {
819        let g = Arc::new(make_graph(
820            &["a", "b", "c"],
821            &[("a", "b"), ("b", "c"), ("c", "a")],
822        ));
823        assert!(rule_bridge(&g, Path::new("/wiki")).is_empty());
824    }
825
826    #[test]
827    fn rule_periphery_produces_findings() {
828        // a→b→c→a: directed cycle, all nodes have eccentricity 2 = diameter
829        let g = Arc::new(make_graph(
830            &["a", "b", "c"],
831            &[("a", "b"), ("b", "c"), ("c", "a")],
832        ));
833        let findings = rule_periphery(&g, Path::new("/wiki"), 100);
834        assert!(!findings.is_empty());
835        for f in &findings {
836            assert_eq!(f.rule, "periphery");
837            assert_eq!(f.severity, Severity::Warning);
838            assert!(f.message.contains("isolated"));
839        }
840    }
841
842    #[test]
843    fn rule_periphery_skips_above_threshold() {
844        // 3 nodes, threshold 2 → local_count(3) > max_nodes(2) → empty
845        let g = Arc::new(make_graph(
846            &["a", "b", "c"],
847            &[("a", "b"), ("b", "c"), ("c", "a")],
848        ));
849        assert!(rule_periphery(&g, Path::new("/wiki"), 2).is_empty());
850    }
851}