diff --git a/Cargo.toml b/Cargo.toml index 4e4e8c9..806c50a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ mermaid-rs-renderer = { version = "0.1.2", default-features = false } itertools-num = "0.1.3" kernel-density-estimation = "0.2.0" ordered-float = "5.0.0" +petgraph = "0.7" pathdiff = "0.2.3" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 5fefdd9..dbaf543 100644 --- a/README.md +++ b/README.md @@ -176,18 +176,19 @@ Multiple options are available to customize and tune the output. See `minpath -- ## depconv Convert dependency graphs between formats. Reads from stdin or a file, writes to stdout or a file. -Input and output formats are auto-detected from file extensions or content when `--from`/`--to` are -not specified. Defaults to DOT output when no output format can be inferred. +Input and output formats are auto-detected from file extensions or content when +`--input-format`/`--output-format` are not specified. Defaults to DOT output when no output format +can be inferred. ```sh -$ echo -e "a Node A\nb Node B\n#\na b depends on" | depconv --to dot +$ echo -e "a Node A\nb Node B\n#\na b depends on" | depconv --output-format dot digraph { a [label="Node A"] b [label="Node B"] a -> b [label="depends on"] } -$ cargo tree --depth 1 | depconv --to tgf +$ cargo tree --depth 1 | depconv --output-format tgf csvizmo v0.1.0 clap v4.5.39 ... @@ -198,16 +199,16 @@ csvizmo v0.1.0 clap v4.5.39 ### Supported formats -| Format | `--from` | `--to` | Description | -| -------------- | :------: | :----: | ------------------------------------------------------------------------------- | -| DOT (GraphViz) | yes | yes | `digraph` / `graph` syntax. Parses cmake, ninja, bitbake, and ad-hoc DOT output | -| Mermaid | yes | yes | `flowchart` / `graph` graph types | -| TGF | yes | yes | Trivial Graph Format | -| Depfile | yes | yes | Makefile `.d` depfile | -| Tree | yes | yes | Box-drawing trees (`tree` CLI output) | -| Pathlist | yes | yes | One path per line; hierarchy inferred from `/` separators | -| Cargo tree | yes | -- | `cargo tree` output | -| Cargo metadata | yes | -- | `cargo metadata --format-version=1` JSON | +| Format | `--input-format` | `--output-format` | Description | +| -------------- | :--------------: | :---------------: | ------------------------------------------------------------------------------- | +| DOT (GraphViz) | yes | yes | `digraph` / `graph` syntax. Parses cmake, ninja, bitbake, and ad-hoc DOT output | +| Mermaid | yes | yes | `flowchart` / `graph` graph types | +| TGF | yes | yes | Trivial Graph Format | +| Depfile | yes | yes | Makefile `.d` depfile | +| Tree | yes | yes | Box-drawing trees (`tree` CLI output) | +| Pathlist | yes | yes | One path per line; hierarchy inferred from `/` separators | +| Cargo tree | yes | -- | `cargo tree` output | +| Cargo metadata | yes | -- | `cargo metadata --format-version=1` JSON | ### What's preserved across formats @@ -229,11 +230,42 @@ Converting from a rich format (DOT, cargo metadata) to a simpler one (TGF, depfi unsupported attributes. Converting in the other direction preserves graph topology but cannot recover lost metadata. -> [!NOTE] DOT parsing requires building with `--features dot`, which pulls in the `dot-parser` crate +> [!NOTE] +> +> DOT parsing requires building with `--features dot`, which pulls in the `dot-parser` crate > (GPL-2.0). The default build does not include this feature and is MIT-licensed. When built with > `--features dot`, the resulting binary is GPL-2.0. DOT _emitting_ is always available (custom > string formatting, no GPL dependency). +## depfilter + +Filter or select subsets of dependency graphs. Works on the same formats as `depconv`, and is +designed to be chained with pipes. + +* `depfilter select` keeps only nodes matching the given patterns +* `depfilter filter` removes nodes matching the given patterns + +Both subcommands have extra options to tune their behavior. + +```sh +# From a cargo dependency tree, select the subtree rooted at "clap", then filter out +# all the proc-macro crates and their dependencies: +$ cargo tree --depth 10 \ + | depfilter select -p "clap*" --deps -I cargo-tree -O tgf \ + | depfilter filter -p "*derive*" -p "*proc*" --deps -I tgf -O dot +digraph { + clap [label="v4.5.57 clap"]; + clap_builder [label="v4.5.57 clap_builder"]; + anstream [label="v0.6.21 anstream"]; + ... +} +``` + +> [!NOTE] +> +> The `depfilter` tool shares the same GPL-2.0 license caveat as `depconv` with respect to +> DOT parsing. + ## can2csv Parse basic data from a CAN frame into a CSV record. Faster than `sed`, and also parses the canid. diff --git a/crates/csvizmo-depgraph/Cargo.toml b/crates/csvizmo-depgraph/Cargo.toml index 59f299f..d453493 100644 --- a/crates/csvizmo-depgraph/Cargo.toml +++ b/crates/csvizmo-depgraph/Cargo.toml @@ -16,7 +16,9 @@ csvizmo-utils.workspace = true dot-parser = { workspace = true, optional = true } either = { workspace = true, optional = true } eyre.workspace = true +globset.workspace = true indexmap.workspace = true +petgraph.workspace = true mermaid-rs-renderer.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/csvizmo-depgraph/src/algorithm/filter.rs b/crates/csvizmo-depgraph/src/algorithm/filter.rs new file mode 100644 index 0000000..facc775 --- /dev/null +++ b/crates/csvizmo-depgraph/src/algorithm/filter.rs @@ -0,0 +1,552 @@ +use std::collections::{HashSet, VecDeque}; + +use clap::Parser; +use petgraph::Direction; +use petgraph::graph::NodeIndex; + +use super::{MatchKey, build_globset}; +use crate::{DepGraph, Edge, FlatGraphView}; + +#[derive(Clone, Debug, Default, Parser)] +pub struct FilterArgs { + /// Glob pattern to remove nodes (can be repeated) + #[clap(short, long)] + pub pattern: Vec, + + /// Combine multiple patterns with AND instead of OR + #[clap(long)] + pub and: bool, + + /// Match patterns against 'id' or 'label' + #[clap(long, default_value_t = MatchKey::default())] + pub key: MatchKey, + + /// Also remove all dependencies of matched nodes (cascade) + #[clap(long)] + pub deps: bool, + + /// Also remove all ancestors of matched nodes (cascade) + #[clap(long)] + pub ancestors: bool, + + /// Preserve graph connectivity when removing nodes + /// (creates direct edges, no self-loops or parallel edges) + #[clap(long)] + pub preserve_connectivity: bool, +} + +impl FilterArgs { + pub fn pattern(mut self, p: impl Into) -> Self { + self.pattern.push(p.into()); + self + } + + pub fn and(mut self) -> Self { + self.and = true; + self + } + + pub fn key(mut self, k: MatchKey) -> Self { + self.key = k; + self + } + + pub fn deps(mut self) -> Self { + self.deps = true; + self + } + + pub fn ancestors(mut self) -> Self { + self.ancestors = true; + self + } + + pub fn preserve_connectivity(mut self) -> Self { + self.preserve_connectivity = true; + self + } +} + +pub fn filter(graph: &DepGraph, args: &FilterArgs) -> eyre::Result { + let globset = build_globset(&args.pattern)?; + let view = FlatGraphView::new(graph); + + // Find nodes that match the patterns (these will be removed). + let mut matched = HashSet::new(); + for (id, info) in graph.all_nodes() { + let text = match args.key { + MatchKey::Id => id.as_str(), + MatchKey::Label => info.label.as_str(), + }; + + let is_match = if args.and { + globset.matches(text).len() == args.pattern.len() + } else { + globset.is_match(text) + }; + + if is_match && let Some(&idx) = view.id_to_idx.get(id.as_str()) { + matched.insert(idx); + } + } + + // Cascade removal via BFS if --deps or --ancestors is set. + if args.deps && args.ancestors { + let seeds = matched.clone(); + matched = view.bfs(seeds.clone(), Direction::Outgoing, None); + matched.extend(view.bfs(seeds, Direction::Incoming, None)); + } else if args.ancestors { + matched = view.bfs(matched, Direction::Incoming, None); + } else if args.deps { + matched = view.bfs(matched, Direction::Outgoing, None); + } + + // Keep set = all nodes minus matched nodes. + let all_nodes: HashSet<_> = view.id_to_idx.values().copied().collect(); + let keep: HashSet<_> = all_nodes.difference(&matched).copied().collect(); + + let mut result = view.filter(&keep); + + // Bypass removed nodes: connect their surviving predecessors to surviving successors. + // BFS through chains of removed nodes so that A->B->C->D with B,C removed produces A->D. + if args.preserve_connectivity { + let mut existing: HashSet<(String, String)> = result + .all_edges() + .iter() + .map(|e| (e.from.clone(), e.to.clone())) + .collect(); + + let mut bypass_edges: Vec<(String, String)> = Vec::new(); + for &idx in &matched { + let preds = surviving_neighbors(&view.pg, idx, Direction::Incoming, &keep); + let succs = surviving_neighbors(&view.pg, idx, Direction::Outgoing, &keep); + + for &pred in &preds { + let from = view.idx_to_id[pred.index()]; + for &succ in &succs { + let to = view.idx_to_id[succ.index()]; + if from != to && existing.insert((from.to_string(), to.to_string())) { + bypass_edges.push((from.to_string(), to.to_string())); + } + } + } + } + + for (from, to) in bypass_edges { + insert_edge(&mut result, &from, &to); + } + + // Bypass edges were inserted directly into the graph fields, so any + // caches populated earlier (e.g. by the `existing` set above) are stale. + result.clear_caches(); + } + + Ok(result) +} + +/// Insert a bypass edge into the deepest subgraph that contains both endpoints. +/// Falls back to the root graph if the endpoints are in different subgraphs. +fn insert_edge(graph: &mut DepGraph, from: &str, to: &str) { + for sg in &mut graph.subgraphs { + let has_from = sg.all_nodes().contains_key(from); + let has_to = sg.all_nodes().contains_key(to); + if has_from && has_to { + return insert_edge(sg, from, to); + } + } + graph.edges.push(Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }); +} + +/// BFS from `start` in `direction`, traversing through removed nodes (those not in `keep`), +/// returning surviving nodes found at the boundary. +fn surviving_neighbors( + pg: &petgraph::Graph<(), ()>, + start: NodeIndex, + direction: Direction, + keep: &HashSet, +) -> Vec { + let mut result = Vec::new(); + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + visited.insert(start); + queue.push_back(start); + + while let Some(node) = queue.pop_front() { + for neighbor in pg.neighbors_directed(node, direction) { + if !visited.insert(neighbor) { + continue; + } + if keep.contains(&neighbor) { + result.push(neighbor); + } else { + queue.push_back(neighbor); + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DepGraph, Edge, NodeInfo}; + + fn make_graph( + nodes: &[(&str, &str)], + edges: &[(&str, &str)], + subgraphs: Vec, + ) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + subgraphs, + ..Default::default() + } + } + + fn node_ids(graph: &DepGraph) -> Vec<&str> { + graph.nodes.keys().map(|s| s.as_str()).collect() + } + + fn edge_pairs(graph: &DepGraph) -> Vec<(&str, &str)> { + graph + .edges + .iter() + .map(|e| (e.from.as_str(), e.to.as_str())) + .collect() + } + + // -- pattern matching -- + + #[test] + fn single_pattern() { + // myapp -> libfoo -> libbar, myapp -> libbar + let g = make_graph( + &[ + ("libfoo", "libfoo"), + ("libbar", "libbar"), + ("myapp", "myapp"), + ], + &[ + ("myapp", "libfoo"), + ("myapp", "libbar"), + ("libfoo", "libbar"), + ], + vec![], + ); + let args = FilterArgs::default().pattern("libfoo"); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["libbar", "myapp"]); + assert_eq!(edge_pairs(&result), vec![("myapp", "libbar")]); + } + + #[test] + fn multiple_patterns_or() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("a").pattern("b"); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["c"]); + assert!(edge_pairs(&result).is_empty()); + } + + #[test] + fn multiple_patterns_and() { + let g = make_graph( + &[ + ("libfoo-alpha", "libfoo-alpha"), + ("libfoo-beta", "libfoo-beta"), + ("libbar-alpha", "libbar-alpha"), + ], + &[], + vec![], + ); + let args = FilterArgs::default() + .pattern("libfoo*") + .pattern("*alpha") + .and(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["libfoo-beta", "libbar-alpha"]); + } + + #[test] + fn no_match_returns_unchanged() { + let g = make_graph(&[("a", "a"), ("b", "b")], &[("a", "b")], vec![]); + let args = FilterArgs::default().pattern("nonexistent"); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b"]); + assert_eq!(edge_pairs(&result), vec![("a", "b")]); + } + + // -- traversal -- + + #[test] + fn with_deps_cascade() { + // a -> b -> c, a -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("a").deps(); + let result = filter(&g, &args).unwrap(); + assert!(node_ids(&result).is_empty()); + } + + #[test] + fn with_ancestors_cascade() { + // a -> b -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("c").ancestors(); + let result = filter(&g, &args).unwrap(); + assert!(node_ids(&result).is_empty()); + } + + #[test] + fn with_deps_and_ancestors_cascade() { + // a -> b -> c -> d: filter b with both deps and ancestors + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = FilterArgs::default().pattern("b").deps().ancestors(); + let result = filter(&g, &args).unwrap(); + // b + ancestors (a) + deps (c, d) = all removed + assert!(node_ids(&result).is_empty()); + } + + #[test] + fn with_deps_and_ancestors_cascade_partial() { + // a -> b -> c, d -> c: filter b with both directions + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("d", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("b").deps().ancestors(); + let result = filter(&g, &args).unwrap(); + // b removed, ancestors (a) removed, deps (c) removed, but d survives + assert_eq!(node_ids(&result), vec!["d"]); + assert!(edge_pairs(&result).is_empty()); + } + + // -- preserve connectivity -- + + #[test] + fn preserve_connectivity_bypass() { + // a -> b -> c: remove b, get a -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "c"]); + assert_eq!(edge_pairs(&result), vec![("a", "c")]); + } + + #[test] + fn preserve_connectivity_chain() { + // a -> b -> c -> d: remove b and c, get a -> d + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = FilterArgs::default() + .pattern("b") + .pattern("c") + .preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "d"]); + assert_eq!(edge_pairs(&result), vec![("a", "d")]); + } + + #[test] + fn preserve_connectivity_diamond_through_removed() { + // a -> b -> d, a -> c -> d: remove b and c, get single a -> d + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")], + vec![], + ); + let args = FilterArgs::default() + .pattern("b") + .pattern("c") + .preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "d"]); + assert_eq!(edge_pairs(&result), vec![("a", "d")]); + } + + #[test] + fn preserve_connectivity_no_self_loops() { + // a -> b -> a: remove b, should not create a -> a + let g = make_graph(&[("a", "a"), ("b", "b")], &[("a", "b"), ("b", "a")], vec![]); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a"]); + assert!(edge_pairs(&result).is_empty()); + } + + #[test] + fn preserve_connectivity_no_parallel_edges() { + // a -> b -> c, a -> c: remove b, should not duplicate a -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + ); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "c"]); + assert_eq!(edge_pairs(&result), vec![("a", "c")]); + } + + // -- subgraphs -- + + #[test] + fn preserves_subgraph_structure() { + // root: a, subgraph: { b, c, b->c }, edge a->b at root + // filter a keeps b, c in subgraph with their edge + let g = make_graph( + &[("a", "a")], + &[("a", "b")], + vec![make_graph(&[("b", "b"), ("c", "c")], &[("b", "c")], vec![])], + ); + let args = FilterArgs::default().pattern("a"); + let result = filter(&g, &args).unwrap(); + assert!(result.nodes.is_empty()); + assert!(result.edges.is_empty()); + assert_eq!(result.subgraphs.len(), 1); + assert_eq!(node_ids(&result.subgraphs[0]), vec!["b", "c"]); + assert_eq!(edge_pairs(&result.subgraphs[0]), vec![("b", "c")]); + } + + // -- preserve connectivity + cascade -- + + #[test] + fn preserve_connectivity_with_deps_cascade() { + // a -> b -> c -> d: remove b with --deps --preserve-connectivity + // b, c, d removed; a survives with no edges (nothing to bypass to) + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = FilterArgs::default() + .pattern("b") + .deps() + .preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a"]); + assert!(edge_pairs(&result).is_empty()); + } + + #[test] + fn preserve_connectivity_with_deps_cascade_bypass() { + // a -> b -> c, a -> d, d -> c: remove b with --deps --preserve-connectivity + // b, c removed (deps of b); a, d survive; a had edge to c via b, bypass a -> (nothing, + // c is removed). d -> c is also removed. Only a, d remain with no edges. + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("a", "d"), ("d", "c")], + vec![], + ); + let args = FilterArgs::default() + .pattern("b") + .deps() + .preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "d"]); + assert_eq!(edge_pairs(&result), vec![("a", "d")]); + } + + // -- preserve connectivity with subgraphs -- + + #[test] + fn preserve_connectivity_bypass_in_subgraph() { + // subgraph { a -> b -> c }: remove b, bypass a -> c should be in the subgraph + let g = make_graph( + &[], + &[], + vec![make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + )], + ); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + // bypass a -> c should be inside the subgraph, not at root + assert!(result.edges.is_empty()); + assert_eq!(result.subgraphs.len(), 1); + let sg = &result.subgraphs[0]; + assert_eq!(node_ids(sg), vec!["a", "c"]); + assert_eq!(edge_pairs(sg), vec![("a", "c")]); + } + + #[test] + fn preserve_connectivity_no_parallel_edges_in_subgraph() { + // subgraph { a -> b -> c, a -> c }: remove b, should not duplicate a -> c + let g = make_graph( + &[], + &[], + vec![make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + )], + ); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert!(result.edges.is_empty()); + assert_eq!(result.subgraphs.len(), 1); + let sg = &result.subgraphs[0]; + assert_eq!(node_ids(sg), vec!["a", "c"]); + assert_eq!(edge_pairs(sg), vec![("a", "c")]); + } + + #[test] + fn preserve_connectivity_cross_subgraph_bypass_at_root() { + // subgraph1 { a }, subgraph2 { c }, root: b, edges a->b, b->c at root + // remove b, bypass a->c should be at root + let g = make_graph( + &[("b", "b")], + &[("a", "b"), ("b", "c")], + vec![ + make_graph(&[("a", "a")], &[], vec![]), + make_graph(&[("c", "c")], &[], vec![]), + ], + ); + let args = FilterArgs::default().pattern("b").preserve_connectivity(); + let result = filter(&g, &args).unwrap(); + assert!(result.nodes.is_empty()); + assert_eq!(edge_pairs(&result), vec![("a", "c")]); + assert_eq!(result.subgraphs.len(), 2); + } +} diff --git a/crates/csvizmo-depgraph/src/algorithm/mod.rs b/crates/csvizmo-depgraph/src/algorithm/mod.rs new file mode 100644 index 0000000..f5b85db --- /dev/null +++ b/crates/csvizmo-depgraph/src/algorithm/mod.rs @@ -0,0 +1,27 @@ +pub mod filter; +pub mod select; + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +#[derive(Debug, Default, Clone, Copy, clap::ValueEnum)] +pub enum MatchKey { + Id, + #[default] + Label, +} + +impl std::fmt::Display for MatchKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use clap::ValueEnum; + + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +fn build_globset(patterns: &[String]) -> eyre::Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + builder.add(Glob::new(pattern)?); + } + Ok(builder.build()?) +} diff --git a/crates/csvizmo-depgraph/src/algorithm/select.rs b/crates/csvizmo-depgraph/src/algorithm/select.rs new file mode 100644 index 0000000..53fcfbe --- /dev/null +++ b/crates/csvizmo-depgraph/src/algorithm/select.rs @@ -0,0 +1,375 @@ +use std::collections::HashSet; + +use clap::Parser; +use petgraph::Direction; + +use super::{MatchKey, build_globset}; +use crate::{DepGraph, FlatGraphView}; + +#[derive(Clone, Debug, Default, Parser)] +pub struct SelectArgs { + /// Glob pattern to select nodes (can be repeated) + #[clap(short, long)] + pub pattern: Vec, + + /// Combine multiple patterns with AND instead of OR + #[clap(long)] + pub and: bool, + + /// Match patterns against 'id' or 'label' + #[clap(long, default_value_t = MatchKey::default())] + pub key: MatchKey, + + /// Include all dependencies of selected nodes + #[clap(long)] + pub deps: bool, + + /// Include all ancestors of selected nodes + #[clap(long)] + pub ancestors: bool, + + /// Traverse up to N layers (implies --deps if no direction given) + #[clap(long)] + pub depth: Option, +} + +impl SelectArgs { + pub fn pattern(mut self, p: impl Into) -> Self { + self.pattern.push(p.into()); + self + } + + pub fn and(mut self) -> Self { + self.and = true; + self + } + + pub fn key(mut self, k: MatchKey) -> Self { + self.key = k; + self + } + + pub fn deps(mut self) -> Self { + self.deps = true; + self + } + + pub fn ancestors(mut self) -> Self { + self.ancestors = true; + self + } + + pub fn depth(mut self, n: usize) -> Self { + self.depth = Some(n); + self + } +} + +pub fn select(graph: &DepGraph, args: &SelectArgs) -> eyre::Result { + let globset = build_globset(&args.pattern)?; + let view = FlatGraphView::new(graph); + + // No filters at all -> pass through the entire graph unchanged. + let no_traversal = !args.deps && !args.ancestors && args.depth.is_none(); + if args.pattern.is_empty() && no_traversal { + return Ok(graph.clone()); + } + + // If no patterns given, seed from root nodes; otherwise match by pattern. + let mut keep: HashSet<_> = if args.pattern.is_empty() { + view.roots().collect() + } else { + let mut matched = HashSet::new(); + for (id, info) in graph.all_nodes() { + let text = match args.key { + MatchKey::Id => id.as_str(), + MatchKey::Label => info.label.as_str(), + }; + + let is_match = if args.and { + globset.matches(text).len() == args.pattern.len() + } else { + globset.is_match(text) + }; + + if is_match && let Some(&idx) = view.id_to_idx.get(id.as_str()) { + matched.insert(idx); + } + } + matched + }; + + // --depth without an explicit direction implies --deps + let deps = args.deps || args.depth.is_some(); + if deps && args.ancestors { + let seeds = keep.clone(); + keep = view.bfs(seeds.clone(), Direction::Outgoing, args.depth); + keep.extend(view.bfs(seeds, Direction::Incoming, args.depth)); + } else if args.ancestors { + keep = view.bfs(keep, Direction::Incoming, args.depth); + } else if deps { + keep = view.bfs(keep, Direction::Outgoing, args.depth); + } + + Ok(view.filter(&keep)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DepGraph, Edge, NodeInfo}; + + fn make_graph( + nodes: &[(&str, &str)], + edges: &[(&str, &str)], + subgraphs: Vec, + ) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + subgraphs, + ..Default::default() + } + } + + fn node_ids(graph: &DepGraph) -> Vec<&str> { + graph.nodes.keys().map(|s| s.as_str()).collect() + } + + fn edge_pairs(graph: &DepGraph) -> Vec<(&str, &str)> { + graph + .edges + .iter() + .map(|e| (e.from.as_str(), e.to.as_str())) + .collect() + } + + // -- pattern matching -- + + #[test] + fn single_glob_pattern() { + // myapp -> libfoo -> libbar, myapp -> libbar + let g = make_graph( + &[ + ("libfoo", "libfoo"), + ("libbar", "libbar"), + ("myapp", "myapp"), + ], + &[ + ("myapp", "libfoo"), + ("myapp", "libbar"), + ("libfoo", "libbar"), + ], + vec![], + ); + let args = SelectArgs::default().pattern("lib*"); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["libfoo", "libbar"]); + assert_eq!(edge_pairs(&result), vec![("libfoo", "libbar")]); + } + + #[test] + fn match_by_id() { + let g = make_graph(&[("1", "libfoo"), ("2", "libbar")], &[("1", "2")], vec![]); + let args = SelectArgs::default().pattern("1").key(MatchKey::Id); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["1"]); + } + + #[test] + fn match_by_label() { + let g = make_graph(&[("1", "libfoo"), ("2", "libbar")], &[("1", "2")], vec![]); + let args = SelectArgs::default().pattern("libbar"); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["2"]); + } + + #[test] + fn multiple_patterns_or() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let args = SelectArgs::default().pattern("a").pattern("c"); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "c"]); + assert!(edge_pairs(&result).is_empty()); + } + + #[test] + fn multiple_patterns_and() { + let g = make_graph( + &[ + ("libfoo-alpha", "libfoo-alpha"), + ("libfoo-beta", "libfoo-beta"), + ("libbar-alpha", "libbar-alpha"), + ], + &[], + vec![], + ); + let args = SelectArgs::default() + .pattern("libfoo*") + .pattern("*alpha") + .and(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["libfoo-alpha"]); + } + + #[test] + fn no_match_produces_empty_graph() { + let g = make_graph(&[("a", "a"), ("b", "b")], &[("a", "b")], vec![]); + let args = SelectArgs::default().pattern("nonexistent"); + let result = select(&g, &args).unwrap(); + assert!(result.nodes.is_empty()); + assert!(result.edges.is_empty()); + } + + // -- traversal -- + + #[test] + fn with_deps() { + // a -> b -> c, a -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + ); + let args = SelectArgs::default().pattern("a").deps(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b", "c"]); + } + + #[test] + fn with_ancestors() { + // a -> b -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let args = SelectArgs::default().pattern("c").ancestors(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b", "c"]); + } + + #[test] + fn with_depth_limit() { + // a -> b -> c -> d + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = SelectArgs::default().pattern("a").deps().depth(1); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b"]); + assert_eq!(edge_pairs(&result), vec![("a", "b")]); + } + + #[test] + fn with_deps_and_ancestors() { + // a -> b -> c -> d: select b with both deps and ancestors + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = SelectArgs::default().pattern("b").deps().ancestors(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b", "c", "d"]); + assert_eq!( + edge_pairs(&result), + vec![("a", "b"), ("b", "c"), ("c", "d")] + ); + } + + #[test] + fn with_deps_and_ancestors_depth_limited() { + // a -> b -> c -> d -> e: select c with both directions, depth 1 + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d"), ("e", "e")], + &[("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")], + vec![], + ); + let args = SelectArgs::default() + .pattern("c") + .deps() + .ancestors() + .depth(1); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["b", "c", "d"]); + assert_eq!(edge_pairs(&result), vec![("b", "c"), ("c", "d")]); + } + + #[test] + fn no_args_returns_full_graph() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = SelectArgs::default(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b", "c", "d"]); + assert_eq!( + edge_pairs(&result), + vec![("a", "b"), ("b", "c"), ("c", "d")] + ); + } + + #[test] + fn depth_without_pattern_seeds_from_roots() { + // a -> b -> c -> d + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let args = SelectArgs::default().depth(2); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b", "c"]); + assert_eq!(edge_pairs(&result), vec![("a", "b"), ("b", "c")]); + } + + #[test] + fn depth_without_pattern_multiple_roots() { + // a -> c, b -> c + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[("a", "c"), ("b", "c")], + vec![], + ); + let args = SelectArgs::default().depth(0); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b"]); + } + + // -- subgraphs -- + + #[test] + fn preserves_subgraph_structure() { + // root: a -> b, subgraph: { c }, edge b -> c at root + // select a with deps keeps all nodes and preserves subgraph + let g = make_graph( + &[("a", "a"), ("b", "b")], + &[("a", "b"), ("b", "c")], + vec![make_graph(&[("c", "c")], &[], vec![])], + ); + let args = SelectArgs::default().pattern("a").deps(); + let result = select(&g, &args).unwrap(); + assert_eq!(node_ids(&result), vec!["a", "b"]); + assert_eq!(result.subgraphs.len(), 1); + assert_eq!(node_ids(&result.subgraphs[0]), vec!["c"]); + } +} diff --git a/crates/csvizmo-depgraph/src/bin/depconv.rs b/crates/csvizmo-depgraph/src/bin/depconv.rs index d5c2998..e5612e0 100644 --- a/crates/csvizmo-depgraph/src/bin/depconv.rs +++ b/crates/csvizmo-depgraph/src/bin/depconv.rs @@ -1,27 +1,20 @@ use std::io::{IsTerminal, Read}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::Parser; -use csvizmo_depgraph::{InputFormat, OutputFormat}; +use csvizmo_depgraph::emit::OutputFormat; +use csvizmo_depgraph::parse::InputFormat; use csvizmo_utils::stdio::{get_input_reader, get_output_writer}; /// Dependency graph format converter. /// -/// Formats are auto-detected from file extensions or content when --from/--to are not specified. +/// Formats are auto-detected from file extensions or content when --input-format/--output-format are not specified. #[derive(Debug, Parser)] #[clap(version, verbatim_doc_comment)] struct Args { #[clap(short, long, default_value_t = tracing::Level::INFO)] log_level: tracing::Level, - /// Input format (auto-detected from extension or content if omitted) - #[clap(short, long)] - from: Option, - - /// Output format (auto-detected from output extension if omitted, defaults to DOT) - #[clap(short, long)] - to: Option, - /// Print the detected input format and exit #[clap(long)] detect: bool, @@ -30,9 +23,17 @@ struct Args { #[clap(short, long)] input: Option, + /// Input format (auto-detected from extension or content if omitted) + #[clap(short = 'I', long)] + input_format: Option, + /// Path to the output. stdout if '-' or omitted #[clap(short, long)] output: Option, + + /// Output format (auto-detected from output extension if omitted, defaults to DOT) + #[clap(short = 'O', long)] + output_format: Option, } fn main() -> eyre::Result<()> { @@ -62,16 +63,21 @@ fn main() -> eyre::Result<()> { let mut input_text = String::new(); input.read_to_string(&mut input_text)?; - let from = resolve_input_format(args.from, input_path.as_deref(), &input_text)?; + let input_format = csvizmo_depgraph::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; if args.detect { - println!("{from}"); + println!("{input_format}"); return Ok(()); } - let to = resolve_output_format(args.to, output_path.as_deref())?; + let output_format = + csvizmo_depgraph::emit::resolve_output_format(args.output_format, output_path.as_deref())?; - let mut graph = csvizmo_depgraph::parse::parse(from, &input_text)?; + let graph = csvizmo_depgraph::parse::parse(input_format, &input_text)?; tracing::info!( "Parsed graph with {} nodes, {} edges, and {} subgraphs", graph.all_nodes().len(), @@ -79,62 +85,8 @@ fn main() -> eyre::Result<()> { graph.subgraphs.len() ); - csvizmo_depgraph::style::apply_default_styles(&mut graph); - let mut output = get_output_writer(&output_path)?; - csvizmo_depgraph::emit::emit(to, &graph, &mut output)?; + csvizmo_depgraph::emit::emit(output_format, &graph, &mut output)?; Ok(()) } - -/// Resolve input format: explicit flag > file extension > content detection. -fn resolve_input_format( - flag: Option, - path: Option<&Path>, - input: &str, -) -> eyre::Result { - if let Some(f) = flag { - return Ok(f); - } - let ext_err = match path.map(InputFormat::try_from) { - Some(Ok(f)) => { - tracing::info!("Detected input format: {f:?} from file extension"); - return Ok(f); - } - // There was an error with the extension detection, but we'll try content detection before bailing - Some(Err(e)) => Some(e), - // There was no path - None => None, - }; - // Try to detect type from content before bailing - if let Some(f) = csvizmo_depgraph::detect::detect(input) { - tracing::info!("Detected input format: {f:?} from content"); - return Ok(f); - } - // Bail, but try to give a better error based on whether the extension detection failed - match ext_err { - Some(e) => Err(e.wrap_err("cannot detect input format; use --from")), - None => eyre::bail!("cannot detect input format; use --from"), - } -} - -/// Resolve output format: explicit flag > file extension > default to DOT. -fn resolve_output_format( - flag: Option, - path: Option<&Path>, -) -> eyre::Result { - if let Some(f) = flag { - return Ok(f); - } - match path.map(OutputFormat::try_from) { - Some(Ok(f)) => { - tracing::info!("Detected output format: {f:?} from file extension"); - Ok(f) - } - Some(Err(e)) => { - // TODO: I can't decide if this should error or default to DOT - Err(e.wrap_err("Failed to detect output format from file extension; use --to")) - } - None => Ok(OutputFormat::Dot), - } -} diff --git a/crates/csvizmo-depgraph/src/bin/depfilter.rs b/crates/csvizmo-depgraph/src/bin/depfilter.rs new file mode 100644 index 0000000..0cb8114 --- /dev/null +++ b/crates/csvizmo-depgraph/src/bin/depfilter.rs @@ -0,0 +1,103 @@ +use std::io::{IsTerminal, Read}; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use csvizmo_depgraph::algorithm; +use csvizmo_depgraph::algorithm::filter::FilterArgs; +use csvizmo_depgraph::algorithm::select::SelectArgs; +use csvizmo_depgraph::emit::OutputFormat; +use csvizmo_depgraph::parse::InputFormat; +use csvizmo_utils::stdio::{get_input_reader, get_output_writer}; + +/// Filter or select nodes from dependency graphs. +/// +/// Operations are performed via select or filter subcommands. +/// Chain operations by piping: depfilter ... | depfilter ... +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input file (stdin if '-' or omitted) + #[clap(short, long, global = true)] + input: Option, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long, global = true)] + input_format: Option, + + /// Output file (stdout if '-' or omitted) + #[clap(short, long, global = true)] + output: Option, + + /// Output format (auto-detected from extension, defaults to DOT) + #[clap(short = 'O', long, global = true)] + output_format: Option, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Select nodes matching patterns and optionally their dependencies/ancestors + Select(SelectArgs), + /// Remove nodes matching patterns and optionally cascade to dependencies/ancestors + Filter(FilterArgs), +} + +fn main() -> eyre::Result<()> { + let use_color = std::io::stderr().is_terminal(); + if use_color { + color_eyre::install()?; + } + + let args = Args::parse(); + + let filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(args.log_level.into()) + .with_env_var("CSV_LOG") + .from_env_lossy(); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(use_color) + .with_writer(std::io::stderr) + .init(); + + // Normalize `-` to None -- it means stdio, not a file path. + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let input_path = args.input.filter(|p| !is_stdio(p)); + let output_path = args.output.filter(|p| !is_stdio(p)); + + let mut input = get_input_reader(&input_path)?; + let mut input_text = String::new(); + input.read_to_string(&mut input_text)?; + + let input_format = csvizmo_depgraph::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; + let output_format = + csvizmo_depgraph::emit::resolve_output_format(args.output_format, output_path.as_deref())?; + + let graph = csvizmo_depgraph::parse::parse(input_format, &input_text)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + let graph = match &args.command { + Command::Select(select_args) => algorithm::select::select(&graph, select_args)?, + Command::Filter(filter_args) => algorithm::filter::filter(&graph, filter_args)?, + }; + + let mut output = get_output_writer(&output_path)?; + csvizmo_depgraph::emit::emit(output_format, &graph, &mut output)?; + + Ok(()) +} diff --git a/crates/csvizmo-depgraph/src/detect.rs b/crates/csvizmo-depgraph/src/detect.rs index ff1a56f..8086748 100644 --- a/crates/csvizmo-depgraph/src/detect.rs +++ b/crates/csvizmo-depgraph/src/detect.rs @@ -1,6 +1,6 @@ use clap::ValueEnum; -use crate::InputFormat; +use crate::parse::InputFormat; /// Detect input format from content heuristics. /// diff --git a/crates/csvizmo-depgraph/src/emit/depfile.rs b/crates/csvizmo-depgraph/src/emit/depfile.rs index 6004957..8d5ccaf 100644 --- a/crates/csvizmo-depgraph/src/emit/depfile.rs +++ b/crates/csvizmo-depgraph/src/emit/depfile.rs @@ -12,7 +12,7 @@ use crate::DepGraph; /// - Graph-level attrs /// - Nodes with no outgoing edges (they appear implicitly as dependencies) pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { - for (target, deps) in &graph.adjacency_list() { + for (target, deps) in graph.adjacency_list() { write!(writer, "{target}:")?; for dep in deps { write!(writer, " {dep}")?; @@ -98,10 +98,9 @@ mod tests { fn nodes_only_produces_empty() { let graph = DepGraph { nodes: IndexMap::from([ - ("x".into(), NodeInfo::default()), - ("y".into(), NodeInfo::default()), + ("x".into(), NodeInfo::new("x")), + ("y".into(), NodeInfo::new("y")), ]), - edges: vec![], ..Default::default() }; assert_eq!(emit_to_string(&graph), ""); @@ -114,9 +113,9 @@ mod tests { nodes: IndexMap::from([( "a".into(), NodeInfo { - label: Some("Alpha".into()), + label: "Alpha".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, )]), edges: vec![Edge { diff --git a/crates/csvizmo-depgraph/src/emit/dot.rs b/crates/csvizmo-depgraph/src/emit/dot.rs index 31c5c93..6bb993c 100644 --- a/crates/csvizmo-depgraph/src/emit/dot.rs +++ b/crates/csvizmo-depgraph/src/emit/dot.rs @@ -100,8 +100,8 @@ fn emit_node( ) -> eyre::Result<()> { let indent = " ".repeat(depth); let mut attrs = Vec::new(); - if let Some(label) = &info.label { - attrs.push(format!("label={}", quote(label))); + if info.label != id { + attrs.push(format!("label={}", quote(&info.label))); } if let Some(node_type) = &info.node_type { attrs.push(format!("type={}", quote(node_type))); @@ -267,17 +267,10 @@ digraph { #[test] fn nodes_only() { let mut nodes = IndexMap::new(); - nodes.insert( - "x".into(), - NodeInfo { - label: Some("X Node".into()), - ..Default::default() - }, - ); - nodes.insert("y".into(), NodeInfo::default()); + nodes.insert("x".into(), NodeInfo::new("X Node")); + nodes.insert("y".into(), NodeInfo::new("y")); let graph = DepGraph { nodes, - edges: vec![], ..Default::default() }; let output = emit_to_string(&graph); @@ -298,17 +291,16 @@ digraph { nodes.insert( "mylib".into(), NodeInfo { - label: Some("My Library".into()), + label: "My Library".into(), + node_type: None, attrs: IndexMap::from([ ("shape".into(), "box".into()), ("version".into(), "1.0".into()), ]), - ..Default::default() }, ); let graph = DepGraph { nodes, - edges: vec![], ..Default::default() }; let output = emit_to_string(&graph); @@ -325,14 +317,8 @@ digraph { #[test] fn special_chars_in_ids() { let mut nodes = IndexMap::new(); - nodes.insert("my node".into(), NodeInfo::default()); - nodes.insert( - "has\"quotes".into(), - NodeInfo { - label: Some("a \"label\"".into()), - ..Default::default() - }, - ); + nodes.insert("my node".into(), NodeInfo::new("my node")); + nodes.insert("has\"quotes".into(), NodeInfo::new("a \"label\"")); let graph = DepGraph { nodes, edges: vec![Edge { @@ -358,8 +344,8 @@ digraph { #[test] fn bare_ids_not_quoted() { let mut nodes = IndexMap::new(); - nodes.insert("foo_bar".into(), NodeInfo::default()); - nodes.insert("Baz123".into(), NodeInfo::default()); + nodes.insert("foo_bar".into(), NodeInfo::new("foo_bar")); + nodes.insert("Baz123".into(), NodeInfo::new("Baz123")); let graph = DepGraph { nodes, edges: vec![Edge { @@ -385,8 +371,8 @@ digraph { #[test] fn dot_keyword_ids_are_quoted() { let mut nodes = IndexMap::new(); - nodes.insert("node".into(), NodeInfo::default()); - nodes.insert("edge".into(), NodeInfo::default()); + nodes.insert("node".into(), NodeInfo::new("node")); + nodes.insert("edge".into(), NodeInfo::new("edge")); let graph = DepGraph { nodes, edges: vec![Edge { @@ -412,9 +398,9 @@ digraph { #[test] fn dot_keyword_ids_case_insensitive() { let mut nodes = IndexMap::new(); - nodes.insert("Node".into(), NodeInfo::default()); - nodes.insert("GRAPH".into(), NodeInfo::default()); - nodes.insert("Subgraph".into(), NodeInfo::default()); + nodes.insert("Node".into(), NodeInfo::new("Node")); + nodes.insert("GRAPH".into(), NodeInfo::new("GRAPH")); + nodes.insert("Subgraph".into(), NodeInfo::new("Subgraph")); let graph = DepGraph { nodes, ..Default::default() @@ -435,10 +421,9 @@ digraph { #[test] fn digit_leading_id_is_quoted() { let mut nodes = IndexMap::new(); - nodes.insert("1abc".into(), NodeInfo::default()); + nodes.insert("1abc".into(), NodeInfo::new("1abc")); let graph = DepGraph { nodes, - edges: vec![], ..Default::default() }; let output = emit_to_string(&graph); @@ -514,8 +499,6 @@ digraph { fn graph_name_emitted() { let graph = DepGraph { id: Some("deps".into()), - nodes: IndexMap::new(), - edges: vec![], ..Default::default() }; let output = emit_to_string(&graph); @@ -527,8 +510,6 @@ digraph { let graph = DepGraph { id: Some("deps".into()), attrs: IndexMap::from([("rankdir".into(), "LR".into())]), - nodes: IndexMap::new(), - edges: vec![], ..Default::default() }; let output = emit_to_string(&graph); @@ -551,12 +532,12 @@ digraph deps { ( "a".into(), NodeInfo { - label: Some("A".into()), + label: "A".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, ), - ("b".into(), NodeInfo::default()), + ("b".into(), NodeInfo::new("b")), ]), edges: vec![Edge { from: "a".into(), @@ -586,13 +567,13 @@ digraph deps { #[test] fn subgraph_emitted() { let graph = DepGraph { - nodes: IndexMap::from([("top".into(), NodeInfo::default())]), + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), subgraphs: vec![DepGraph { id: Some("cluster0".into()), attrs: IndexMap::from([("label".into(), "Group A".into())]), nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), ]), edges: vec![Edge { from: "a".into(), @@ -632,9 +613,9 @@ digraph { nodes.insert( "mylib".into(), NodeInfo { - label: Some("My Library".into()), + label: "My Library".into(), node_type: Some("lib".into()), - ..Default::default() + attrs: Default::default(), }, ); let graph = DepGraph { @@ -652,7 +633,7 @@ digraph { nodes.insert( "mylib".into(), NodeInfo { - label: Some("My Library".into()), + label: "My Library".into(), node_type: Some("proc-macro".into()), attrs: IndexMap::from([ ("version".into(), "1.0".into()), @@ -677,7 +658,7 @@ digraph { nodes.insert( "mylib".into(), NodeInfo { - label: Some("My Library".into()), + label: "My Library".into(), node_type: None, attrs: IndexMap::from([("version".into(), "1.0".into())]), }, @@ -697,33 +678,33 @@ digraph { nodes.insert( "pm".into(), NodeInfo { + label: "pm".into(), node_type: Some("proc-macro".into()), attrs: IndexMap::from([("shape".into(), "diamond".into())]), - ..Default::default() }, ); nodes.insert( "mybin".into(), NodeInfo { + label: "mybin".into(), node_type: Some("bin".into()), attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, ); nodes.insert( "bs".into(), NodeInfo { + label: "bs".into(), node_type: Some("build-script".into()), attrs: IndexMap::from([("shape".into(), "note".into())]), - ..Default::default() }, ); nodes.insert( "opt".into(), NodeInfo { + label: "opt".into(), node_type: Some("optional".into()), attrs: IndexMap::from([("style".into(), "dashed".into())]), - ..Default::default() }, ); let graph = DepGraph { @@ -750,9 +731,9 @@ digraph { nodes.insert( "pm".into(), NodeInfo { + label: "pm".into(), node_type: Some("proc-macro".into()), attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, ); let graph = DepGraph { @@ -851,14 +832,7 @@ digraph { #[test] fn no_type_no_styling() { let mut nodes = IndexMap::new(); - nodes.insert( - "plain".into(), - NodeInfo { - label: Some("Plain".into()), - node_type: None, - ..Default::default() - }, - ); + nodes.insert("plain".into(), NodeInfo::new("Plain")); let graph = DepGraph { nodes, ..Default::default() diff --git a/crates/csvizmo-depgraph/src/emit/mermaid.rs b/crates/csvizmo-depgraph/src/emit/mermaid.rs index 972728b..a0fd168 100644 --- a/crates/csvizmo-depgraph/src/emit/mermaid.rs +++ b/crates/csvizmo-depgraph/src/emit/mermaid.rs @@ -126,7 +126,7 @@ fn emit_body(graph: &DepGraph, writer: &mut dyn Write, depth: usize) -> eyre::Re // in edges, but we emit them to show labels and shapes. for (id, info) in &graph.nodes { let sanitized = sanitize_id(id); - let label = info.label.as_deref().unwrap_or(id); + let label = &info.label; let escaped = escape_label(label); let shape = node_shape(info, &escaped); writeln!(writer, "{indent}{sanitized}{shape}")?; @@ -249,14 +249,8 @@ flowchart LR #[test] fn nodes_only() { let mut nodes = IndexMap::new(); - nodes.insert( - "x".into(), - NodeInfo { - label: Some("X Node".into()), - ..Default::default() - }, - ); - nodes.insert("y".into(), NodeInfo::default()); + nodes.insert("x".into(), NodeInfo::new("X Node")); + nodes.insert("y".into(), NodeInfo::new("y")); let graph = DepGraph { nodes, edges: vec![], @@ -279,41 +273,41 @@ flowchart LR nodes.insert( "lib1".into(), NodeInfo { - label: Some("Library".into()), + label: "Library".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "ellipse".into())]), - ..Default::default() }, ); nodes.insert( "bin1".into(), NodeInfo { - label: Some("Binary".into()), + label: "Binary".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, ); nodes.insert( "pm1".into(), NodeInfo { - label: Some("Proc Macro".into()), + label: "Proc Macro".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "diamond".into())]), - ..Default::default() }, ); nodes.insert( "bs1".into(), NodeInfo { - label: Some("Build Script".into()), + label: "Build Script".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "note".into())]), - ..Default::default() }, ); nodes.insert( "test1".into(), NodeInfo { - label: Some("Test".into()), + label: "Test".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "hexagon".into())]), - ..Default::default() }, ); let graph = DepGraph { @@ -357,7 +351,7 @@ flowchart LR fn direction_from_rankdir() { let graph = DepGraph { attrs: IndexMap::from([("rankdir".into(), "TB".into())]), - nodes: IndexMap::from([("a".into(), NodeInfo::default())]), + nodes: IndexMap::from([("a".into(), NodeInfo::new("a"))]), ..Default::default() }; let output = emit_to_string(&graph); @@ -368,7 +362,7 @@ flowchart LR fn direction_from_direction_attr() { let graph = DepGraph { attrs: IndexMap::from([("direction".into(), "RL".into())]), - nodes: IndexMap::from([("a".into(), NodeInfo::default())]), + nodes: IndexMap::from([("a".into(), NodeInfo::new("a"))]), ..Default::default() }; let output = emit_to_string(&graph); @@ -378,24 +372,12 @@ flowchart LR #[test] fn subgraph_emitted() { let graph = DepGraph { - nodes: IndexMap::from([("top".into(), NodeInfo::default())]), + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), subgraphs: vec![DepGraph { id: Some("backend".into()), nodes: IndexMap::from([ - ( - "api".into(), - NodeInfo { - label: Some("API Server".into()), - ..Default::default() - }, - ), - ( - "db".into(), - NodeInfo { - label: Some("Database".into()), - ..Default::default() - }, - ), + ("api".into(), NodeInfo::new("API Server")), + ("db".into(), NodeInfo::new("Database")), ]), edges: vec![Edge { from: "api".into(), @@ -430,14 +412,8 @@ flowchart LR #[test] fn special_chars_in_ids() { let mut nodes = IndexMap::new(); - nodes.insert("my node".into(), NodeInfo::default()); - nodes.insert( - "has\"quotes".into(), - NodeInfo { - label: Some("a \"label\"".into()), - ..Default::default() - }, - ); + nodes.insert("my node".into(), NodeInfo::new("my node")); + nodes.insert("has\"quotes".into(), NodeInfo::new("a \"label\"")); let graph = DepGraph { nodes, edges: vec![Edge { @@ -459,12 +435,12 @@ flowchart LR nodes.insert( "a".into(), NodeInfo { - label: Some("Alpha".into()), + label: "Alpha".into(), + node_type: None, attrs: IndexMap::from([ ("shape".into(), "box".into()), ("color".into(), "red".into()), ]), - ..Default::default() }, ); let graph = DepGraph { @@ -501,41 +477,41 @@ flowchart LR nodes.insert( "n1".into(), NodeInfo { - label: Some("Circle".into()), + label: "Circle".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "circle".into())]), - ..Default::default() }, ); nodes.insert( "n2".into(), NodeInfo { - label: Some("Diamond".into()), + label: "Diamond".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "diamond".into())]), - ..Default::default() }, ); nodes.insert( "n3".into(), NodeInfo { - label: Some("Hexagon".into()), + label: "Hexagon".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "hexagon".into())]), - ..Default::default() }, ); nodes.insert( "n4".into(), NodeInfo { - label: Some("Ellipse".into()), + label: "Ellipse".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "ellipse".into())]), - ..Default::default() }, ); nodes.insert( "n5".into(), NodeInfo { - label: Some("Cylinder".into()), + label: "Cylinder".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "cylinder".into())]), - ..Default::default() }, ); let graph = DepGraph { @@ -556,9 +532,9 @@ flowchart LR nodes.insert( "lib1".into(), NodeInfo { - label: Some("Library".into()), + label: "Library".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, ); let graph = DepGraph { @@ -576,9 +552,9 @@ flowchart LR nodes.insert( "n1".into(), NodeInfo { - label: Some("Unknown".into()), + label: "Unknown".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "trapezium".into())]), - ..Default::default() }, ); let graph = DepGraph { diff --git a/crates/csvizmo-depgraph/src/emit/mod.rs b/crates/csvizmo-depgraph/src/emit/mod.rs index 75d3f2e..1e2a13c 100644 --- a/crates/csvizmo-depgraph/src/emit/mod.rs +++ b/crates/csvizmo-depgraph/src/emit/mod.rs @@ -7,8 +7,66 @@ mod tree; mod walk; use std::io::Write; +use std::path::Path; -use crate::{DepGraph, OutputFormat}; +use clap::ValueEnum; + +use crate::DepGraph; + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum OutputFormat { + Dot, + Mermaid, + Tgf, + Depfile, + Tree, + Pathlist, +} + +impl TryFrom<&Path> for OutputFormat { + type Error = eyre::Report; + + fn try_from(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; + match ext { + "dot" | "gv" => Ok(Self::Dot), + "mmd" | "mermaid" => Ok(Self::Mermaid), + "tgf" => Ok(Self::Tgf), + "d" => Ok(Self::Depfile), + _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), + } + } +} + +/// Resolve output format using explicit flag, file extension, or default to DOT. +/// +/// Resolution order: +/// 1. Explicit flag if provided +/// 2. File extension if path is available +/// 3. Default to DOT format +/// +/// Returns an error if file extension is present but unrecognized. +pub fn resolve_output_format( + flag: Option, + path: Option<&Path>, +) -> eyre::Result { + if let Some(f) = flag { + return Ok(f); + } + match path.map(OutputFormat::try_from) { + Some(Ok(f)) => { + tracing::info!("Detected output format: {f:?} from file extension"); + Ok(f) + } + Some(Err(e)) => Err( + e.wrap_err("Failed to detect output format from file extension; use --output-format") + ), + None => Ok(OutputFormat::Dot), + } +} /// Emit a [`DepGraph`] in the given output format. /// @@ -48,21 +106,9 @@ pub(crate) mod fixtures { /// A small graph for testing: a -> b -> c, a -> c pub fn sample_graph() -> DepGraph { let mut nodes = IndexMap::new(); - nodes.insert( - "a".into(), - NodeInfo { - label: Some("alpha".into()), - ..Default::default() - }, - ); - nodes.insert( - "b".into(), - NodeInfo { - label: Some("bravo".into()), - ..Default::default() - }, - ); - nodes.insert("c".into(), NodeInfo::default()); + nodes.insert("a".into(), NodeInfo::new("alpha")); + nodes.insert("b".into(), NodeInfo::new("bravo")); + nodes.insert("c".into(), NodeInfo::new("c")); DepGraph { nodes, diff --git a/crates/csvizmo-depgraph/src/emit/pathlist.rs b/crates/csvizmo-depgraph/src/emit/pathlist.rs index 9aeebe7..66acb33 100644 --- a/crates/csvizmo-depgraph/src/emit/pathlist.rs +++ b/crates/csvizmo-depgraph/src/emit/pathlist.rs @@ -38,7 +38,7 @@ struct PathlistVisitor<'w> { impl TreeVisitor for PathlistVisitor<'_> { fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()> { self.stack.truncate(ctx.depth); - let label = ctx.info.label.as_deref().unwrap_or(ctx.node); + let label = &ctx.info.label; self.stack.push(label.to_string()); let is_leaf = ctx.child_count == 0; @@ -88,13 +88,7 @@ mod tests { #[test] fn single_node() { let graph = DepGraph { - nodes: IndexMap::from([( - "readme".into(), - NodeInfo { - label: Some("README.md".into()), - ..Default::default() - }, - )]), + nodes: IndexMap::from([("readme".into(), NodeInfo::new("README.md"))]), ..Default::default() }; assert_eq!(emit_to_string(&graph), "README.md\n"); @@ -104,27 +98,9 @@ mod tests { fn linear_chain() { let graph = DepGraph { nodes: IndexMap::from([ - ( - "src".into(), - NodeInfo { - label: Some("src".into()), - ..Default::default() - }, - ), - ( - "src/parse".into(), - NodeInfo { - label: Some("parse".into()), - ..Default::default() - }, - ), - ( - "src/parse/tgf.rs".into(), - NodeInfo { - label: Some("tgf.rs".into()), - ..Default::default() - }, - ), + ("src".into(), NodeInfo::new("src")), + ("src/parse".into(), NodeInfo::new("parse")), + ("src/parse/tgf.rs".into(), NodeInfo::new("tgf.rs")), ]), edges: vec![ Edge { @@ -147,27 +123,9 @@ mod tests { fn branching() { let graph = DepGraph { nodes: IndexMap::from([ - ( - "src".into(), - NodeInfo { - label: Some("src".into()), - ..Default::default() - }, - ), - ( - "src/a.rs".into(), - NodeInfo { - label: Some("a.rs".into()), - ..Default::default() - }, - ), - ( - "src/b.rs".into(), - NodeInfo { - label: Some("b.rs".into()), - ..Default::default() - }, - ), + ("src".into(), NodeInfo::new("src")), + ("src/a.rs".into(), NodeInfo::new("a.rs")), + ("src/b.rs".into(), NodeInfo::new("b.rs")), ]), edges: vec![ Edge { @@ -191,34 +149,10 @@ mod tests { // a -> b -> d, a -> c -> d let graph = DepGraph { nodes: IndexMap::from([ - ( - "a".into(), - NodeInfo { - label: Some("a".into()), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() - }, - ), - ( - "c".into(), - NodeInfo { - label: Some("c".into()), - ..Default::default() - }, - ), - ( - "d".into(), - NodeInfo { - label: Some("d".into()), - ..Default::default() - }, - ), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), ]), edges: vec![ Edge { @@ -255,10 +189,10 @@ mod tests { // c has children (d), so when revisited under a it's a suppressed subtree. let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), ]), edges: vec![ Edge { @@ -293,27 +227,9 @@ mod tests { // a -> b -> c -> b let graph = DepGraph { nodes: IndexMap::from([ - ( - "a".into(), - NodeInfo { - label: Some("a".into()), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() - }, - ), - ( - "c".into(), - NodeInfo { - label: Some("c".into()), - ..Default::default() - }, - ), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), ]), edges: vec![ Edge { @@ -342,8 +258,8 @@ mod tests { // Nodes without labels use the node ID as the path component. let graph = DepGraph { nodes: IndexMap::from([ - ("root".into(), NodeInfo::default()), - ("child".into(), NodeInfo::default()), + ("root".into(), NodeInfo::new("root")), + ("child".into(), NodeInfo::new("child")), ]), edges: vec![Edge { from: "root".into(), @@ -368,20 +284,8 @@ mod tests { fn multiple_roots() { let graph = DepGraph { nodes: IndexMap::from([ - ( - "a".into(), - NodeInfo { - label: Some("a".into()), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() - }, - ), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), ]), ..Default::default() }; @@ -396,18 +300,12 @@ mod tests { ( "a".into(), NodeInfo { - label: Some("a".into()), + label: "a".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() }, ), + ("b".into(), NodeInfo::new("b")), ]), edges: vec![Edge { from: "a".into(), @@ -423,40 +321,28 @@ mod tests { #[test] fn roundtrip_simple() { let input = "src/a.rs\nsrc/b.rs\n"; - let graph = crate::parse::parse(crate::InputFormat::Pathlist, input).unwrap(); + let graph = crate::parse::parse(crate::parse::InputFormat::Pathlist, input).unwrap(); assert_eq!(emit_to_string(&graph), input); } #[test] fn roundtrip_nested() { let input = "a/b/c\na/b/d\na/e\n"; - let graph = crate::parse::parse(crate::InputFormat::Pathlist, input).unwrap(); + let graph = crate::parse::parse(crate::parse::InputFormat::Pathlist, input).unwrap(); assert_eq!(emit_to_string(&graph), input); } #[test] fn subgraph_nodes_included() { let graph = DepGraph { - nodes: IndexMap::from([( - "root".into(), - NodeInfo { - label: Some("root".into()), - ..Default::default() - }, - )]), + nodes: IndexMap::from([("root".into(), NodeInfo::new("root"))]), edges: vec![Edge { from: "root".into(), to: "child".into(), ..Default::default() }], subgraphs: vec![DepGraph { - nodes: IndexMap::from([( - "child".into(), - NodeInfo { - label: Some("child".into()), - ..Default::default() - }, - )]), + nodes: IndexMap::from([("child".into(), NodeInfo::new("child"))]), ..Default::default() }], ..Default::default() diff --git a/crates/csvizmo-depgraph/src/emit/tgf.rs b/crates/csvizmo-depgraph/src/emit/tgf.rs index 258bdaa..253c4f5 100644 --- a/crates/csvizmo-depgraph/src/emit/tgf.rs +++ b/crates/csvizmo-depgraph/src/emit/tgf.rs @@ -9,9 +9,10 @@ use crate::DepGraph; /// (TGF has no syntax for them). pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { for (id, info) in graph.all_nodes() { - match &info.label { - Some(label) => writeln!(writer, "{id}\t{label}")?, - None => writeln!(writer, "{id}")?, + if info.label != *id { + writeln!(writer, "{id}\t{}", info.label)?; + } else { + writeln!(writer, "{id}")?; } } @@ -57,13 +58,7 @@ mod tests { #[test] fn emit_nodes_only() { let mut nodes = IndexMap::new(); - nodes.insert( - "x".into(), - NodeInfo { - label: Some("xray".into()), - ..Default::default() - }, - ); + nodes.insert("x".into(), NodeInfo::new("xray")); let graph = DepGraph { nodes, edges: vec![], @@ -81,15 +76,15 @@ mod tests { nodes.insert( "a".into(), NodeInfo { - label: Some("Alpha".into()), + label: "Alpha".into(), + node_type: None, attrs: IndexMap::from([ ("shape".into(), "box".into()), ("color".into(), "red".into()), ]), - ..Default::default() }, ); - nodes.insert("b".into(), NodeInfo::default()); + nodes.insert("b".into(), NodeInfo::new("b")); let graph = DepGraph { attrs: IndexMap::from([ ("name".into(), "deps".into()), @@ -114,7 +109,7 @@ mod tests { #[test] fn subgraph_nodes_and_edges_included() { let graph = DepGraph { - nodes: IndexMap::from([("top".into(), NodeInfo::default())]), + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), edges: vec![Edge { from: "top".into(), to: "a".into(), @@ -122,14 +117,8 @@ mod tests { }], subgraphs: vec![DepGraph { nodes: IndexMap::from([ - ( - "a".into(), - NodeInfo { - label: Some("Alpha".into()), - ..Default::default() - }, - ), - ("b".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("Alpha")), + ("b".into(), NodeInfo::new("b")), ]), edges: vec![Edge { from: "a".into(), diff --git a/crates/csvizmo-depgraph/src/emit/tree.rs b/crates/csvizmo-depgraph/src/emit/tree.rs index 6e337c3..2178702 100644 --- a/crates/csvizmo-depgraph/src/emit/tree.rs +++ b/crates/csvizmo-depgraph/src/emit/tree.rs @@ -56,7 +56,7 @@ impl TreeVisitor for TreeEmitVisitor<'_> { } } - let label = ctx.info.label.as_deref().unwrap_or(ctx.node); + let label = &ctx.info.label; write!(self.writer, "{label}")?; match ctx.status { @@ -97,13 +97,7 @@ mod tests { #[test] fn single_root() { let graph = DepGraph { - nodes: IndexMap::from([( - "r".into(), - NodeInfo { - label: Some("root".into()), - ..Default::default() - }, - )]), + nodes: IndexMap::from([("r".into(), NodeInfo::new("root"))]), ..Default::default() }; assert_eq!(emit_to_string(&graph), "root\n"); @@ -114,27 +108,9 @@ mod tests { // root -> a, root -> b let graph = DepGraph { nodes: IndexMap::from([ - ( - "root".into(), - NodeInfo { - label: Some("root".into()), - ..Default::default() - }, - ), - ( - "a".into(), - NodeInfo { - label: Some("alpha".into()), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("bravo".into()), - ..Default::default() - }, - ), + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("alpha")), + ("b".into(), NodeInfo::new("bravo")), ]), edges: vec![ Edge { @@ -165,34 +141,10 @@ root // root -> a -> b, root -> c let graph = DepGraph { nodes: IndexMap::from([ - ( - "root".into(), - NodeInfo { - label: Some("root".into()), - ..Default::default() - }, - ), - ( - "a".into(), - NodeInfo { - label: Some("a".into()), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() - }, - ), - ( - "c".into(), - NodeInfo { - label: Some("c".into()), - ..Default::default() - }, - ), + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), ]), edges: vec![ Edge { @@ -230,11 +182,11 @@ root // Verifies continuation columns render correctly at depth 3. let graph = DepGraph { nodes: IndexMap::from([ - ("root".into(), NodeInfo::default()), - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), ]), edges: vec![ Edge { @@ -281,12 +233,12 @@ root // appear in parallel when rendering x at depth 3. let graph = DepGraph { nodes: IndexMap::from([ - ("root".into(), NodeInfo::default()), - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), - ("x".into(), NodeInfo::default()), + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ("x".into(), NodeInfo::new("x")), ]), edges: vec![ Edge { @@ -336,10 +288,10 @@ root // d is a leaf -- no subtree suppressed, no marker. let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), ]), edges: vec![ Edge { @@ -383,10 +335,10 @@ a // c is expanded under b (showing d), then truncated under a. let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), ]), edges: vec![ Edge { @@ -429,9 +381,9 @@ a // a -> b -> c -> b let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), ]), edges: vec![ Edge { @@ -467,8 +419,8 @@ a fn falls_back_to_node_id() { let graph = DepGraph { nodes: IndexMap::from([ - ("root".into(), NodeInfo::default()), - ("child".into(), NodeInfo::default()), + ("root".into(), NodeInfo::new("root")), + ("child".into(), NodeInfo::new("child")), ]), edges: vec![Edge { from: "root".into(), @@ -506,8 +458,8 @@ alpha fn multiple_roots() { let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), ]), ..Default::default() }; @@ -522,7 +474,7 @@ root │ └── b └── c "; - let graph = crate::parse::parse(crate::InputFormat::Tree, input).unwrap(); + let graph = crate::parse::parse(crate::parse::InputFormat::Tree, input).unwrap(); assert_eq!(emit_to_string(&graph), input); } @@ -534,18 +486,12 @@ root ( "a".into(), NodeInfo { - label: Some("a".into()), + label: "a".into(), + node_type: None, attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() - }, - ), - ( - "b".into(), - NodeInfo { - label: Some("b".into()), - ..Default::default() }, ), + ("b".into(), NodeInfo::new("b")), ]), edges: vec![Edge { from: "a".into(), diff --git a/crates/csvizmo-depgraph/src/emit/walk.rs b/crates/csvizmo-depgraph/src/emit/walk.rs index eb82c4b..e47c695 100644 --- a/crates/csvizmo-depgraph/src/emit/walk.rs +++ b/crates/csvizmo-depgraph/src/emit/walk.rs @@ -18,7 +18,9 @@ pub enum VisitStatus { /// Context passed to a [`TreeVisitor`] at each visited node. pub struct VisitContext<'a> { /// Node ID. - pub node: &'a str, + // the two DFS emitters only use the node label, but the ID is still available for future visitors + #[allow(unused)] + pub node_id: &'a str, /// Node metadata (label, attrs). pub info: &'a NodeInfo, /// Depth in the traversal tree (0 for roots). @@ -49,7 +51,7 @@ pub fn walk(graph: &DepGraph, visitor: &mut dyn TreeVisitor) -> eyre::Result<()> let data = GraphData { nodes: graph.all_nodes(), adj: graph.adjacency_list(), - default_info: NodeInfo::default(), + default_info: NodeInfo::new(""), }; // Find roots: nodes with no incoming edges. @@ -57,7 +59,7 @@ pub fn walk(graph: &DepGraph, visitor: &mut dyn TreeVisitor) -> eyre::Result<()> let roots: Vec<&str> = data .nodes .keys() - .copied() + .map(String::as_str) .filter(|n| !targets.contains(n)) .collect(); @@ -81,8 +83,8 @@ pub fn walk(graph: &DepGraph, visitor: &mut dyn TreeVisitor) -> eyre::Result<()> } struct GraphData<'a> { - nodes: IndexMap<&'a str, &'a NodeInfo>, - adj: IndexMap<&'a str, Vec<&'a str>>, + nodes: &'a IndexMap, + adj: &'a IndexMap>, default_info: NodeInfo, } @@ -95,14 +97,14 @@ fn dfs<'a>( in_progress: &mut HashSet<&'a str>, visitor: &mut dyn TreeVisitor, ) -> eyre::Result<()> { - let info = data.nodes.get(node).copied().unwrap_or(&data.default_info); + let info = data.nodes.get(node).unwrap_or(&data.default_info); let children = data.adj.get(node); let child_count = children.map_or(0, |c| c.len()); // Cycle: node is an ancestor on the current DFS path. if in_progress.contains(node) { visitor.visit(&VisitContext { - node, + node_id: node, info, depth, is_last, @@ -115,7 +117,7 @@ fn dfs<'a>( // Already expanded: node was fully visited from a different path. if visited.contains(node) { visitor.visit(&VisitContext { - node, + node_id: node, info, depth, is_last, @@ -127,7 +129,7 @@ fn dfs<'a>( // First visit. visitor.visit(&VisitContext { - node, + node_id: node, info, depth, is_last, @@ -141,7 +143,7 @@ fn dfs<'a>( let len = children.len(); for (i, child) in children.iter().enumerate() { dfs( - child, + child.as_str(), depth + 1, i == len - 1, data, @@ -203,7 +205,7 @@ mod tests { impl TreeVisitor for CollectVisitor { fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()> { self.visits.push(Visit { - node: ctx.node.to_string(), + node: ctx.node_id.to_string(), depth: ctx.depth, is_last: ctx.is_last, child_count: ctx.child_count, @@ -223,7 +225,14 @@ mod tests { #[test] fn single_node() { let graph = DepGraph { - nodes: IndexMap::from([("a".into(), NodeInfo::default())]), + nodes: IndexMap::from([( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + )]), ..Default::default() }; let mut visitor = CollectVisitor::new(); @@ -235,9 +244,30 @@ mod tests { fn linear_chain() { let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![ Edge { @@ -271,10 +301,38 @@ mod tests { // d is expanded under b, then AlreadyExpanded under c. let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), - ("d".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "d".into(), + NodeInfo { + label: "d".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![ Edge { @@ -319,8 +377,22 @@ mod tests { // a -> b -> a let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![ Edge { @@ -346,7 +418,14 @@ mod tests { fn self_loop() { // a -> a let graph = DepGraph { - nodes: IndexMap::from([("a".into(), NodeInfo::default())]), + nodes: IndexMap::from([( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + )]), edges: vec![Edge { from: "a".into(), to: "a".into(), @@ -366,9 +445,30 @@ mod tests { // a -> b -> c -> b (c cycles back to b, a is the root) let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![ Edge { @@ -407,9 +507,30 @@ mod tests { // a (isolated), b -> c let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![Edge { from: "b".into(), @@ -435,9 +556,30 @@ mod tests { // a -> c, b -> c (both a and b are roots, c is shared) let graph = DepGraph { nodes: IndexMap::from([ - ("a".into(), NodeInfo::default()), - ("b".into(), NodeInfo::default()), - ("c".into(), NodeInfo::default()), + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![ Edge { @@ -486,7 +628,14 @@ mod tests { #[test] fn subgraph_nodes_included() { let graph = DepGraph { - nodes: IndexMap::from([("root".into(), NodeInfo::default())]), + nodes: IndexMap::from([( + "root".into(), + NodeInfo { + label: "root".into(), + node_type: None, + attrs: Default::default(), + }, + )]), edges: vec![Edge { from: "root".into(), to: "sub_a".into(), @@ -494,8 +643,22 @@ mod tests { }], subgraphs: vec![DepGraph { nodes: IndexMap::from([ - ("sub_a".into(), NodeInfo::default()), - ("sub_b".into(), NodeInfo::default()), + ( + "sub_a".into(), + NodeInfo { + label: "sub_a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "sub_b".into(), + NodeInfo { + label: "sub_b".into(), + node_type: None, + attrs: Default::default(), + }, + ), ]), edges: vec![Edge { from: "sub_a".into(), diff --git a/crates/csvizmo-depgraph/src/graph.rs b/crates/csvizmo-depgraph/src/graph.rs new file mode 100644 index 0000000..1e456ee --- /dev/null +++ b/crates/csvizmo-depgraph/src/graph.rs @@ -0,0 +1,604 @@ +use std::cell::OnceCell; +use std::collections::{HashSet, VecDeque}; + +use indexmap::IndexMap; +use petgraph::Direction; +use petgraph::graph::{DiGraph, NodeIndex}; + +#[derive(Clone, Default)] +pub struct DepGraph { + /// Graph or subgraph identifier (e.g. DOT `digraph ` / `subgraph `). + pub id: Option, + /// Graph-level attributes (e.g. DOT `rankdir`, `label`, `color`). + pub attrs: IndexMap, + pub nodes: IndexMap, + pub edges: Vec, + /// Nested subgraphs, each owning its own nodes and edges. + pub subgraphs: Vec, + + /// Cached flattened nodes from [`Self::all_nodes`]. Lazily populated on first access. + pub(crate) all_nodes_cache: OnceCell>, + /// Cached flattened edges from [`Self::all_edges`]. Lazily populated on first access. + pub(crate) all_edges_cache: OnceCell>, + /// Cached adjacency list from [`Self::adjacency_list`]. Lazily populated on first access. + pub(crate) adjacency_cache: OnceCell>>, +} + +impl DepGraph { + /// Collect all nodes from this graph and all nested subgraphs. + /// + /// The result is cached internally using interior mutability. The first call recurses over + /// subgraphs in DFS order and clones all node data into an owned map; subsequent calls + /// return a reference to the cached result. + pub fn all_nodes(&self) -> &IndexMap { + self.all_nodes_cache.get_or_init(|| { + let mut result = IndexMap::new(); + self.collect_nodes(&mut result); + result + }) + } + + fn collect_nodes(&self, result: &mut IndexMap) { + for (id, info) in &self.nodes { + result.insert(id.clone(), info.clone()); + } + for sg in &self.subgraphs { + sg.collect_nodes(result); + } + } + + /// Collect all edges from this graph and all nested subgraphs. + /// + /// The result is cached internally using interior mutability. The first call recurses over + /// subgraphs in DFS order and clones all edge data into an owned vec; subsequent calls + /// return a reference to the cached result. + pub fn all_edges(&self) -> &Vec { + self.all_edges_cache.get_or_init(|| { + let mut result = Vec::new(); + self.collect_edges(&mut result); + result + }) + } + + fn collect_edges(&self, result: &mut Vec) { + result.extend(self.edges.iter().cloned()); + for sg in &self.subgraphs { + sg.collect_edges(result); + } + } + + /// Clear all internal caches on this graph and its subgraphs. + /// + /// Call this after mutating nodes, edges, or subgraphs so that subsequent calls to + /// [`Self::all_nodes`], [`Self::all_edges`], or [`Self::adjacency_list`] recompute. + pub(crate) fn clear_caches(&mut self) { + self.all_nodes_cache.take(); + self.all_edges_cache.take(); + self.adjacency_cache.take(); + for sg in &mut self.subgraphs { + sg.clear_caches(); + } + } + + /// Build an adjacency list from all edges across all subgraphs. + /// + /// The result is cached internally using interior mutability. The first call builds the + /// adjacency map from [`Self::all_edges`]; subsequent calls return a reference to the + /// cached result. + pub fn adjacency_list(&self) -> &IndexMap> { + self.adjacency_cache.get_or_init(|| { + let mut adj = IndexMap::new(); + for edge in self.all_edges() { + adj.entry(edge.from.clone()) + .or_insert_with(Vec::new) + .push(edge.to.clone()); + } + adj + }) + } +} + +#[derive(Clone, Debug)] +pub struct NodeInfo { + pub label: String, + /// Node type/kind (e.g. "lib", "bin", "proc-macro", "build-script"). + /// Semantics are format-specific on input; normalized to canonical names where possible. + /// Formats that don't support types leave this as None. + pub node_type: Option, + /// Arbitrary extra attributes. Parsers populate these from format-specific features; + /// emitters carry them through where the output format allows. + pub attrs: IndexMap, +} + +impl NodeInfo { + /// Create a new NodeInfo with the given label. + /// Node type and attributes are initialized to their defaults (None and empty, respectively). + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + node_type: None, + attrs: Default::default(), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Edge { + pub from: String, + pub to: String, + pub label: Option, + /// Arbitrary extra attributes (e.g. DOT `style`, `color`). + pub attrs: IndexMap, +} + +/// A flattened view of a [`DepGraph`] as a petgraph [`DiGraph`]. +/// +/// Bridges `DepGraph` (which has nested subgraphs and string-keyed maps) with petgraph's +/// graph algorithms by flattening all nodes and edges into a single directed graph. +pub struct FlatGraphView<'a> { + /// The source dependency graph. + pub graph: &'a DepGraph, + /// Flattened petgraph with all nodes and edges from all subgraph levels. + pub pg: DiGraph<(), ()>, + /// Map from node ID string to petgraph NodeIndex. + pub id_to_idx: IndexMap<&'a str, NodeIndex>, + /// Map from petgraph NodeIndex (as usize index) to node ID string. + pub idx_to_id: Vec<&'a str>, +} + +impl<'a> FlatGraphView<'a> { + /// Create a new `FlatGraphView` from a `DepGraph`. + /// + /// Collects all nodes and edges from the graph and its nested subgraphs into a flat + /// petgraph `DiGraph`. Edges whose endpoints are not present in the node set are skipped. + pub fn new(graph: &'a DepGraph) -> Self { + let all_nodes = graph.all_nodes(); + let all_edges = graph.all_edges(); + + let mut pg = DiGraph::new(); + let mut id_to_idx = IndexMap::new(); + let mut idx_to_id = Vec::with_capacity(all_nodes.len()); + + for id in all_nodes.keys() { + let idx = pg.add_node(()); + id_to_idx.insert(id.as_str(), idx); + idx_to_id.push(id.as_str()); + } + + for edge in all_edges { + let from = id_to_idx.get(edge.from.as_str()); + let to = id_to_idx.get(edge.to.as_str()); + if let (Some(&from_idx), Some(&to_idx)) = (from, to) { + pg.add_edge(from_idx, to_idx, ()); + } + } + + Self { + graph, + pg, + id_to_idx, + idx_to_id, + } + } + + /// Return all root nodes (nodes with no incoming edges). + pub fn roots(&self) -> impl Iterator + '_ { + self.pg.node_indices().filter(|&idx| { + self.pg + .neighbors_directed(idx, Direction::Incoming) + .next() + .is_none() + }) + } + + /// BFS from `seeds` following edges in `direction`, returning all visited nodes. + /// + /// If `max_depth` is `Some(n)`, only nodes within `n` hops of a seed are included. + /// The seeds themselves are always included (depth 0). + pub fn bfs( + &self, + seeds: impl IntoIterator, + direction: Direction, + max_depth: Option, + ) -> HashSet { + let mut visited = HashSet::new(); + let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new(); + for seed in seeds { + if visited.insert(seed) { + queue.push_back((seed, 0)); + } + } + + while let Some((node, depth)) = queue.pop_front() { + if max_depth.is_some_and(|max| depth >= max) { + continue; + } + for neighbor in self.pg.neighbors_directed(node, direction) { + if visited.insert(neighbor) { + queue.push_back((neighbor, depth + 1)); + } + } + } + + visited + } + + /// Filter the original `DepGraph` to only include nodes in the `keep` set. + /// + /// Returns a new `DepGraph` that preserves the original subgraph structure but only + /// contains nodes whose `NodeIndex` is in `keep`, plus edges where both endpoints survive. + /// Empty subgraphs (no nodes and no non-empty child subgraphs) are dropped. + pub fn filter(&self, keep: &HashSet) -> DepGraph { + let keep_ids: HashSet<&str> = keep + .iter() + .filter_map(|idx| self.idx_to_id.get(idx.index()).copied()) + .collect(); + filter_depgraph(self.graph, &keep_ids) + } +} + +fn filter_depgraph(graph: &DepGraph, keep: &HashSet<&str>) -> DepGraph { + DepGraph { + id: graph.id.clone(), + attrs: graph.attrs.clone(), + nodes: graph + .nodes + .iter() + .filter(|(id, _)| keep.contains(id.as_str())) + .map(|(id, info)| (id.clone(), info.clone())) + .collect(), + edges: graph + .edges + .iter() + .filter(|e| keep.contains(e.from.as_str()) && keep.contains(e.to.as_str())) + .cloned() + .collect(), + subgraphs: graph + .subgraphs + .iter() + .map(|sg| filter_depgraph(sg, keep)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_graph( + nodes: &[(&str, &str)], + edges: &[(&str, &str)], + subgraphs: Vec, + ) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + subgraphs, + ..Default::default() + } + } + + #[test] + fn new_empty() { + let g = DepGraph::default(); + let view = FlatGraphView::new(&g); + assert_eq!(view.pg.node_count(), 0); + assert_eq!(view.pg.edge_count(), 0); + assert!(view.id_to_idx.is_empty()); + assert!(view.idx_to_id.is_empty()); + } + + #[test] + fn new_flat() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 3); + assert_eq!(view.pg.edge_count(), 2); + + // Round-trip: id -> idx -> id + for &id in &["a", "b", "c"] { + let idx = view.id_to_idx[id]; + assert_eq!(view.idx_to_id[idx.index()], id); + } + } + + #[test] + fn new_with_subgraphs() { + let sub = make_graph(&[("c", "C")], &[("a", "c")], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![sub]); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 3); + // a->b from root, a->c from subgraph + assert_eq!(view.pg.edge_count(), 2); + assert!(view.id_to_idx.contains_key("c")); + } + + #[test] + fn new_skips_dangling_edges() { + let g = make_graph( + &[("a", "A")], + &[("a", "b"), ("x", "a")], // b and x don't exist + vec![], + ); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 1); + assert_eq!(view.pg.edge_count(), 0); + } + + #[test] + fn filter_keeps_matching_nodes() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + let keep: HashSet = ["a", "b"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.nodes.contains_key("a")); + assert!(filtered.nodes.contains_key("b")); + assert_eq!(filtered.edges.len(), 1); + assert_eq!(filtered.edges[0].from, "a"); + assert_eq!(filtered.edges[0].to, "b"); + } + + #[test] + fn filter_drops_unmatched_edges() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + // Keep a and c but not b -- both edges touch b so both are dropped + let keep: HashSet = ["a", "c"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.edges.is_empty()); + } + + #[test] + fn filter_preserves_subgraph_structure() { + let sub = make_graph(&[("c", "C")], &[], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![sub]); + let view = FlatGraphView::new(&g); + + // Keep all three nodes + let keep: HashSet = ["a", "b", "c"] + .iter() + .map(|id| view.id_to_idx[*id]) + .collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); // a, b at root + assert_eq!(filtered.subgraphs.len(), 1); + assert_eq!(filtered.subgraphs[0].nodes.len(), 1); // c in subgraph + assert!(filtered.subgraphs[0].nodes.contains_key("c")); + } + + #[test] + fn filter_drops_empty_subgraphs() { + let sub = make_graph(&[("c", "C")], &[], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[], vec![sub]); + let view = FlatGraphView::new(&g); + + // Keep only root nodes, subgraph node c is excluded + let keep: HashSet = ["a", "b"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.subgraphs.is_empty()); + } + + #[test] + fn filter_preserves_subgraph_attrs() { + let mut sub = make_graph(&[("c", "C")], &[], vec![]); + sub.id = Some("cluster_0".to_string()); + sub.attrs.insert("color".to_string(), "blue".to_string()); + + let g = make_graph(&[("a", "A")], &[], vec![sub]); + let view = FlatGraphView::new(&g); + + let keep: HashSet = ["a", "c"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.subgraphs.len(), 1); + assert_eq!(filtered.subgraphs[0].id.as_deref(), Some("cluster_0")); + assert_eq!( + filtered.subgraphs[0].attrs.get("color").map(String::as_str), + Some("blue") + ); + } + + // -- roots -- + + #[test] + fn roots_empty_graph() { + let g = DepGraph::default(); + let view = FlatGraphView::new(&g); + assert_eq!(view.roots().count(), 0); + } + + #[test] + fn roots_no_edges() { + let g = make_graph(&[("a", "A"), ("b", "B")], &[], vec![]); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a", "b"]); + } + + #[test] + fn roots_chain() { + // a -> b -> c: only a is a root + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a"]); + } + + #[test] + fn roots_diamond() { + // a -> b, a -> c, b -> d, c -> d + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a"]); + } + + #[test] + fn roots_cycle() { + // a -> b -> c -> a: every node has an incoming edge, no roots + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("c", "a")], + vec![], + ); + let view = FlatGraphView::new(&g); + assert_eq!(view.roots().count(), 0); + } + + // -- bfs -- + + #[test] + fn bfs_outgoing_full() { + // a -> b -> c + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, None); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn bfs_incoming_full() { + // a -> b -> c: ancestors of c = {a, b, c} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["c"]], Direction::Incoming, None); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn bfs_depth_limited() { + // a -> b -> c -> d: depth 1 from a = {a, b} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, Some(1)); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b"]); + } + + #[test] + fn bfs_depth_zero() { + // depth 0 from a = just {a} + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![]); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, Some(0)); + let ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(ids, vec!["a"]); + } + + #[test] + fn bfs_multiple_seeds() { + // a -> b, c -> d: seeds {a, c} outgoing = {a, b, c, d} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs( + [view.id_to_idx["a"], view.id_to_idx["c"]], + Direction::Outgoing, + None, + ); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c", "d"]); + } + + #[test] + fn bfs_cycle() { + // a -> b -> c -> a: full traversal doesn't loop forever + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("c", "a")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, None); + assert_eq!(result.len(), 3); + } +} diff --git a/crates/csvizmo-depgraph/src/lib.rs b/crates/csvizmo-depgraph/src/lib.rs index f8f4d43..2ac4fca 100644 --- a/crates/csvizmo-depgraph/src/lib.rs +++ b/crates/csvizmo-depgraph/src/lib.rs @@ -1,187 +1,7 @@ -use std::fmt; -use std::path::Path; - -use clap::ValueEnum; -use indexmap::IndexMap; - +pub mod algorithm; pub mod detect; pub mod emit; +mod graph; pub mod parse; -pub mod style; - -/// Variant order defines content-detection priority (most specific first). -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] -pub enum InputFormat { - CargoMetadata, - Mermaid, - Dot, - Tgf, - Depfile, - CargoTree, - Tree, - Pathlist, -} - -impl fmt::Display for InputFormat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.to_possible_value().unwrap().get_name()) - } -} - -impl TryFrom<&Path> for InputFormat { - type Error = eyre::Report; - - fn try_from(path: &Path) -> Result { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; - match ext { - "dot" | "gv" => Ok(Self::Dot), - "mmd" | "mermaid" => Ok(Self::Mermaid), - "tgf" => Ok(Self::Tgf), - "d" => Ok(Self::Depfile), - "json" => Ok(Self::CargoMetadata), - _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), - } - } -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -pub enum OutputFormat { - Dot, - Mermaid, - Tgf, - Depfile, - Tree, - Pathlist, -} - -impl TryFrom<&Path> for OutputFormat { - type Error = eyre::Report; - - fn try_from(path: &Path) -> Result { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; - match ext { - "dot" | "gv" => Ok(Self::Dot), - "mmd" | "mermaid" => Ok(Self::Mermaid), - "tgf" => Ok(Self::Tgf), - "d" => Ok(Self::Depfile), - _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct DepGraph { - /// Graph or subgraph identifier (e.g. DOT `digraph ` / `subgraph `). - pub id: Option, - /// Graph-level attributes (e.g. DOT `rankdir`, `label`, `color`). - pub attrs: IndexMap, - pub nodes: IndexMap, - pub edges: Vec, - /// Nested subgraphs, each owning its own nodes and edges. - pub subgraphs: Vec, -} - -impl DepGraph { - /// Collect all nodes from this graph and all nested subgraphs. - /// - /// This function recurses over subgraphs to aggregate the results. If you're doing repeated - /// lookups, consider caching the results. - pub fn all_nodes(&self) -> IndexMap<&str, &NodeInfo> { - let mut result = IndexMap::new(); - // Recurse over the subgraphs in DFS order to collect nodes from each - self.collect_nodes(&mut result); - result - } - - fn collect_nodes<'a>(&'a self, result: &mut IndexMap<&'a str, &'a NodeInfo>) { - for (id, info) in &self.nodes { - result.insert(id.as_str(), info); - } - for sg in &self.subgraphs { - sg.collect_nodes(result); - } - } - - /// Collect all edges from this graph and all nested subgraphs. - /// - /// This function recurses over subgraphs to aggregate the results. If you're doing repeated - /// lookups, consider caching the results. - pub fn all_edges(&self) -> Vec<&Edge> { - let mut result = Vec::new(); - // Recurse over the subgraphs in DFS order to collect edges from each - self.collect_edges(&mut result); - result - } - - fn collect_edges<'a>(&'a self, result: &mut Vec<&'a Edge>) { - result.extend(&self.edges); - for sg in &self.subgraphs { - sg.collect_edges(result); - } - } - - /// Build an adjacency list from all edges across all subgraphs. - /// - /// This function recurses over subgraphs to aggregate the results. If you're doing repeated - /// lookups, consider caching the results. - pub fn adjacency_list(&self) -> IndexMap<&str, Vec<&str>> { - let mut adj = IndexMap::new(); - for edge in self.all_edges() { - adj.entry(edge.from.as_str()) - .or_insert_with(Vec::new) - .push(edge.to.as_str()); - } - adj - } -} - -/// Normalize a node type string to a canonical form. -/// -/// Converts format-specific type names to standardized equivalents: -/// - `"custom-build"` -> `"build-script"` -/// - `"rlib"`, `"cdylib"`, `"dylib"`, `"staticlib"` -> `"lib"` -/// - Already canonical types (`"proc-macro"`, `"bin"`, `"test"`, etc.) pass through -/// -/// # Examples -/// -/// ``` -/// use csvizmo_depgraph::normalize_node_type; -/// assert_eq!(normalize_node_type("custom-build"), "build-script"); -/// assert_eq!(normalize_node_type("proc-macro"), "proc-macro"); -/// assert_eq!(normalize_node_type("rlib"), "lib"); -/// assert_eq!(normalize_node_type("cdylib"), "lib"); -/// ``` -pub fn normalize_node_type(raw: &str) -> String { - match raw { - "custom-build" => "build-script".to_string(), - "rlib" | "cdylib" | "dylib" | "staticlib" => "lib".to_string(), - _ => raw.to_string(), - } -} - -#[derive(Clone, Debug, Default)] -pub struct NodeInfo { - pub label: Option, - /// Node type/kind (e.g. "lib", "bin", "proc-macro", "build-script"). - /// Semantics are format-specific on input; normalized to canonical names where possible. - /// Formats that don't support types leave this as None. - pub node_type: Option, - /// Arbitrary extra attributes. Parsers populate these from format-specific features; - /// emitters carry them through where the output format allows. - pub attrs: IndexMap, -} -#[derive(Clone, Debug, Default)] -pub struct Edge { - pub from: String, - pub to: String, - pub label: Option, - /// Arbitrary extra attributes (e.g. DOT `style`, `color`). - pub attrs: IndexMap, -} +pub use graph::{DepGraph, Edge, FlatGraphView, NodeInfo}; diff --git a/crates/csvizmo-depgraph/src/parse/cargo_metadata.rs b/crates/csvizmo-depgraph/src/parse/cargo_metadata.rs index 21f2478..084548e 100644 --- a/crates/csvizmo-depgraph/src/parse/cargo_metadata.rs +++ b/crates/csvizmo-depgraph/src/parse/cargo_metadata.rs @@ -70,7 +70,7 @@ fn extract_package_types(pkg: &Package) -> Option { .flat_map(|t| &t.kind) .map(|k| { let kind_str = k.to_string(); - crate::normalize_node_type(&kind_str) + super::normalize_node_type(&kind_str) }) .collect(); @@ -140,7 +140,7 @@ pub fn parse(input: &str) -> eyre::Result { graph.nodes.insert( node_id, NodeInfo { - label: Some(label), + label, node_type, attrs, }, diff --git a/crates/csvizmo-depgraph/src/parse/cargo_tree.rs b/crates/csvizmo-depgraph/src/parse/cargo_tree.rs index ef33298..410a08a 100644 --- a/crates/csvizmo-depgraph/src/parse/cargo_tree.rs +++ b/crates/csvizmo-depgraph/src/parse/cargo_tree.rs @@ -81,7 +81,7 @@ fn parse_node_text( match annotation { "proc-macro" => { - node_type = Some(crate::normalize_node_type(annotation)); + node_type = Some(super::normalize_node_type(annotation)); } "build" => dep_kind = Some("build"), "dev" => dep_kind = Some("dev"), @@ -177,7 +177,7 @@ pub fn parse(input: &str) -> eyre::Result { graph.nodes.insert( id.clone(), NodeInfo { - label: Some(label), + label, node_type, attrs, }, @@ -224,7 +224,7 @@ mod tests { let graph = parse("myapp v1.0.0\n").unwrap(); assert_eq!(graph.nodes.len(), 1); let node = &graph.nodes["myapp v1.0.0"]; - assert_eq!(node.label.as_deref(), Some("myapp")); + assert_eq!(node.label.as_str(), "myapp"); assert_eq!( node.attrs.get("version").map(|s| s.as_str()), Some("v1.0.0") @@ -325,7 +325,7 @@ myapp v1.0.0 let node = &graph.nodes["derive-thing v0.5.0"]; assert_eq!(node.node_type.as_deref(), Some("proc-macro")); assert!(!node.attrs.contains_key("kind")); - assert_eq!(node.label.as_deref(), Some("derive-thing")); + assert_eq!(node.label.as_str(), "derive-thing"); assert_eq!( node.attrs.get("version").map(|s| s.as_str()), Some("v0.5.0") @@ -664,7 +664,7 @@ myapp v1.0.0 // Root node: ID has version, label is just the name let root = &graph.nodes["csvizmo-depgraph v0.5.0"]; - assert_eq!(root.label.as_deref(), Some("csvizmo-depgraph")); + assert_eq!(root.label.as_str(), "csvizmo-depgraph"); assert_eq!( root.attrs.get("version").map(|s| s.as_str()), Some("v0.5.0") @@ -675,7 +675,7 @@ myapp v1.0.0 ); // Check a few specific nodes - assert_eq!(graph.nodes["clap v4.5.57"].label.as_deref(), Some("clap")); + assert_eq!(graph.nodes["clap v4.5.57"].label.as_str(), "clap"); assert_eq!( graph.nodes["clap_derive v4.5.55"].node_type.as_deref(), Some("proc-macro") diff --git a/crates/csvizmo-depgraph/src/parse/depfile.rs b/crates/csvizmo-depgraph/src/parse/depfile.rs index c1d1e22..8ebe388 100644 --- a/crates/csvizmo-depgraph/src/parse/depfile.rs +++ b/crates/csvizmo-depgraph/src/parse/depfile.rs @@ -1,4 +1,6 @@ -use crate::{DepGraph, Edge}; +use indexmap::IndexMap; + +use crate::{DepGraph, Edge, NodeInfo}; /// Parse a makefile-style `.d` depfile into a `DepGraph`. /// @@ -42,7 +44,14 @@ pub fn parse(input: &str) -> eyre::Result { } fn ensure_node(graph: &mut DepGraph, id: &str) { - graph.nodes.entry(id.to_string()).or_default(); + graph + .nodes + .entry(id.to_string()) + .or_insert_with(|| NodeInfo { + label: id.to_string(), + node_type: None, + attrs: IndexMap::new(), + }); } /// Join backslash-continued lines into single logical lines. @@ -161,7 +170,7 @@ mod tests { #[test] fn no_labels_or_attrs() { let graph = parse("a.o: a.c\n").unwrap(); - assert_eq!(graph.nodes["a.o"].label, None); + assert_eq!(graph.nodes["a.o"].label, "a.o"); assert!(graph.nodes["a.o"].attrs.is_empty()); assert_eq!(graph.edges[0].label, None); assert!(graph.edges[0].attrs.is_empty()); diff --git a/crates/csvizmo-depgraph/src/parse/dot.rs b/crates/csvizmo-depgraph/src/parse/dot.rs index 897ae4e..b684e65 100644 --- a/crates/csvizmo-depgraph/src/parse/dot.rs +++ b/crates/csvizmo-depgraph/src/parse/dot.rs @@ -103,13 +103,13 @@ fn remove_implicit_duplicates(dep: &mut DepGraph) { if dep.subgraphs.is_empty() { return; } - let subgraph_nodes = dep + let subgraph_nodes: indexmap::IndexMap<&str, &NodeInfo> = dep .subgraphs .iter() - .flat_map(|sg| sg.all_nodes()) - .collect::>(); + .flat_map(|sg| sg.all_nodes().iter().map(|(k, v)| (k.as_str(), v))) + .collect(); dep.nodes.retain(|id, info| { - let is_implicit = info.label.is_none() && info.attrs.is_empty(); + let is_implicit = info.label == *id && info.attrs.is_empty(); !(is_implicit && subgraph_nodes.contains_key(id.as_str())) }); } @@ -177,7 +177,7 @@ fn shape_to_node_type(shape: &str) -> Option<&'static str> { /// Add a node from a NodeStmt into the DepGraph, returning the unquoted node ID. fn add_node(node_stmt: &ast::NodeStmt<(ast::ID, ast::ID)>, dep: &mut DepGraph) -> String { let id = unquote(&node_stmt.node.id); - let mut info = NodeInfo::default(); + let mut info = NodeInfo::new(id.clone()); let mut explicit_type = None; let mut shape_value = None; let mut style_value = None; @@ -189,10 +189,10 @@ fn add_node(node_stmt: &ast::NodeStmt<(ast::ID, ast::ID)>, dep: &mut DepGraph) - let value = unquote(&id_to_string(v)); match key.as_str() { "label" => { - info.label = Some(value); + info.label = value; } "type" => { - explicit_type = Some(crate::normalize_node_type(&value)); + explicit_type = Some(super::normalize_node_type(&value)); } "shape" => { shape_value = Some(value.clone()); @@ -268,8 +268,12 @@ fn add_edges(edge_stmt: &AstEdgeStmt, dep: &mut DepGraph) { for from_id in &from_ids { for to_id in &to_ids { // Ensure implicit nodes exist. - dep.nodes.entry(from_id.clone()).or_default(); - dep.nodes.entry(to_id.clone()).or_default(); + dep.nodes + .entry(from_id.clone()) + .or_insert_with(|| NodeInfo::new(from_id.clone())); + dep.nodes + .entry(to_id.clone()) + .or_insert_with(|| NodeInfo::new(to_id.clone())); dep.edges.push(Edge { from: from_id.clone(), to: to_id.clone(), @@ -466,8 +470,8 @@ mod tests { #[test] fn node_labels() { let graph = parse(r#"digraph { a [label="Alpha"]; b [label="Bravo"]; a -> b; }"#).unwrap(); - assert_eq!(graph.nodes["a"].label.as_deref(), Some("Alpha")); - assert_eq!(graph.nodes["b"].label.as_deref(), Some("Bravo")); + assert_eq!(graph.nodes["a"].label.as_str(), "Alpha"); + assert_eq!(graph.nodes["b"].label.as_str(), "Bravo"); } #[test] @@ -514,7 +518,7 @@ mod tests { #[test] fn extra_attrs_preserved() { let graph = parse(r#"digraph { a [label="A", color="red", style="bold"]; }"#).unwrap(); - assert_eq!(graph.nodes["a"].label.as_deref(), Some("A")); + assert_eq!(graph.nodes["a"].label.as_str(), "A"); assert_eq!( graph.nodes["a"].attrs.get("color").map(|s| s.as_str()), Some("red") @@ -553,7 +557,7 @@ mod tests { let graph = parse(r#"digraph { "my node" [label="My Node"]; "my node" -> "other"; }"#).unwrap(); assert!(graph.nodes.contains_key("my node")); - assert_eq!(graph.nodes["my node"].label.as_deref(), Some("My Node")); + assert_eq!(graph.nodes["my node"].label.as_str(), "My Node"); } #[test] @@ -586,7 +590,7 @@ mod tests { // attribute values but preserves \" escape sequences. Our unquote // must decode them so they don't get double-escaped by quote(). let graph = parse(r#"digraph { a [label="say \"hi\""]; }"#).unwrap(); - assert_eq!(graph.nodes["a"].label.as_deref(), Some(r#"say "hi""#)); + assert_eq!(graph.nodes["a"].label.as_str(), r#"say "hi""#); } #[test] @@ -600,7 +604,7 @@ mod tests { assert_eq!(output, "digraph {\n a [label=\"say \\\"hi\\\"\"];\n}\n"); // And parse the output again to verify it's valid. let graph2 = parse(&output).unwrap(); - assert_eq!(graph2.nodes["a"].label.as_deref(), Some(r#"say "hi""#)); + assert_eq!(graph2.nodes["a"].label.as_str(), r#"say "hi""#); } #[test] @@ -611,10 +615,7 @@ mod tests { assert!(graph.nodes.contains_key("myapp")); assert!(graph.nodes.contains_key("libfoo")); assert!(graph.nodes.contains_key("libbar")); - assert_eq!( - graph.nodes["myapp"].label.as_deref(), - Some("My Application") - ); + assert_eq!(graph.nodes["myapp"].label.as_str(), "My Application"); // shape=box stored in attrs assert_eq!( graph.nodes["myapp"].attrs.get("shape").map(|s| s.as_str()), @@ -720,7 +721,7 @@ mod tests { assert!(!graph.nodes.contains_key("b")); // `b` lives in the subgraph with its label. assert_eq!(graph.subgraphs[0].nodes.len(), 1); - assert_eq!(graph.subgraphs[0].nodes["b"].label.as_deref(), Some("B")); + assert_eq!(graph.subgraphs[0].nodes["b"].label.as_str(), "B"); // Flattened view still has both nodes. let all = graph.all_nodes(); assert_eq!(all.len(), 2); @@ -743,7 +744,7 @@ mod tests { assert_eq!(graph.nodes.len(), 1); assert!(graph.nodes.contains_key("b")); assert!(!graph.nodes.contains_key("a")); - assert_eq!(graph.subgraphs[0].nodes["a"].label.as_deref(), Some("A")); + assert_eq!(graph.subgraphs[0].nodes["a"].label.as_str(), "A"); } #[test] @@ -770,8 +771,8 @@ mod tests { // Inner: b and c with labels. let inner = &graph.subgraphs[0].subgraphs[0]; assert_eq!(inner.nodes.len(), 2); - assert_eq!(inner.nodes["b"].label.as_deref(), Some("B")); - assert_eq!(inner.nodes["c"].label.as_deref(), Some("C")); + assert_eq!(inner.nodes["b"].label.as_str(), "B"); + assert_eq!(inner.nodes["c"].label.as_str(), "C"); // Flattened view has all three. let all = graph.all_nodes(); assert_eq!(all.len(), 3); @@ -797,7 +798,7 @@ mod tests { Some("red") ); assert_eq!(graph.subgraphs[0].nodes.len(), 1); - assert_eq!(graph.subgraphs[0].nodes["a"].label.as_deref(), Some("A")); + assert_eq!(graph.subgraphs[0].nodes["a"].label.as_str(), "A"); } #[test] @@ -829,10 +830,7 @@ mod tests { assert!(graph.nodes.contains_key("node8")); assert!(graph.nodes.contains_key("node10")); // node8's label is "Threads::Threads". - assert_eq!( - graph.nodes["node8"].label.as_deref(), - Some("Threads::Threads") - ); + assert_eq!(graph.nodes["node8"].label.as_str(), "Threads::Threads"); assert_eq!(graph.edges.len(), 13); // No legend attributes leaked into parent. @@ -893,9 +891,9 @@ mod tests { ) .unwrap(); let adj = graph.adjacency_list(); - assert_eq!(adj.get("a").map(|v| v.as_slice()), Some(["b"].as_slice())); - assert_eq!(adj.get("b").map(|v| v.as_slice()), Some(["c"].as_slice())); - assert_eq!(adj.get("c").map(|v| v.as_slice()), Some(["d"].as_slice())); + assert_eq!(adj["a"], ["b"]); + assert_eq!(adj["b"], ["c"]); + assert_eq!(adj["c"], ["d"]); } #[test] @@ -1130,12 +1128,9 @@ mod tests { assert_eq!(graph.id.as_deref(), Some("ninja")); // "all" target node - assert_eq!(graph.nodes["0x7fe58d50f070"].label.as_deref(), Some("all")); + assert_eq!(graph.nodes["0x7fe58d50f070"].label.as_str(), "all"); // "phony" build-rule nodes have shape=ellipse - assert_eq!( - graph.nodes["0x7fe58d50eeb0"].label.as_deref(), - Some("phony") - ); + assert_eq!(graph.nodes["0x7fe58d50eeb0"].label.as_str(), "phony"); assert_eq!( graph.nodes["0x7fe58d50eeb0"] .attrs @@ -1144,10 +1139,7 @@ mod tests { Some("ellipse") ); // Source file node - assert_eq!( - graph.nodes["0x7fe58d508c50"].label.as_deref(), - Some("src/ninja.cc") - ); + assert_eq!(graph.nodes["0x7fe58d508c50"].label.as_str(), "src/ninja.cc"); } #[test] @@ -1161,17 +1153,14 @@ mod tests { assert_eq!(graph.id.as_deref(), Some("ninja")); // "all" target node - assert_eq!(graph.nodes["0x55b5eb08a840"].label.as_deref(), Some("all")); + assert_eq!(graph.nodes["0x55b5eb08a840"].label.as_str(), "all"); // Shared library output assert_eq!( - graph.nodes["0x55b5eb07a950"].label.as_deref(), - Some("lib/libgeos.so") + graph.nodes["0x55b5eb07a950"].label.as_str(), + "lib/libgeos.so" ); // Build-rule nodes have shape=ellipse - assert_eq!( - graph.nodes["0x55b5eb1b6210"].label.as_deref(), - Some("phony") - ); + assert_eq!(graph.nodes["0x55b5eb1b6210"].label.as_str(), "phony"); assert_eq!( graph.nodes["0x55b5eb1b6210"] .attrs @@ -1192,7 +1181,7 @@ mod tests { assert_eq!(graph.id.as_deref(), Some("depends")); // Labels contain \n formatting directives (preserved, not decoded) - let label = graph.nodes["acl-native.do_fetch"].label.as_deref().unwrap(); + let label = graph.nodes["acl-native.do_fetch"].label.as_str(); assert!( label.contains("\\n"), "bitbake labels should preserve \\n formatting directives" diff --git a/crates/csvizmo-depgraph/src/parse/mermaid.rs b/crates/csvizmo-depgraph/src/parse/mermaid.rs index 77fb504..9a7700e 100644 --- a/crates/csvizmo-depgraph/src/parse/mermaid.rs +++ b/crates/csvizmo-depgraph/src/parse/mermaid.rs @@ -121,9 +121,9 @@ pub fn parse(input: &str) -> eyre::Result { fn convert_node(node: &mermaid_rs_renderer::ir::Node) -> NodeInfo { let label = if node.label != node.id { - Some(node.label.clone()) + node.label.clone() } else { - None + node.id.clone() }; let mut attrs = IndexMap::new(); if let Some(shape) = map_shape(node.shape) { @@ -156,9 +156,9 @@ mod tests { assert_eq!(graph.attrs.get("direction").unwrap(), "LR"); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["A"].label.as_deref(), Some("myapp")); - assert_eq!(graph.nodes["B"].label.as_deref(), Some("libfoo")); - assert_eq!(graph.nodes["C"].label.as_deref(), Some("libbar")); + assert_eq!(graph.nodes["A"].label.as_str(), "myapp"); + assert_eq!(graph.nodes["B"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["C"].label.as_str(), "libbar"); assert_eq!(graph.edges.len(), 3); assert_eq!(graph.edges[0].from, "A"); @@ -186,7 +186,7 @@ mod tests { assert!(backend.nodes.contains_key("api")); assert!(backend.nodes.contains_key("db")); assert!(backend.nodes.contains_key("cache")); - assert_eq!(backend.nodes["api"].label.as_deref(), Some("API Server")); + assert_eq!(backend.nodes["api"].label.as_str(), "API Server"); let frontend = &graph.subgraphs[1]; assert_eq!(frontend.id.as_deref(), Some("frontend")); diff --git a/crates/csvizmo-depgraph/src/parse/mod.rs b/crates/csvizmo-depgraph/src/parse/mod.rs index f9bdd9d..2bbabd9 100644 --- a/crates/csvizmo-depgraph/src/parse/mod.rs +++ b/crates/csvizmo-depgraph/src/parse/mod.rs @@ -5,13 +5,105 @@ mod depfile; pub(crate) mod dot; mod mermaid; mod pathlist; +mod style; mod tgf; mod tree; -use crate::{DepGraph, InputFormat}; +use std::fmt; +use std::path::Path; + +use clap::ValueEnum; + +use crate::DepGraph; + +/// Variant order defines content-detection priority (most specific first). +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum InputFormat { + CargoMetadata, + Mermaid, + Dot, + Tgf, + Depfile, + CargoTree, + Tree, + Pathlist, +} + +impl fmt::Display for InputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +impl TryFrom<&Path> for InputFormat { + type Error = eyre::Report; + + fn try_from(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; + match ext { + "dot" | "gv" => Ok(Self::Dot), + "mmd" | "mermaid" => Ok(Self::Mermaid), + "tgf" => Ok(Self::Tgf), + "d" => Ok(Self::Depfile), + "json" => Ok(Self::CargoMetadata), + _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), + } + } +} + +/// Normalize a node type string to a canonical form. +/// +/// Converts format-specific type names to standardized equivalents: +/// - `"custom-build"` -> `"build-script"` +/// - `"rlib"`, `"cdylib"`, `"dylib"`, `"staticlib"` -> `"lib"` +/// - Already canonical types (`"proc-macro"`, `"bin"`, `"test"`, etc.) pass through +fn normalize_node_type(raw: &str) -> String { + match raw { + "custom-build" => "build-script".to_string(), + "rlib" | "cdylib" | "dylib" | "staticlib" => "lib".to_string(), + _ => raw.to_string(), + } +} + +/// Resolve input format using explicit flag, file extension, or content detection. +/// +/// Resolution order: +/// 1. Explicit flag if provided +/// 2. File extension if path is available +/// 3. Content detection from input string +/// +/// Returns an error if format cannot be determined. +pub fn resolve_input_format( + flag: Option, + path: Option<&Path>, + input: &str, +) -> eyre::Result { + if let Some(f) = flag { + return Ok(f); + } + let ext_err = match path.map(InputFormat::try_from) { + Some(Ok(f)) => { + tracing::info!("Detected input format: {f:?} from file extension"); + return Ok(f); + } + Some(Err(e)) => Some(e), + None => None, + }; + if let Some(f) = crate::detect::detect(input) { + tracing::info!("Detected input format: {f:?} from content"); + return Ok(f); + } + match ext_err { + Some(e) => Err(e.wrap_err("cannot detect input format; use --input-format")), + None => eyre::bail!("cannot detect input format; use --input-format"), + } +} pub fn parse(format: InputFormat, input: &str) -> eyre::Result { - match format { + let mut graph = match format { #[cfg(feature = "dot")] InputFormat::Dot => dot::parse(input), #[cfg(not(feature = "dot"))] @@ -23,5 +115,9 @@ pub fn parse(format: InputFormat, input: &str) -> eyre::Result { InputFormat::CargoTree => cargo_tree::parse(input), InputFormat::CargoMetadata => cargo_metadata::parse(input), InputFormat::Mermaid => mermaid::parse(input), - } + }?; + + style::apply_default_styles(&mut graph); + + Ok(graph) } diff --git a/crates/csvizmo-depgraph/src/parse/pathlist.rs b/crates/csvizmo-depgraph/src/parse/pathlist.rs index 0ea3783..643fcdc 100644 --- a/crates/csvizmo-depgraph/src/parse/pathlist.rs +++ b/crates/csvizmo-depgraph/src/parse/pathlist.rs @@ -33,13 +33,9 @@ pub fn parse(input: &str) -> eyre::Result { // Each unique path is inserted once; edge added with the new node. if !graph.nodes.contains_key(¤t) { - graph.nodes.insert( - current.clone(), - NodeInfo { - label: Some(component.to_string()), - ..Default::default() - }, - ); + graph + .nodes + .insert(current.clone(), NodeInfo::new(component.to_string())); if let Some(parent) = parent { graph.edges.push(Edge { from: parent, @@ -69,8 +65,8 @@ mod tests { fn single_path() { let graph = parse("src/main.rs\n").unwrap(); assert_eq!(graph.nodes.len(), 2); - assert_eq!(graph.nodes["src"].label.as_deref(), Some("src")); - assert_eq!(graph.nodes["src/main.rs"].label.as_deref(), Some("main.rs")); + assert_eq!(graph.nodes["src"].label.as_str(), "src"); + assert_eq!(graph.nodes["src/main.rs"].label.as_str(), "main.rs"); assert_eq!(graph.edges.len(), 1); assert_eq!(graph.edges[0].from, "src"); assert_eq!(graph.edges[0].to, "src/main.rs"); @@ -94,9 +90,9 @@ mod tests { fn nested_paths() { let graph = parse("a/b/c\n").unwrap(); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["a"].label.as_deref(), Some("a")); - assert_eq!(graph.nodes["a/b"].label.as_deref(), Some("b")); - assert_eq!(graph.nodes["a/b/c"].label.as_deref(), Some("c")); + assert_eq!(graph.nodes["a"].label.as_str(), "a"); + assert_eq!(graph.nodes["a/b"].label.as_str(), "b"); + assert_eq!(graph.nodes["a/b/c"].label.as_str(), "c"); assert_eq!(graph.edges.len(), 2); assert_eq!(graph.edges[0].from, "a"); assert_eq!(graph.edges[0].to, "a/b"); @@ -137,7 +133,7 @@ mod tests { fn single_component() { let graph = parse("README.md\n").unwrap(); assert_eq!(graph.nodes.len(), 1); - assert_eq!(graph.nodes["README.md"].label.as_deref(), Some("README.md")); + assert_eq!(graph.nodes["README.md"].label.as_str(), "README.md"); assert!(graph.edges.is_empty()); } @@ -187,10 +183,8 @@ mod tests { assert!(graph.nodes.contains_key("crates/csvizmo-can")); assert!(graph.nodes.contains_key("crates/csvizmo-can/Cargo.toml")); assert_eq!( - graph.nodes["crates/csvizmo-can/Cargo.toml"] - .label - .as_deref(), - Some("Cargo.toml") + graph.nodes["crates/csvizmo-can/Cargo.toml"].label.as_str(), + "Cargo.toml" ); // Multiple crates share the "crates" prefix -- only one "crates" node let crates_children: Vec<&Edge> = @@ -213,8 +207,8 @@ mod tests { assert_eq!( graph.nodes["crates/csvizmo-can/src/bin/can2csv.rs"] .label - .as_deref(), - Some("can2csv.rs") + .as_str(), + "can2csv.rs" ); } } diff --git a/crates/csvizmo-depgraph/src/style.rs b/crates/csvizmo-depgraph/src/parse/style.rs similarity index 92% rename from crates/csvizmo-depgraph/src/style.rs rename to crates/csvizmo-depgraph/src/parse/style.rs index 82c9601..b76537e 100644 --- a/crates/csvizmo-depgraph/src/style.rs +++ b/crates/csvizmo-depgraph/src/parse/style.rs @@ -61,8 +61,9 @@ mod tests { nodes: IndexMap::from([( "pm".into(), NodeInfo { + label: "pm".into(), node_type: Some("proc-macro".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -77,8 +78,9 @@ mod tests { nodes: IndexMap::from([( "b".into(), NodeInfo { + label: String::new(), node_type: Some("bin".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -93,8 +95,9 @@ mod tests { nodes: IndexMap::from([( "bs".into(), NodeInfo { + label: String::new(), node_type: Some("build-script".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -109,8 +112,9 @@ mod tests { nodes: IndexMap::from([( "opt".into(), NodeInfo { + label: String::new(), node_type: Some("optional".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -125,8 +129,9 @@ mod tests { nodes: IndexMap::from([( "l".into(), NodeInfo { + label: String::new(), node_type: Some("lib".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -141,8 +146,9 @@ mod tests { nodes: IndexMap::from([( "t".into(), NodeInfo { + label: String::new(), node_type: Some("test".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -157,9 +163,9 @@ mod tests { nodes: IndexMap::from([( "pm".into(), NodeInfo { + label: String::new(), node_type: Some("proc-macro".into()), attrs: IndexMap::from([("shape".into(), "box".into())]), - ..Default::default() }, )]), ..Default::default() @@ -174,9 +180,9 @@ mod tests { nodes: IndexMap::from([( "opt".into(), NodeInfo { + label: String::new(), node_type: Some("optional".into()), attrs: IndexMap::from([("style".into(), "bold".into())]), - ..Default::default() }, )]), ..Default::default() @@ -191,8 +197,9 @@ mod tests { nodes: IndexMap::from([( "x".into(), NodeInfo { + label: String::new(), node_type: Some("unknown-thing".into()), - ..Default::default() + attrs: Default::default(), }, )]), ..Default::default() @@ -204,7 +211,7 @@ mod tests { #[test] fn no_type_no_attrs() { let mut graph = DepGraph { - nodes: IndexMap::from([("x".into(), NodeInfo::default())]), + nodes: IndexMap::from([("x".into(), NodeInfo::new("x"))]), ..Default::default() }; apply_default_styles(&mut graph); @@ -302,8 +309,9 @@ mod tests { nodes: IndexMap::from([( "inner".into(), NodeInfo { + label: String::new(), node_type: Some("bin".into()), - ..Default::default() + attrs: Default::default(), }, )]), edges: vec![Edge { diff --git a/crates/csvizmo-depgraph/src/parse/tgf.rs b/crates/csvizmo-depgraph/src/parse/tgf.rs index 9ea98b6..0197442 100644 --- a/crates/csvizmo-depgraph/src/parse/tgf.rs +++ b/crates/csvizmo-depgraph/src/parse/tgf.rs @@ -47,10 +47,7 @@ pub fn parse(input: &str) -> eyre::Result { .ok_or_else(|| eyre::eyre!("invalid node line: {line:?}"))?; graph.nodes.insert( id.to_string(), - NodeInfo { - label: join_rest(&mut parts), - ..Default::default() - }, + NodeInfo::new(join_rest(&mut parts).unwrap_or_else(|| id.to_string())), ); } } @@ -80,16 +77,16 @@ mod tests { fn nodes_with_labels() { let graph = parse("1 libfoo\n2 libbar\n#\n").unwrap(); assert_eq!(graph.nodes.len(), 2); - assert_eq!(graph.nodes["1"].label.as_deref(), Some("libfoo")); - assert_eq!(graph.nodes["2"].label.as_deref(), Some("libbar")); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); } #[test] fn nodes_without_labels() { let graph = parse("a\nb\n#\n").unwrap(); assert_eq!(graph.nodes.len(), 2); - assert_eq!(graph.nodes["a"].label, None); - assert_eq!(graph.nodes["b"].label, None); + assert_eq!(graph.nodes["a"].label, "a"); + assert_eq!(graph.nodes["b"].label, "b"); } #[test] @@ -113,15 +110,15 @@ mod tests { #[test] fn tab_separated() { let graph = parse("1\tlibfoo\n2\tlibbar\n#\n1\t2\tdepends on\n").unwrap(); - assert_eq!(graph.nodes["1"].label.as_deref(), Some("libfoo")); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); assert_eq!(graph.edges[0].label.as_deref(), Some("depends on")); } #[test] fn multiple_whitespace() { let graph = parse("1 libfoo\n2\t\tlibbar\n#\n1 2\n").unwrap(); - assert_eq!(graph.nodes["1"].label.as_deref(), Some("libfoo")); - assert_eq!(graph.nodes["2"].label.as_deref(), Some("libbar")); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); assert_eq!(graph.edges[0].from, "1"); assert_eq!(graph.edges[0].to, "2"); } @@ -151,9 +148,9 @@ mod tests { let input = include_str!("../../../../data/depconv/small.tgf"); let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["1"].label.as_deref(), Some("libfoo")); - assert_eq!(graph.nodes["2"].label.as_deref(), Some("libbar")); - assert_eq!(graph.nodes["3"].label.as_deref(), Some("myapp")); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); + assert_eq!(graph.nodes["3"].label.as_str(), "myapp"); assert_eq!(graph.edges.len(), 3); assert_eq!(graph.edges[0].from, "3"); assert_eq!(graph.edges[0].to, "1"); @@ -165,9 +162,9 @@ mod tests { let input = include_str!("../../../../data/depconv/nodes-only.tgf"); let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["a"].label.as_deref(), Some("alpha")); - assert_eq!(graph.nodes["b"].label.as_deref(), Some("bravo")); - assert_eq!(graph.nodes["c"].label.as_deref(), Some("charlie")); + assert_eq!(graph.nodes["a"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["b"].label.as_str(), "bravo"); + assert_eq!(graph.nodes["c"].label.as_str(), "charlie"); assert!(graph.edges.is_empty()); } @@ -176,7 +173,7 @@ mod tests { let input = include_str!("../../../../data/depconv/edge-labels.tgf"); let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 4); - assert_eq!(graph.nodes["fmt"].label.as_deref(), Some("csvizmo-fmt")); + assert_eq!(graph.nodes["fmt"].label.as_str(), "csvizmo-fmt"); assert_eq!(graph.edges.len(), 4); assert_eq!(graph.edges[0].from, "depgraph"); assert_eq!(graph.edges[0].to, "utils"); diff --git a/crates/csvizmo-depgraph/src/parse/tree.rs b/crates/csvizmo-depgraph/src/parse/tree.rs index 7b77c71..e78a1f2 100644 --- a/crates/csvizmo-depgraph/src/parse/tree.rs +++ b/crates/csvizmo-depgraph/src/parse/tree.rs @@ -91,13 +91,9 @@ pub fn parse(input: &str) -> eyre::Result { stack.truncate(depth); stack.push(id.clone()); - graph.nodes.insert( - id.clone(), - NodeInfo { - label: Some(name.to_string()), - ..Default::default() - }, - ); + graph + .nodes + .insert(id.clone(), NodeInfo::new(name.to_string())); if depth > 0 { graph.edges.push(Edge { @@ -126,7 +122,7 @@ mod tests { fn root_only() { let graph = parse("mydir\n").unwrap(); assert_eq!(graph.nodes.len(), 1); - assert_eq!(graph.nodes["mydir"].label.as_deref(), Some("mydir")); + assert_eq!(graph.nodes["mydir"].label.as_str(), "mydir"); assert!(graph.edges.is_empty()); } @@ -139,9 +135,9 @@ root "; let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["root"].label.as_deref(), Some("root")); - assert_eq!(graph.nodes["root/alpha"].label.as_deref(), Some("alpha")); - assert_eq!(graph.nodes["root/bravo"].label.as_deref(), Some("bravo")); + assert_eq!(graph.nodes["root"].label.as_str(), "root"); + assert_eq!(graph.nodes["root/alpha"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["root/bravo"].label.as_str(), "bravo"); assert_eq!(graph.edges.len(), 2); assert_eq!(graph.edges[0].from, "root"); assert_eq!(graph.edges[0].to, "root/alpha"); @@ -174,8 +170,8 @@ root "; let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 3); - assert_eq!(graph.nodes["root/alpha"].label.as_deref(), Some("alpha")); - assert_eq!(graph.nodes["root/bravo"].label.as_deref(), Some("bravo")); + assert_eq!(graph.nodes["root/alpha"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["root/bravo"].label.as_str(), "bravo"); assert_eq!(graph.edges.len(), 2); } @@ -201,7 +197,7 @@ root "; let graph = parse(input).unwrap(); assert_eq!(graph.nodes.len(), 2); - assert_eq!(graph.nodes["root/only"].label.as_deref(), Some("only")); + assert_eq!(graph.nodes["root/only"].label.as_str(), "only"); } #[test] @@ -285,10 +281,8 @@ root .contains_key("crates/csvizmo-utils/src/stdio.rs") ); assert_eq!( - graph.nodes["crates/csvizmo-can/Cargo.toml"] - .label - .as_deref(), - Some("Cargo.toml") + graph.nodes["crates/csvizmo-can/Cargo.toml"].label.as_str(), + "Cargo.toml" ); // "crates" is the root -- no incoming edges assert!(!graph.edges.iter().any(|e| e.to == "crates")); @@ -337,10 +331,7 @@ root // "shared" under b should resolve to the same name (without marker) assert!(graph.nodes.contains_key("root/a/shared")); assert!(graph.nodes.contains_key("root/b/shared")); - assert_eq!( - graph.nodes["root/b/shared"].label.as_deref(), - Some("shared") - ); + assert_eq!(graph.nodes["root/b/shared"].label.as_str(), "shared"); } #[test] @@ -352,7 +343,7 @@ root "; let graph = parse(input).unwrap(); assert!(graph.nodes.contains_key("root/a/root")); - assert_eq!(graph.nodes["root/a/root"].label.as_deref(), Some("root")); + assert_eq!(graph.nodes["root/a/root"].label.as_str(), "root"); } #[test] diff --git a/crates/csvizmo-depgraph/tests/depconv.rs b/crates/csvizmo-depgraph/tests/depconv.rs index 2cf1c3c..891a281 100644 --- a/crates/csvizmo-depgraph/tests/depconv.rs +++ b/crates/csvizmo-depgraph/tests/depconv.rs @@ -16,7 +16,7 @@ fn normalize_whitespace(s: &str) -> String { fn tgf_to_dot() { let input = include_str!("../../../data/depconv/small.tgf"); let output = tool!("depconv") - .args(["--from", "tgf", "--to", "dot"]) + .args(["--input-format", "tgf", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -42,7 +42,7 @@ digraph { fn dot_to_tgf() { let input = include_str!("../../../data/depconv/small.dot"); let output = tool!("depconv") - .args(["--from", "dot", "--to", "tgf"]) + .args(["--input-format", "dot", "--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -50,7 +50,7 @@ fn dot_to_tgf() { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout, - "libbar\tlibbar\nlibfoo\tlibfoo\nmyapp\tMy Application\n#\nmyapp\tlibfoo\nmyapp\tlibbar\nlibfoo\tlibbar\n" + "libbar\nlibfoo\nmyapp\tMy Application\n#\nmyapp\tlibfoo\nmyapp\tlibbar\nlibfoo\tlibbar\n" ); } @@ -60,7 +60,7 @@ fn tgf_to_dot_to_tgf_roundtrip() { let input = "a\tAlpha\nb\tBravo\n#\na\tb\tuses\n"; // TGF → DOT let dot_output = tool!("depconv") - .args(["--from", "tgf", "--to", "dot"]) + .args(["--input-format", "tgf", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -68,7 +68,7 @@ fn tgf_to_dot_to_tgf_roundtrip() { let dot = String::from_utf8_lossy(&dot_output.stdout); // DOT → TGF let tgf_output = tool!("depconv") - .args(["--from", "dot", "--to", "tgf"]) + .args(["--input-format", "dot", "--output-format", "tgf"]) .write_stdin(dot.as_ref()) .captured_output() .unwrap(); @@ -81,7 +81,7 @@ fn tgf_to_dot_to_tgf_roundtrip() { fn depfile_to_dot() { let input = "main.o: main.c config.h\n"; let output = tool!("depconv") - .args(["--from", "depfile", "--to", "dot"]) + .args(["--input-format", "depfile", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -105,7 +105,7 @@ digraph { fn depfile_to_tgf() { let input = include_str!("../../../data/depconv/small.d"); let output = tool!("depconv") - .args(["--from", "depfile", "--to", "tgf"]) + .args(["--input-format", "depfile", "--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -126,7 +126,7 @@ fn depfile_to_tgf() { fn depfile_auto_detect_content() { let input = "main.o: main.c config.h\n"; let output = tool!("depconv") - .args(["--to", "tgf"]) + .args(["--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -143,7 +143,7 @@ fn depfile_auto_detect_extension() { // Path relative to test CWD let fixture = "../../data/depconv/small.d"; let output = tool!("depconv") - .args(["--to", "tgf", "-i", fixture]) + .args(["--output-format", "tgf", "-i", fixture]) .captured_output() .unwrap(); assert!(output.status.success()); @@ -163,7 +163,7 @@ fn depfile_auto_detect_extension() { fn depfile_multi_target_fixture() { let input = include_str!("../../../data/depconv/multi-target.d"); let output = tool!("depconv") - .args(["--from", "depfile", "--to", "dot"]) + .args(["--input-format", "depfile", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -198,7 +198,7 @@ digraph { fn depfile_roundtrip() { let input = "main.o: main.c config.h\nutils.o: utils.c utils.h\n"; let output = tool!("depconv") - .args(["--from", "depfile", "--to", "depfile"]) + .args(["--input-format", "depfile", "--output-format", "depfile"]) .write_stdin(input) .captured_output() .unwrap(); @@ -211,7 +211,7 @@ fn depfile_roundtrip() { fn tgf_to_depfile() { let input = "3\tmyapp\n1\tlibfoo\n2\tlibbar\n#\n3\t1\n3\t2\n1\t2\n"; let output = tool!("depconv") - .args(["--from", "tgf", "--to", "depfile"]) + .args(["--input-format", "tgf", "--output-format", "depfile"]) .write_stdin(input) .captured_output() .unwrap(); @@ -224,7 +224,7 @@ fn tgf_to_depfile() { fn pathlist_to_dot() { let input = "src/a.rs\nsrc/b.rs\nREADME.md\n"; let output = tool!("depconv") - .args(["--from", "pathlist", "--to", "dot"]) + .args(["--input-format", "pathlist", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -234,10 +234,10 @@ fn pathlist_to_dot() { stdout, "\ digraph { - src [label=\"src\"]; + src; \"src/a.rs\" [label=\"a.rs\"]; \"src/b.rs\" [label=\"b.rs\"]; - \"README.md\" [label=\"README.md\"]; + \"README.md\"; src -> \"src/a.rs\"; src -> \"src/b.rs\"; } @@ -249,7 +249,7 @@ digraph { fn pathlist_auto_detect_content() { let input = "src/main.rs\nsrc/lib.rs\n"; let output = tool!("depconv") - .args(["--to", "tgf"]) + .args(["--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -257,7 +257,7 @@ fn pathlist_auto_detect_content() { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout, - "src\tsrc\nsrc/main.rs\tmain.rs\nsrc/lib.rs\tlib.rs\n#\nsrc\tsrc/main.rs\nsrc\tsrc/lib.rs\n" + "src\nsrc/main.rs\tmain.rs\nsrc/lib.rs\tlib.rs\n#\nsrc\tsrc/main.rs\nsrc\tsrc/lib.rs\n" ); } @@ -265,7 +265,7 @@ fn pathlist_auto_detect_content() { fn tree_to_dot() { let input = "root\n├── a\n│ └── b\n└── c\n"; let output = tool!("depconv") - .args(["--from", "tree", "--to", "dot"]) + .args(["--input-format", "tree", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -275,7 +275,7 @@ fn tree_to_dot() { stdout, "\ digraph { - root [label=\"root\"]; + root; \"root/a\" [label=\"a\"]; \"root/a/b\" [label=\"b\"]; \"root/c\" [label=\"c\"]; @@ -291,16 +291,13 @@ digraph { fn tree_auto_detect_content() { let input = "root\n├── child\n"; let output = tool!("depconv") - .args(["--to", "tgf"]) + .args(["--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!( - stdout, - "root\troot\nroot/child\tchild\n#\nroot\troot/child\n" - ); + assert_eq!(stdout, "root\nroot/child\tchild\n#\nroot\troot/child\n"); } #[test] @@ -308,7 +305,7 @@ fn tgf_to_tree() { // a -> b -> c, a -> d (diamond-like with branching at root) let input = "a\tAlpha\nb\tBravo\nc\tCharlie\nd\tDelta\n#\na\tb\na\td\nb\tc\n"; let output = tool!("depconv") - .args(["--from", "tgf", "--to", "tree"]) + .args(["--input-format", "tgf", "--output-format", "tree"]) .write_stdin(input) .captured_output() .unwrap(); @@ -329,7 +326,7 @@ Alpha fn pathlist_roundtrip() { let input = "src/a.rs\nsrc/b.rs\nREADME.md\n"; let output = tool!("depconv") - .args(["--from", "pathlist", "--to", "pathlist"]) + .args(["--input-format", "pathlist", "--output-format", "pathlist"]) .write_stdin(input) .captured_output() .unwrap(); @@ -343,7 +340,7 @@ fn tgf_to_pathlist() { // a -> b -> c, a -> c (diamond: c is shared) let input = "a\tAlpha\nb\tBravo\nc\tCharlie\n#\na\tb\na\tc\nb\tc\n"; let output = tool!("depconv") - .args(["--from", "tgf", "--to", "pathlist"]) + .args(["--input-format", "tgf", "--output-format", "pathlist"]) .write_stdin(input) .captured_output() .unwrap(); @@ -357,7 +354,7 @@ fn tgf_to_pathlist() { fn pathlist_to_pathlist_fixture() { let input = include_str!("../../../data/depconv/gitfiles.txt"); let output = tool!("depconv") - .args(["--from", "pathlist", "--to", "pathlist"]) + .args(["--input-format", "pathlist", "--output-format", "pathlist"]) .write_stdin(input) .captured_output() .unwrap(); @@ -371,7 +368,7 @@ fn pathlist_to_pathlist_fixture() { fn dot_to_dot() { let input = include_str!("../../../data/depconv/small.dot"); let output = tool!("depconv") - .args(["--from", "dot", "--to", "dot"]) + .args(["--input-format", "dot", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -382,8 +379,8 @@ fn dot_to_dot() { "\ digraph deps { rankdir=\"LR\"; - libbar [label=\"libbar\"]; - libfoo [label=\"libfoo\"]; + libbar; + libfoo; myapp [label=\"My Application\", shape=\"box\"]; myapp -> libfoo; myapp -> libbar; @@ -406,7 +403,7 @@ digraph { } "; let output = tool!("depconv") - .args(["--from", "dot", "--to", "depfile"]) + .args(["--input-format", "dot", "--output-format", "depfile"]) .write_stdin(input) .captured_output() .unwrap(); @@ -422,7 +419,7 @@ fn cmake_dot_preserves_subgraph() { // Parse -> emit -> re-parse. let output1 = tool!("depconv") - .args(["--from", "dot", "--to", "dot"]) + .args(["--input-format", "dot", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -430,7 +427,7 @@ fn cmake_dot_preserves_subgraph() { let dot1 = String::from_utf8_lossy(&output1.stdout); let output2 = tool!("depconv") - .args(["--from", "dot", "--to", "dot"]) + .args(["--input-format", "dot", "--output-format", "dot"]) .write_stdin(dot1.as_ref()) .captured_output() .unwrap(); @@ -477,7 +474,7 @@ myapp v1.0.0 └── shared v1.0.0 (*) "; let output = tool!("depconv") - .args(["--from", "cargo-tree", "--to", "dot"]) + .args(["--input-format", "cargo-tree", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -504,7 +501,7 @@ digraph { fn cargo_tree_auto_detect() { let input = include_str!("../../../data/depconv/cargo-tree.txt"); let output = tool!("depconv") - .args(["--to", "tgf"]) + .args(["--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -550,7 +547,7 @@ fn cargo_metadata_to_dot() { // Test with the real cargo-metadata.json fixture let input = include_str!("../../../data/depconv/cargo-metadata.json"); let output = tool!("depconv") - .args(["--from", "cargo-metadata", "--to", "dot"]) + .args(["--input-format", "cargo-metadata", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -643,7 +640,7 @@ digraph { // Parse DOT -> emit DOT let output1 = tool!("depconv") - .args(["--from", "dot", "--to", "dot"]) + .args(["--input-format", "dot", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -653,7 +650,7 @@ digraph { // Parse again to verify round-trip stability let output2 = tool!("depconv") - .args(["--from", "dot", "--to", "dot"]) + .args(["--input-format", "dot", "--output-format", "dot"]) .write_stdin(stdout1.as_ref()) .captured_output() .unwrap(); @@ -666,7 +663,7 @@ digraph { fn tgf_to_mermaid() { let input = "a\talpha\nb\tbravo\nc\n#\na\tb\tdepends\nb\tc\na\tc\n"; let output = tool!("depconv") - .args(["--from", "tgf", "--to", "mermaid"]) + .args(["--input-format", "tgf", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -699,7 +696,7 @@ fn mermaid_node_types() { } "#; let output = tool!("depconv") - .args(["--from", "dot", "--to", "mermaid"]) + .args(["--input-format", "dot", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -740,7 +737,7 @@ fn dot_to_mermaid_with_subgraphs() { } "#; let output = tool!("depconv") - .args(["--from", "dot", "--to", "mermaid"]) + .args(["--input-format", "dot", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -764,7 +761,7 @@ fn dot_to_mermaid_with_subgraphs() { fn mermaid_special_chars() { let input = "a\tLabel [with] \"quotes\"\nb\tOther{label}\n#\na\tb\tuses|pipes\n"; let output = tool!("depconv") - .args(["--from", "tgf", "--to", "mermaid"]) + .args(["--input-format", "tgf", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -785,7 +782,7 @@ flowchart LR fn depfile_to_mermaid() { let input = "main.o: main.c config.h\n"; let output = tool!("depconv") - .args(["--from", "depfile", "--to", "mermaid"]) + .args(["--input-format", "depfile", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -808,7 +805,7 @@ flowchart LR fn mermaid_to_tgf() { let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; let output = tool!("depconv") - .args(["--from", "mermaid", "--to", "tgf"]) + .args(["--input-format", "mermaid", "--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -824,7 +821,7 @@ fn mermaid_to_tgf() { fn mermaid_to_dot() { let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; let output = tool!("depconv") - .args(["--from", "mermaid", "--to", "dot"]) + .args(["--input-format", "mermaid", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -850,7 +847,7 @@ digraph { fn mermaid_auto_detect() { let input = "flowchart LR\n A[myapp] --> B[libfoo]\n"; let output = tool!("depconv") - .args(["--to", "tgf"]) + .args(["--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); @@ -864,7 +861,7 @@ fn mermaid_roundtrip() { let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; // parse mermaid -> emit mermaid let output1 = tool!("depconv") - .args(["--from", "mermaid", "--to", "mermaid"]) + .args(["--input-format", "mermaid", "--output-format", "mermaid"]) .write_stdin(input) .captured_output() .unwrap(); @@ -873,7 +870,7 @@ fn mermaid_roundtrip() { // parse again -> emit again, should be stable let output2 = tool!("depconv") - .args(["--from", "mermaid", "--to", "mermaid"]) + .args(["--input-format", "mermaid", "--output-format", "mermaid"]) .write_stdin(mmd1.as_ref()) .captured_output() .unwrap(); @@ -887,7 +884,7 @@ fn mermaid_roundtrip() { fn mermaid_subgraph_to_dot() { let input = include_str!("../../../data/depconv/subgraph.mmd"); let output = tool!("depconv") - .args(["--from", "mermaid", "--to", "dot"]) + .args(["--input-format", "mermaid", "--output-format", "dot"]) .write_stdin(input) .captured_output() .unwrap(); @@ -922,7 +919,7 @@ digraph { fn mermaid_edge_labels_to_tgf() { let input = include_str!("../../../data/depconv/flowchart.mmd"); let output = tool!("depconv") - .args(["--from", "mermaid", "--to", "tgf"]) + .args(["--input-format", "mermaid", "--output-format", "tgf"]) .write_stdin(input) .captured_output() .unwrap(); diff --git a/crates/csvizmo-depgraph/tests/depfilter.rs b/crates/csvizmo-depgraph/tests/depfilter.rs new file mode 100644 index 0000000..e09c5f3 --- /dev/null +++ b/crates/csvizmo-depgraph/tests/depfilter.rs @@ -0,0 +1,454 @@ +use csvizmo_test::{CommandExt, tool}; +use pretty_assertions::assert_eq; + +// Test graph: myapp -> libfoo -> libbar +// myapp -> libbar +const SIMPLE_GRAPH: &str = "1\tlibfoo\n2\tlibbar\n3\tmyapp\n#\n3\t1\n3\t2\n1\t2\n"; + +#[test] +fn select_single_pattern() { + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "lib*", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tlibfoo\n2\tlibbar\n#\n1\t2\n"); +} + +#[test] +fn select_by_id() { + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "1", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tlibfoo\n#\n"); +} + +#[test] +fn select_with_deps() { + // Select myapp and include all its dependencies + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "myapp", + "--deps", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include myapp, libfoo, and libbar with all edges + assert_eq!(stdout, SIMPLE_GRAPH); +} + +#[test] +fn select_with_ancestors() { + // Select libbar and include all nodes that depend on it + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "libbar", + "--ancestors", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include libbar, libfoo (depends on libbar), and myapp (depends on libbar) + assert_eq!(stdout, SIMPLE_GRAPH); +} + +#[test] +fn select_with_depth() { + // Create a deeper graph for depth testing + // a -> b -> c -> d + let deep_graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "a", + "--deps", + "--depth", + "1", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(deep_graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include only a and b (1 level deep) + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn select_depth_from_roots() { + // a -> b -> c -> d: no pattern, depth 1 seeds from roots (a), keeps a and b + let deep_graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--depth", + "1", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(deep_graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn select_multiple_patterns_and() { + // Graph with nodes that match multiple criteria + let graph = "libfoo-alpha\nlibfoo-beta\nlibbar-alpha\n#\n"; + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "libfoo*", + "--pattern", + "*alpha", + "--and", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include only libfoo-alpha (matches both patterns) + assert_eq!(stdout, "libfoo-alpha\n#\n"); +} + +#[test] +fn select_with_deps_and_ancestors() { + // a -> b -> c -> d: select b with both directions gets everything + let graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "b", + "--deps", + "--ancestors", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"); +} + +// -- filter integration tests: one per CLI flag -- + +#[test] +fn filter_single_pattern() { + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "libfoo", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +} + +#[test] +fn filter_with_and() { + let graph = "libfoo-alpha\nlibfoo-beta\nlibbar-alpha\n#\n"; + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "libfoo*", + "--pattern", + "*alpha", + "--and", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "libfoo-beta\nlibbar-alpha\n#\n"); +} + +#[test] +fn filter_with_deps() { + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "myapp", + "--deps", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn filter_with_ancestors() { + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "libbar", + "--ancestors", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn filter_with_deps_and_ancestors() { + // a -> b -> c, d -> c: filter b with both directions removes a, b, c but keeps d + let graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nd\tc\n"; + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "b", + "--deps", + "--ancestors", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "d\n#\n"); +} + +#[test] +fn filter_by_id() { + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "1", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +} + +#[test] +fn filter_with_preserve_connectivity() { + let chain_graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "b", + "--preserve-connectivity", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(chain_graph) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nc\n#\na\tc\n"); +} + +#[test] +fn select_dot_output() { + let output = tool!("depfilter") + .args([ + "select", + "--pattern", + "lib*", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"1\" [label=\"libfoo\"]; + \"2\" [label=\"libbar\"]; + \"1\" -> \"2\"; +} +" + ); +} + +#[cfg(feature = "dot")] +#[test] +fn filter_preserve_connectivity_subgraph() { + // subgraph { a -> b -> c }: remove b, bypass a -> c stays in subgraph + let dot_input = "\ +digraph { + subgraph cluster_0 { + a; + b; + c; + a -> b; + b -> c; + } +} +"; + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "b", + "--preserve-connectivity", + "--input-format", + "dot", + "--output-format", + "dot", + ]) + .write_stdin(dot_input) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + c; + a -> c; + } +} +" + ); +} + +#[cfg(feature = "dot")] +#[test] +fn filter_dot_input() { + let dot_input = "\ +digraph { + \"1\" [label=\"libfoo\"]; + \"2\" [label=\"libbar\"]; + \"3\" [label=\"myapp\"]; + \"3\" -> \"1\"; + \"3\" -> \"2\"; + \"1\" -> \"2\"; +} +"; + let output = tool!("depfilter") + .args([ + "filter", + "--pattern", + "libfoo", + "--input-format", + "dot", + "--output-format", + "tgf", + ]) + .write_stdin(dot_input) + .captured_output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +}