1use crate::dependency_graph::formatter::Chunk;
2use crate::dependency_graph::violation::ViolationReport;
3
4use super::Graph;
5use super::formatter::Pattern;
6use anyhow::{Error, anyhow};
7use cargo_metadata::{DependencyKind, Metadata, Package, PackageId};
8use petgraph::EdgeDirection;
9use petgraph::visit::EdgeRef;
10use std::collections::HashSet;
11use std::io::Write;
12
13#[derive(Clone, Copy, clap::ValueEnum)]
14pub enum Prefix {
15 None,
16 Indent,
17 Depth,
18}
19
20struct Symbols {
21 down: &'static str,
22 tee: &'static str,
23 ell: &'static str,
24 right: &'static str,
25}
26
27static UTF8_SYMBOLS: Symbols = Symbols {
28 down: "│",
29 tee: "├",
30 ell: "└",
31 right: "─",
32};
33
34static ASCII_SYMBOLS: Symbols = Symbols {
35 down: "|",
36 tee: "|",
37 ell: "`",
38 right: "-",
39};
40
41#[derive(Clone, Copy, clap::ValueEnum)]
42pub enum Charset {
43 Utf8,
44 Ascii,
45}
46
47impl Charset {
48 fn symbols(&self) -> &'static Symbols {
49 match self {
50 Charset::Utf8 => &UTF8_SYMBOLS,
51 Charset::Ascii => &ASCII_SYMBOLS,
52 }
53 }
54}
55
56impl std::str::FromStr for Charset {
57 type Err = &'static str;
58
59 fn from_str(s: &str) -> Result<Charset, &'static str> {
60 match s {
61 "utf8" => Ok(Charset::Utf8),
62 "ascii" => Ok(Charset::Ascii),
63 _ => Err("invalid charset"),
64 }
65 }
66}
67
68pub struct TreePrintConfig {
69 pub charset: Charset,
70 pub prefix: Prefix,
71}
72
73impl Default for TreePrintConfig {
74 fn default() -> Self {
75 Self {
76 charset: Charset::Utf8,
77 prefix: Prefix::Indent,
78 }
79 }
80}
81
82struct TreePrinter<'a, W: Write> {
83 writer: W,
84 graph: &'a Graph,
85 format: Pattern,
86 direction: EdgeDirection,
87 symbols: &'static Symbols,
88 prefix: Prefix,
89 all: bool,
90 report: &'a ViolationReport,
91 visited_deps: HashSet<&'a PackageId>,
92 levels_continue: Vec<bool>,
93}
94
95impl<'a, W: Write> TreePrinter<'a, W> {
96 fn new(
97 writer: W,
98 graph: &'a Graph,
99 report: &'a ViolationReport,
100 config: TreePrintConfig,
101 ) -> Result<Self, Error> {
102 Ok(Self {
103 writer,
104 graph,
105 format: Pattern::new("{p}")?,
106 direction: EdgeDirection::Outgoing,
107 symbols: config.charset.symbols(),
108 prefix: config.prefix,
109 all: true,
110 report,
111 visited_deps: HashSet::new(),
112 levels_continue: vec![],
113 })
114 }
115
116 fn print_root(&mut self, root: &'a Package) -> Result<(), Error> {
117 self.visited_deps.clear();
118 self.levels_continue.clear();
119 self.print_package(None, root)
120 }
121
122 fn print_package(
123 &mut self,
124 parent_package: Option<&'a Package>,
125 package: &'a Package,
126 ) -> Result<(), Error> {
127 let new = self.all || self.visited_deps.insert(&package.id);
128
129 match self.prefix {
130 Prefix::Depth => write!(self.writer, "{}", self.levels_continue.len())?,
131 Prefix::Indent => {
132 if let Some((last_continues, rest)) = self.levels_continue.split_last() {
133 for continues in rest {
134 let c = if *continues { self.symbols.down } else { " " };
135 write!(self.writer, "{c} ")?;
136 }
137
138 let c = if *last_continues {
139 self.symbols.tee
140 } else {
141 self.symbols.ell
142 };
143 write!(self.writer, "{0}{1}{1} ", c, self.symbols.right)?;
144 }
145 }
146 Prefix::None => {}
147 }
148
149 let star = if new { "" } else { " (*)" };
150 let is_violation = if let Some(parent) = parent_package {
151 self.report.is_violation(&parent.name, &package.name)
152 } else {
153 false
154 };
155 match is_violation {
156 true => {
157 let f = Pattern(vec![Chunk::ViolationPackage]);
158 writeln!(self.writer, "{}{}", f.display(package), star)?;
159 }
160 false => writeln!(self.writer, "{}{}", self.format.display(package), star)?,
161 };
162
163 if !new {
164 return Ok(());
165 }
166
167 for kind in &[
168 DependencyKind::Normal,
169 DependencyKind::Build,
170 DependencyKind::Development,
171 ] {
172 self.print_dependencies(package, *kind)?;
173 }
174
175 Ok(())
176 }
177
178 fn print_dependencies(
179 &mut self,
180 package: &'a Package,
181 kind: DependencyKind,
182 ) -> Result<(), Error> {
183 let idx = self.graph.nodes[&package.id];
184 let mut deps = vec![];
185 for edge in self.graph.graph.edges_directed(idx, self.direction) {
186 let weight: &DependencyKind = edge.weight();
187 if *weight != kind {
188 continue;
189 }
190
191 let dep = match self.direction {
192 EdgeDirection::Incoming => &self.graph.graph[edge.source()],
193 EdgeDirection::Outgoing => &self.graph.graph[edge.target()],
194 };
195 deps.push(dep);
196 }
197
198 if deps.is_empty() {
199 return Ok(());
200 }
201
202 deps.sort_by_key(|p| &p.id);
204
205 let name = match kind {
206 DependencyKind::Normal => None,
207 DependencyKind::Build => Some("[build-dependencies]"),
208 DependencyKind::Development => Some("[dev-dependencies]"),
209 _ => unreachable!(),
210 };
211
212 if let Prefix::Indent = self.prefix
213 && let Some(name) = name
214 {
215 for continues in &*self.levels_continue {
216 let c = if *continues { self.symbols.down } else { " " };
217 write!(self.writer, "{c} ")?;
218 }
219
220 writeln!(self.writer, "{name}")?;
221 }
222
223 let mut it = deps.iter().peekable();
224 while let Some(dependency) = it.next() {
225 self.levels_continue.push(it.peek().is_some());
226 self.print_package(Some(package), dependency)?;
227 self.levels_continue.pop();
228 }
229
230 Ok(())
231 }
232}
233
234pub fn print(
235 writer: &mut impl Write,
236 graph: &Graph,
237 metadata: &Metadata,
238 report: &ViolationReport,
239 config: TreePrintConfig,
240) -> Result<(), Error> {
241 let mut printer = TreePrinter::new(writer, graph, report, config)?;
242
243 for member_id in &metadata.workspace_members {
244 let idx = graph.nodes.get(member_id).ok_or_else(|| {
245 anyhow!("workspace member `{member_id}` not found in dependency graph")
246 })?;
247 let root = &graph.graph[*idx];
248
249 printer.print_root(root)?;
250 }
251
252 Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::dependency_graph::violation::check_violations;
259 use crate::dependency_graph::{DependencyGraphBuildConfigs, build_dependency_graph};
260 use crate::dependency_rule::DependencyRules;
261 use crate::metadata::{CollectMetadataConfig, collect_metadata};
262 use anyhow::Result;
263
264 #[test]
265 fn test_print_no_violation_writes_output() -> Result<()> {
266 let config = CollectMetadataConfig {
267 manifest_path: Some("tests/demo_crates/clean-arch/Cargo.toml".to_string()),
268 ..CollectMetadataConfig::default()
269 };
270 let metadata = collect_metadata(config)?;
271 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
272 let rules =
273 DependencyRules::from_file("tests/demo_crates/clean-arch/dependency_rules.toml")?;
274 let report = check_violations(&graph, &rules);
275
276 let mut buf = Vec::new();
277 print(
278 &mut buf,
279 &graph,
280 &metadata,
281 &report,
282 TreePrintConfig::default(),
283 )?;
284
285 assert!(!buf.is_empty());
286 assert!(!report.has_violations());
287 Ok(())
288 }
289
290 #[test]
291 fn test_print_with_violation_writes_output() -> Result<()> {
292 let config = CollectMetadataConfig {
293 manifest_path: Some("tests/demo_crates/tangled-clean-arch/Cargo.toml".to_string()),
294 ..CollectMetadataConfig::default()
295 };
296 let metadata = collect_metadata(config)?;
297 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
298 let rules = DependencyRules::from_file(
299 "tests/demo_crates/tangled-clean-arch/dependency_rules.toml",
300 )?;
301 let report = check_violations(&graph, &rules);
302
303 let mut buf = Vec::new();
304 print(
305 &mut buf,
306 &graph,
307 &metadata,
308 &report,
309 TreePrintConfig::default(),
310 )?;
311
312 assert!(!buf.is_empty());
313 assert!(report.has_violations());
314 Ok(())
315 }
316
317 #[test]
318 fn test_print_output_contains_workspace_members() -> Result<()> {
319 let config = CollectMetadataConfig {
320 manifest_path: Some("tests/demo_crates/clean-arch/Cargo.toml".to_string()),
321 ..CollectMetadataConfig::default()
322 };
323 let metadata = collect_metadata(config)?;
324 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
325 let rules =
326 DependencyRules::from_file("tests/demo_crates/clean-arch/dependency_rules.toml")?;
327 let report = check_violations(&graph, &rules);
328
329 let mut buf = Vec::new();
330 print(
331 &mut buf,
332 &graph,
333 &metadata,
334 &report,
335 TreePrintConfig::default(),
336 )?;
337
338 let output = String::from_utf8(buf)?;
339 assert!(output.contains("ca-core"));
340 assert!(output.contains("ca-interactor"));
341 Ok(())
342 }
343
344 #[test]
345 fn test_print_ascii_charset() -> Result<()> {
346 let config = CollectMetadataConfig {
347 manifest_path: Some("tests/demo_crates/clean-arch/Cargo.toml".to_string()),
348 ..CollectMetadataConfig::default()
349 };
350 let metadata = collect_metadata(config)?;
351 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
352 let rules =
353 DependencyRules::from_file("tests/demo_crates/clean-arch/dependency_rules.toml")?;
354 let report = check_violations(&graph, &rules);
355
356 let mut buf = Vec::new();
357 let tree_config = TreePrintConfig {
358 charset: Charset::Ascii,
359 prefix: Prefix::Indent,
360 };
361 print(&mut buf, &graph, &metadata, &report, tree_config)?;
362
363 let output = String::from_utf8(buf)?;
364 assert!(output.contains("|--"), "ASCII tree should contain |--");
365 Ok(())
366 }
367
368 #[test]
369 fn test_print_depth_prefix() -> Result<()> {
370 let config = CollectMetadataConfig {
371 manifest_path: Some("tests/demo_crates/clean-arch/Cargo.toml".to_string()),
372 ..CollectMetadataConfig::default()
373 };
374 let metadata = collect_metadata(config)?;
375 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
376 let rules =
377 DependencyRules::from_file("tests/demo_crates/clean-arch/dependency_rules.toml")?;
378 let report = check_violations(&graph, &rules);
379
380 let mut buf = Vec::new();
381 let tree_config = TreePrintConfig {
382 charset: Charset::Utf8,
383 prefix: Prefix::Depth,
384 };
385 print(&mut buf, &graph, &metadata, &report, tree_config)?;
386
387 let output = String::from_utf8(buf)?;
388 assert!(
389 output.contains("0"),
390 "Depth prefix should start with depth 0"
391 );
392 Ok(())
393 }
394
395 #[test]
396 fn test_print_no_prefix() -> Result<()> {
397 let config = CollectMetadataConfig {
398 manifest_path: Some("tests/demo_crates/clean-arch/Cargo.toml".to_string()),
399 ..CollectMetadataConfig::default()
400 };
401 let metadata = collect_metadata(config)?;
402 let graph = build_dependency_graph(&metadata, DependencyGraphBuildConfigs::default())?;
403 let rules =
404 DependencyRules::from_file("tests/demo_crates/clean-arch/dependency_rules.toml")?;
405 let report = check_violations(&graph, &rules);
406
407 let mut buf = Vec::new();
408 let tree_config = TreePrintConfig {
409 charset: Charset::Utf8,
410 prefix: Prefix::None,
411 };
412 print(&mut buf, &graph, &metadata, &report, tree_config)?;
413
414 let output = String::from_utf8(buf)?;
415 assert!(
416 !output.contains("├"),
417 "None prefix should not contain tree symbols"
418 );
419 assert!(
420 !output.contains("└"),
421 "None prefix should not contain tree symbols"
422 );
423 Ok(())
424 }
425
426 #[test]
427 fn test_charset_from_str() {
428 assert!(matches!("utf8".parse::<Charset>(), Ok(Charset::Utf8)));
429 assert!(matches!("ascii".parse::<Charset>(), Ok(Charset::Ascii)));
430 assert!("invalid".parse::<Charset>().is_err());
431 }
432
433 #[test]
434 fn test_tree_print_config_default() {
435 let config = TreePrintConfig::default();
436 assert!(matches!(config.charset, Charset::Utf8));
437 assert!(matches!(config.prefix, Prefix::Indent));
438 }
439}