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 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 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 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, }
74 } else {
75 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, },
82 Err(_) => continue,
83 }
84 };
85
86 for edge in &graph.edges {
87 if let Some(source_node) = graph.nodes.get(&edge.source)
89 && source_node.graph.as_deref() != Some(".")
90 {
91 continue;
92 }
93
94 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 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::External,
160 hash: None,
161 graph: Some("..".into()),
162 is_graph: false,
163 metadata: HashMap::new(),
164 });
165 graph.add_edge(make_edge("index.md", "../README.md"));
166
167 let config = Config::defaults();
168 let ctx = make_ctx(&graph, dir.path(), &config);
169 let result = GraphBoundaries.run(&ctx);
170 assert!(result.sealed);
171 assert_eq!(result.escapes.len(), 1);
172 assert_eq!(result.escapes[0].target, "../README.md");
173 }
174
175 #[test]
176 fn no_escape_without_lockfile() {
177 let dir = TempDir::new().unwrap();
178
179 let mut graph = Graph::new();
180 graph.add_node(make_node("index.md"));
181 graph.add_edge(make_edge("index.md", "../README.md"));
182
183 let config = Config::defaults();
184 let ctx = make_ctx(&graph, dir.path(), &config);
185 let result = GraphBoundaries.run(&ctx);
186 assert!(!result.sealed);
187 assert!(result.escapes.is_empty());
188 }
189
190 #[test]
191 fn detects_encapsulation_violation() {
192 let dir = TempDir::new().unwrap();
193 let research = dir.path().join("research");
194 fs::create_dir_all(&research).unwrap();
195 fs::write(research.join("overview.md"), "# Overview").unwrap();
196 fs::write(research.join("internal.md"), "# Internal").unwrap();
197
198 let mut nodes = BTreeMap::new();
199 nodes.insert(
200 "overview.md".into(),
201 LockfileNode {
202 node_type: NodeType::File,
203 hash: Some("b3:aaa".into()),
204 graph: None,
205 },
206 );
207 let lockfile = Lockfile {
208 lockfile_version: 2,
209 interface: Some(LockfileInterface {
210 files: vec!["overview.md".into()],
211 }),
212 nodes,
213 };
214 write_lockfile(&research, &lockfile).unwrap();
215
216 let mut graph = Graph::new();
217 graph.add_node(make_node("index.md"));
218 graph.add_node(Node {
219 path: "research".into(),
220 node_type: NodeType::Directory,
221 hash: None,
222 graph: Some(".".into()),
223 is_graph: true,
224 metadata: HashMap::new(),
225 });
226 graph.add_node(Node {
227 path: "research/internal.md".into(),
228 node_type: NodeType::External,
229 hash: None,
230 graph: Some("research".into()),
231 is_graph: false,
232 metadata: HashMap::new(),
233 });
234 graph.add_edge(make_edge("index.md", "research/internal.md"));
235
236 let config = Config::defaults();
237 let ctx = make_ctx(&graph, dir.path(), &config);
238 let result = GraphBoundaries.run(&ctx);
239 assert_eq!(result.encapsulation_violations.len(), 1);
240 assert_eq!(
241 result.encapsulation_violations[0].target,
242 "research/internal.md"
243 );
244 assert_eq!(result.encapsulation_violations[0].graph, "research");
245 }
246
247 #[test]
248 fn interface_file_is_not_violation() {
249 let dir = TempDir::new().unwrap();
250 let research = dir.path().join("research");
251 fs::create_dir_all(&research).unwrap();
252 fs::write(research.join("overview.md"), "# Overview").unwrap();
253
254 let mut nodes = BTreeMap::new();
255 nodes.insert(
256 "overview.md".into(),
257 LockfileNode {
258 node_type: NodeType::File,
259 hash: Some("b3:aaa".into()),
260 graph: None,
261 },
262 );
263 let lockfile = Lockfile {
264 lockfile_version: 2,
265 interface: Some(LockfileInterface {
266 files: vec!["overview.md".into()],
267 }),
268 nodes,
269 };
270 write_lockfile(&research, &lockfile).unwrap();
271
272 let mut graph = Graph::new();
273 graph.add_node(make_node("index.md"));
274 graph.add_node(Node {
275 path: "research".into(),
276 node_type: NodeType::Directory,
277 hash: None,
278 graph: Some(".".into()),
279 is_graph: true,
280 metadata: HashMap::new(),
281 });
282 graph.add_node(Node {
283 path: "research/overview.md".into(),
284 node_type: NodeType::External,
285 hash: None,
286 graph: Some("research".into()),
287 is_graph: false,
288 metadata: HashMap::new(),
289 });
290 graph.add_edge(make_edge("index.md", "research/overview.md"));
291
292 let config = Config::defaults();
293 let ctx = make_ctx(&graph, dir.path(), &config);
294 let result = GraphBoundaries.run(&ctx);
295 assert!(result.encapsulation_violations.is_empty());
296 }
297}