Skip to main content

drft/rules/
structural.rs

1//! Structural rules: findings derived from graph shape alone, no lockfile.
2//!
3//! Findings: `unresolved-edge` (an edge target with no `@fs` block — no defining
4//! node) and `detached-node` (a node with no inbound or outbound edges). URI
5//! targets are intentional external references, not unresolved.
6
7use std::collections::HashSet;
8
9use crate::diagnostic::Finding;
10use crate::model::{Graph, Node};
11use crate::rules::{edge_provenance, provenance};
12use crate::util::is_uri;
13
14/// Evaluate structural findings for `graph`.
15pub fn evaluate(graph: &Graph) -> Vec<Finding> {
16    let mut findings = Vec::new();
17
18    // unresolved-edge: a non-URI edge target with no defining node.
19    for edge in &graph.edges {
20        if is_uri(&edge.target) {
21            continue;
22        }
23        let resolved = graph.nodes.get(&edge.target).is_some_and(Node::is_resolved);
24        if !resolved {
25            findings.push(
26                Finding::warn(
27                    "unresolved-edge",
28                    &edge.source,
29                    edge_provenance(edge),
30                    "no defining node",
31                )
32                .with_target(&edge.target),
33            );
34        }
35    }
36
37    // detached-node: a node touched by no edge in either direction.
38    let mut connected: HashSet<&str> = HashSet::new();
39    for edge in &graph.edges {
40        connected.insert(edge.source.as_str());
41        connected.insert(edge.target.as_str());
42    }
43    for (path, node) in &graph.nodes {
44        if !connected.contains(path.as_str()) {
45            findings.push(Finding::warn(
46                "detached-node",
47                path,
48                provenance(&node.metadata),
49                "no connections",
50            ));
51        }
52    }
53
54    findings
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::compose::compose;
61    use crate::model::{Edge, GraphSet, Node};
62    use serde_json::json;
63
64    fn fs_node() -> Node {
65        Node::new(
66            json!({ "type": "file", "hash": "b3:x" })
67                .as_object()
68                .unwrap()
69                .clone(),
70        )
71    }
72
73    fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
74        findings
75            .iter()
76            .map(|f| (f.name.as_str(), f.subject.as_str()))
77            .collect()
78    }
79
80    #[test]
81    fn flags_unresolved_target() {
82        let mut fs = Graph::labeled("fs");
83        fs.set_node("index.md", fs_node());
84        fs.add_edge(Edge::new("index.md", "gone.md"));
85        let composed = compose(&GraphSet::new(vec![fs]));
86
87        let findings = evaluate(&composed);
88        assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
89    }
90
91    #[test]
92    fn does_not_flag_uri_target() {
93        let mut markdown = Graph::labeled("markdown");
94        markdown.add_edge(Edge::new("index.md", "https://example.com"));
95        let mut fs = Graph::labeled("fs");
96        fs.set_node("index.md", fs_node());
97        let composed = compose(&GraphSet::new(vec![fs, markdown]));
98
99        assert!(
100            !names(&evaluate(&composed))
101                .iter()
102                .any(|(name, _)| *name == "unresolved-edge")
103        );
104    }
105
106    #[test]
107    fn flags_detached_node() {
108        let mut fs = Graph::labeled("fs");
109        fs.set_node("lonely.md", fs_node());
110        fs.set_node("a.md", fs_node());
111        fs.set_node("b.md", fs_node());
112        fs.add_edge(Edge::new("a.md", "b.md"));
113        let composed = compose(&GraphSet::new(vec![fs]));
114
115        let findings = evaluate(&composed);
116        let n = names(&findings);
117        assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
118        assert!(!n.contains(&("detached-node", "a.md")));
119        assert!(!n.contains(&("detached-node", "b.md")));
120    }
121}