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::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}