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                .with_lines(edge.lines()),
34            );
35        }
36    }
37
38    // detached-node: a file touched by no edge in either direction. Directories
39    // are structural scaffolding — links point at the files inside them, not at
40    // the directory — so a link-less directory is normal, not orphaned content.
41    let mut connected: HashSet<&str> = HashSet::new();
42    for edge in &graph.edges {
43        connected.insert(edge.source.as_str());
44        connected.insert(edge.target.as_str());
45    }
46    for (path, node) in &graph.nodes {
47        if node.fs_type() == Some("directory") {
48            continue;
49        }
50        if !connected.contains(path.as_str()) {
51            findings.push(Finding::warn(
52                "detached-node",
53                path,
54                provenance(&node.metadata),
55                "no connections",
56            ));
57        }
58    }
59
60    findings
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::compose::compose;
67    use crate::model::{Edge, GraphSet, Metadata, Node};
68    use serde_json::json;
69
70    fn fs_node() -> Node {
71        Node::new(
72            json!({ "type": "file", "hash": "b3:x" })
73                .as_object()
74                .unwrap()
75                .clone(),
76        )
77    }
78
79    fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
80        findings
81            .iter()
82            .map(|f| (f.name.as_str(), f.subject.as_str()))
83            .collect()
84    }
85
86    #[test]
87    fn flags_unresolved_target() {
88        let mut fs = Graph::labeled("fs");
89        fs.set_node("index.md", fs_node());
90        fs.add_edge(Edge::new("index.md", "gone.md"));
91        let composed = compose(&GraphSet::new(vec![fs]));
92
93        let findings = evaluate(&composed);
94        assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
95    }
96
97    #[test]
98    fn unresolved_edge_carries_link_lines() {
99        // A markdown link to a missing target on line 3 — the finding points there.
100        let mut markdown = Graph::labeled("markdown");
101        let mut meta = Metadata::new();
102        meta.insert("lines".into(), json!([3]));
103        markdown.add_edge(Edge::with_metadata("index.md", "gone.md", meta));
104        let mut fs = Graph::labeled("fs");
105        fs.set_node("index.md", fs_node());
106        let composed = compose(&GraphSet::new(vec![fs, markdown]));
107
108        let findings = evaluate(&composed);
109        let f = findings
110            .iter()
111            .find(|f| f.name == "unresolved-edge")
112            .unwrap();
113        assert_eq!(f.lines, vec![3]);
114        assert!(
115            f.format_text().contains("index.md:3 → gone.md"),
116            "got: {}",
117            f.format_text()
118        );
119    }
120
121    #[test]
122    fn does_not_flag_uri_target() {
123        let mut markdown = Graph::labeled("markdown");
124        markdown.add_edge(Edge::new("index.md", "https://example.com"));
125        let mut fs = Graph::labeled("fs");
126        fs.set_node("index.md", fs_node());
127        let composed = compose(&GraphSet::new(vec![fs, markdown]));
128
129        assert!(
130            !names(&evaluate(&composed))
131                .iter()
132                .any(|(name, _)| *name == "unresolved-edge")
133        );
134    }
135
136    #[test]
137    fn flags_detached_node() {
138        let mut fs = Graph::labeled("fs");
139        fs.set_node("lonely.md", fs_node());
140        fs.set_node("a.md", fs_node());
141        fs.set_node("b.md", fs_node());
142        fs.add_edge(Edge::new("a.md", "b.md"));
143        let composed = compose(&GraphSet::new(vec![fs]));
144
145        let findings = evaluate(&composed);
146        let n = names(&findings);
147        assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
148        assert!(!n.contains(&("detached-node", "a.md")));
149        assert!(!n.contains(&("detached-node", "b.md")));
150    }
151
152    #[test]
153    fn directory_node_is_not_detached() {
154        // A link-less directory is scaffolding, not orphaned content.
155        let mut fs = Graph::labeled("fs");
156        fs.set_node(
157            "guides",
158            Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
159        );
160        fs.set_node("lonely.md", fs_node());
161        let composed = compose(&GraphSet::new(vec![fs]));
162
163        let findings = evaluate(&composed);
164        let n = names(&findings);
165        assert!(
166            !n.contains(&("detached-node", "guides")),
167            "directory should not be flagged detached, got {n:?}"
168        );
169        // A genuinely orphaned file is still flagged.
170        assert!(n.contains(&("detached-node", "lonely.md")));
171    }
172}