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                    !edge.target.starts_with("http://")
46                        && !edge.target.starts_with("https://")
47                        && (edge.target.starts_with("../") || edge.target == "..")
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.node_type != NodeType::Graph {
63                continue;
64            }
65
66            let child_dir = root.join(path.trim_end_matches('/'));
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.nodes.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.nodes,
80                        None => continue, // No interface = open graph
81                    },
82                    Err(_) => continue,
83                }
84            };
85
86            let graph_prefix = path.as_str();
87
88            for edge in &graph.edges {
89                // Skip child-graph sources (implicit coupling edges)
90                if let Some(source_node) = graph.nodes.get(&edge.source)
91                    && source_node.graph.is_some()
92                {
93                    continue;
94                }
95
96                if !edge.target.starts_with(graph_prefix) {
97                    continue;
98                }
99
100                let relative_target = &edge.target[graph_prefix.len()..];
101                if !interface_nodes.iter().any(|n| n == relative_target) {
102                    encapsulation_violations.push(EncapsulationViolation {
103                        source: edge.source.clone(),
104                        target: edge.target.clone(),
105                        graph: graph_prefix.to_string(),
106                    });
107                }
108            }
109        }
110
111        GraphBoundariesResult {
112            sealed,
113            escapes,
114            encapsulation_violations,
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::analyses::AnalysisContext;
123    use crate::config::Config;
124    use crate::graph::test_helpers::{make_edge, make_node};
125    use crate::graph::{Graph, Node, NodeType};
126    use crate::lockfile::{Lockfile, LockfileInterface, LockfileNode, write_lockfile};
127    use std::collections::BTreeMap;
128    use std::fs;
129    use std::path::Path;
130    use tempfile::TempDir;
131
132    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
133        AnalysisContext {
134            graph,
135            root,
136            config,
137            lockfile: None,
138        }
139    }
140
141    #[test]
142    fn detects_graph_escape() {
143        let dir = TempDir::new().unwrap();
144        fs::write(dir.path().join("drft.lock"), "lockfile_version = 2\n").unwrap();
145
146        let mut graph = Graph::new();
147        graph.add_node(make_node("index.md"));
148        graph.add_edge(make_edge("index.md", "../README.md"));
149
150        let config = Config::defaults();
151        let ctx = make_ctx(&graph, dir.path(), &config);
152        let result = GraphBoundaries.run(&ctx);
153        assert!(result.sealed);
154        assert_eq!(result.escapes.len(), 1);
155        assert_eq!(result.escapes[0].target, "../README.md");
156    }
157
158    #[test]
159    fn no_escape_without_lockfile() {
160        let dir = TempDir::new().unwrap();
161
162        let mut graph = Graph::new();
163        graph.add_node(make_node("index.md"));
164        graph.add_edge(make_edge("index.md", "../README.md"));
165
166        let config = Config::defaults();
167        let ctx = make_ctx(&graph, dir.path(), &config);
168        let result = GraphBoundaries.run(&ctx);
169        assert!(!result.sealed);
170        assert!(result.escapes.is_empty());
171    }
172
173    #[test]
174    fn detects_encapsulation_violation() {
175        let dir = TempDir::new().unwrap();
176        let research = dir.path().join("research");
177        fs::create_dir_all(&research).unwrap();
178        fs::write(research.join("overview.md"), "# Overview").unwrap();
179        fs::write(research.join("internal.md"), "# Internal").unwrap();
180
181        let mut nodes = BTreeMap::new();
182        nodes.insert(
183            "overview.md".into(),
184            LockfileNode {
185                node_type: NodeType::Source,
186                hash: Some("b3:aaa".into()),
187                graph: None,
188            },
189        );
190        let lockfile = Lockfile {
191            lockfile_version: 2,
192            interface: Some(LockfileInterface {
193                nodes: vec!["overview.md".into()],
194            }),
195            nodes,
196        };
197        write_lockfile(&research, &lockfile).unwrap();
198
199        let mut graph = Graph::new();
200        graph.add_node(make_node("index.md"));
201        graph.add_node(Node {
202            path: "research/".into(),
203            node_type: NodeType::Graph,
204            hash: None,
205            graph: None,
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::Source,
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        });
253        graph.add_edge(make_edge("index.md", "research/overview.md"));
254
255        let config = Config::defaults();
256        let ctx = make_ctx(&graph, dir.path(), &config);
257        let result = GraphBoundaries.run(&ctx);
258        assert!(result.encapsulation_violations.is_empty());
259    }
260}