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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Severity {
24 Error,
26 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#[derive(Debug, Clone, Serialize)]
41pub struct LintFinding {
42 pub slug: String,
44 pub rule: &'static str,
46 pub severity: Severity,
48 pub message: String,
50 pub path: String,
52}
53
54#[derive(Debug, Clone, Serialize)]
56pub struct LintReport {
57 pub wiki: String,
59 pub total: usize,
61 pub errors: usize,
63 pub warnings: usize,
65 pub findings: Vec<LintFinding>,
67}
68
69pub 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 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
198fn 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
209fn 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 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 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 if page_type == "section" {
263 continue;
264 }
265 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
284fn 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
368fn 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 let required = registry.required_fields(&page_type);
404 for field_name in &required {
405 let present = if let Some(f) = is.try_field(field_name) {
407 doc.get_first(f).is_some()
408 } else {
409 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
427fn 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 true
471 };
472
473 if !is_old {
474 continue;
475 }
476
477 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, }
483 } else {
484 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
507fn 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
541fn 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
584fn 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
606fn 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
634fn 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 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 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 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 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 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 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}