Skip to main content

drft/analyses/
graph_boundaries.rs

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