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 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 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 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, }
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.nodes,
80 None => continue, },
82 Err(_) => continue,
83 }
84 };
85
86 let graph_prefix = path.as_str();
87
88 for edge in &graph.edges {
89 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}