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