Skip to main content

check_deprule/dependency_graph/
tree.rs

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        // ensure a consistent output ordering
203        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}