Skip to main content

drft/analyses/
graph_boundaries.rs

1use super::{Analysis, AnalysisContext};
2use crate::graph::NodeType;
3use crate::lockfile::read_lockfile;
4
5#[derive(Debug, Clone, serde::Serialize)]
6pub struct GraphEscape {
7    pub source: String,
8    pub target: String,
9}
10
11#[derive(Debug, Clone, serde::Serialize)]
12pub struct EncapsulationViolation {
13    pub source: String,
14    pub target: String,
15    pub graph: String,
16}
17
18#[derive(Debug, Clone, serde::Serialize)]
19pub struct GraphBoundariesResult {
20    pub sealed: bool,
21    pub escapes: Vec<GraphEscape>,
22    pub encapsulation_violations: Vec<EncapsulationViolation>,
23}
24
25pub struct GraphBoundaries;
26
27impl Analysis for GraphBoundaries {
28    type Output = GraphBoundariesResult;
29
30    fn name(&self) -> &str {
31        "graph-boundaries"
32    }
33
34    fn run(&self, ctx: &AnalysisContext) -> GraphBoundariesResult {
35        let graph = ctx.graph;
36        let root = ctx.root;
37        let sealed = root.join("drft.lock").exists() || root.join("drft.toml").exists();
38
39        // Find graph escapes (edges with ../ targets)
40        let escapes = if sealed {
41            graph
42                .edges
43                .iter()
44                .filter(|edge| {
45                    !crate::graph::is_uri(&edge.target)
46                        && (edge.target.starts_with("../") || edge.target == "..")
47                })
48                .map(|edge| GraphEscape {
49                    source: edge.source.clone(),
50                    target: edge.target.clone(),
51                })
52                .collect()
53        } else {
54            Vec::new()
55        };
56
57        // Find encapsulation violations
58        let mut encapsulation_violations = Vec::new();
59
60        for (path, node) in &graph.nodes {
61            if node.node_type != NodeType::Graph {
62                continue;
63            }
64
65            let child_dir = root.join(path.trim_end_matches('/'));
66
67            // Try reading interface from child lockfile
68            let interface_nodes = if let Ok(Some(lf)) = read_lockfile(&child_dir) {
69                match &lf.interface {
70                    Some(iface) => iface.nodes.clone(),
71                    None => continue, // No interface = open graph, no violations
72                }
73            } else {
74                // No lockfile — try reading child's drft.toml for interface
75                let child_config = crate::config::Config::load(&child_dir);
76                match child_config {
77                    Ok(config) => match config.interface {
78                        Some(iface) => iface.nodes,
79                        None => continue, // No interface = open graph
80                    },
81                    Err(_) => continue,
82                }
83            };
84
85            let graph_prefix = path.as_str();
86
87            for edge in &graph.edges {
88                // Skip child-graph sources (implicit coupling edges)
89                if let Some(source_node) = graph.nodes.get(&edge.source)
90                    && source_node.graph.is_some()
91                {
92                    continue;
93                }
94
95                if !edge.target.starts_with(graph_prefix) {
96                    continue;
97                }
98
99                let relative_target = &edge.target[graph_prefix.len()..];
100                if !interface_nodes.iter().any(|n| n == relative_target) {
101                    encapsulation_violations.push(EncapsulationViolation {
102                        source: edge.source.clone(),
103                        target: edge.target.clone(),
104                        graph: graph_prefix.to_string(),
105                    });
106                }
107            }
108        }
109
110        GraphBoundariesResult {
111            sealed,
112            escapes,
113            encapsulation_violations,
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::analyses::AnalysisContext;
122    use crate::config::Config;
123    use crate::graph::test_helpers::{make_edge, make_node};
124    use crate::graph::{Graph, Node, NodeType};
125    use crate::lockfile::{Lockfile, LockfileInterface, LockfileNode, write_lockfile};
126    use std::collections::{BTreeMap, HashMap};
127    use std::fs;
128    use std::path::Path;
129    use tempfile::TempDir;
130
131    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
132        AnalysisContext {
133            graph,
134            root,
135            config,
136            lockfile: None,
137        }
138    }
139
140    #[test]
141    fn detects_graph_escape() {
142        let dir = TempDir::new().unwrap();
143        fs::write(dir.path().join("drft.lock"), "lockfile_version = 2\n").unwrap();
144
145        let mut graph = Graph::new();
146        graph.add_node(make_node("index.md"));
147        graph.add_edge(make_edge("index.md", "../README.md"));
148
149        let config = Config::defaults();
150        let ctx = make_ctx(&graph, dir.path(), &config);
151        let result = GraphBoundaries.run(&ctx);
152        assert!(result.sealed);
153        assert_eq!(result.escapes.len(), 1);
154        assert_eq!(result.escapes[0].target, "../README.md");
155    }
156
157    #[test]
158    fn no_escape_without_lockfile() {
159        let dir = TempDir::new().unwrap();
160
161        let mut graph = Graph::new();
162        graph.add_node(make_node("index.md"));
163        graph.add_edge(make_edge("index.md", "../README.md"));
164
165        let config = Config::defaults();
166        let ctx = make_ctx(&graph, dir.path(), &config);
167        let result = GraphBoundaries.run(&ctx);
168        assert!(!result.sealed);
169        assert!(result.escapes.is_empty());
170    }
171
172    #[test]
173    fn detects_encapsulation_violation() {
174        let dir = TempDir::new().unwrap();
175        let research = dir.path().join("research");
176        fs::create_dir_all(&research).unwrap();
177        fs::write(research.join("overview.md"), "# Overview").unwrap();
178        fs::write(research.join("internal.md"), "# Internal").unwrap();
179
180        let mut nodes = BTreeMap::new();
181        nodes.insert(
182            "overview.md".into(),
183            LockfileNode {
184                node_type: NodeType::File,
185                hash: Some("b3:aaa".into()),
186                graph: None,
187            },
188        );
189        let lockfile = Lockfile {
190            lockfile_version: 2,
191            interface: Some(LockfileInterface {
192                nodes: vec!["overview.md".into()],
193            }),
194            nodes,
195        };
196        write_lockfile(&research, &lockfile).unwrap();
197
198        let mut graph = Graph::new();
199        graph.add_node(make_node("index.md"));
200        graph.add_node(Node {
201            path: "research/".into(),
202            node_type: NodeType::Graph,
203            hash: None,
204            graph: None,
205            metadata: HashMap::new(),
206        });
207        graph.add_edge(make_edge("index.md", "research/internal.md"));
208
209        let config = Config::defaults();
210        let ctx = make_ctx(&graph, dir.path(), &config);
211        let result = GraphBoundaries.run(&ctx);
212        assert_eq!(result.encapsulation_violations.len(), 1);
213        assert_eq!(
214            result.encapsulation_violations[0].target,
215            "research/internal.md"
216        );
217        assert_eq!(result.encapsulation_violations[0].graph, "research/");
218    }
219
220    #[test]
221    fn interface_file_is_not_violation() {
222        let dir = TempDir::new().unwrap();
223        let research = dir.path().join("research");
224        fs::create_dir_all(&research).unwrap();
225        fs::write(research.join("overview.md"), "# Overview").unwrap();
226
227        let mut nodes = BTreeMap::new();
228        nodes.insert(
229            "overview.md".into(),
230            LockfileNode {
231                node_type: NodeType::File,
232                hash: Some("b3:aaa".into()),
233                graph: None,
234            },
235        );
236        let lockfile = Lockfile {
237            lockfile_version: 2,
238            interface: Some(LockfileInterface {
239                nodes: vec!["overview.md".into()],
240            }),
241            nodes,
242        };
243        write_lockfile(&research, &lockfile).unwrap();
244
245        let mut graph = Graph::new();
246        graph.add_node(make_node("index.md"));
247        graph.add_node(Node {
248            path: "research/".into(),
249            node_type: NodeType::Graph,
250            hash: None,
251            graph: None,
252            metadata: HashMap::new(),
253        });
254        graph.add_edge(make_edge("index.md", "research/overview.md"));
255
256        let config = Config::defaults();
257        let ctx = make_ctx(&graph, dir.path(), &config);
258        let result = GraphBoundaries.run(&ctx);
259        assert!(result.encapsulation_violations.is_empty());
260    }
261}