ascii_dag/
lib.rs

1//! # ascii-dag
2//!
3//! Modular ASCII DAG (Directed Acyclic Graph) renderer for error chains,
4//! build systems, and dependency visualization.
5//!
6//! ## Features
7//!
8//! - **Tiny**: ~77KB WASM (minimal), ~41KB without generic features
9//! - **Fast**: Cached adjacency lists, O(1) lookups, zero-copy rendering
10//! - **no_std**: Works in embedded/WASM environments
11//! - **Modular**: Each component can be used independently
12//! - **Safe**: Cycle detection built-in
13//!
14//! ## Performance
15//!
16//! - **Cached Adjacency Lists**: O(1) child/parent lookups (not O(E))
17//! - **Zero Allocations**: Direct buffer writes with `write_node()`
18//! - **HashMap Indexing**: O(1) ID→index instead of O(N) scans
19//!
20//! ## Feature Flags
21//!
22//! - `std` (default): Standard library support
23//! - `generic` (default): Generic algorithms (cycle detection, topological sort, impact analysis, metrics)
24//! - `warnings`: Debug warnings for auto-created nodes
25//!
26//! To minimize bundle size, disable `generic`:
27//! ```toml
28//! ascii-dag = { version = "0.1", default-features = false, features = ["std"] }
29//! ```
30//!
31//! ## Quick Start
32//!
33//! ```rust
34//! use ascii_dag::graph::{DAG, RenderMode};
35//!
36//! // Batch construction (fast!)
37//! let dag = DAG::from_edges(
38//!     &[(1, "Error1"), (2, "Error2"), (3, "Error3")],
39//!     &[(1, 2), (2, 3)]
40//! );
41//!
42//! println!("{}", dag.render());
43//! ```
44//!
45//! ## Modular Design
46//!
47//! The library is organized into separate, independently-usable modules:
48//!
49//! ### [`graph`] - Core DAG Structure
50//! ```rust
51//! use ascii_dag::graph::DAG;
52//!
53//! let mut dag = DAG::new();
54//! dag.add_node(1, "A");
55//! dag.add_node(2, "B");
56//! dag.add_edge(1, 2);
57//! ```
58//!
59//! ### [`cycles`] - Cycle Detection
60//! ```rust
61//! use ascii_dag::graph::DAG;
62//!
63//! let mut dag = DAG::new();
64//! dag.add_edge(1, 2);
65//! dag.add_edge(2, 1);
66//! assert!(dag.has_cycle());
67//! ```
68//!
69//! ### [`cycles::generic`] - Generic Cycle Detection
70//! Works with any data structure via higher-order functions:
71//! ```rust
72//! # #[cfg(feature = "generic")]
73//! # {
74//! use ascii_dag::cycles::generic::detect_cycle_fn;
75//!
76//! let get_deps = |id: &usize| match id {
77//!     1 => vec![2],
78//!     2 => vec![3],
79//!     _ => vec![],
80//! };
81//!
82//! let cycle = detect_cycle_fn(&[1, 2, 3], get_deps);
83//! assert!(cycle.is_none());
84//! # }
85//! ```
86//!
87//! ### [`layout::generic`] - Generic Topological Sorting
88//! Sort any dependency graph into execution order:
89//! ```rust
90//! # #[cfg(feature = "generic")]
91//! # {
92//! use ascii_dag::layout::generic::topological_sort_fn;
93//!
94//! let get_deps = |task: &&str| match *task {
95//!     "deploy" => vec!["build"],
96//!     "build" => vec!["compile"],
97//!     "compile" => vec![],
98//!     _ => vec![],
99//! };
100//!
101//! let sorted = topological_sort_fn(&["deploy", "compile", "build"], get_deps).unwrap();
102//! // Result: ["compile", "build", "deploy"]
103//! assert_eq!(sorted[0], "compile");
104//! # }
105//! ```
106//!
107//! ### [`layout`] - Graph Layout Algorithms
108//! Sugiyama hierarchical layout for positioning nodes.
109//!
110//! ### [`render`] - ASCII Rendering
111//! Vertical, horizontal, and cycle visualization modes.
112
113#![cfg_attr(not(feature = "std"), no_std)]
114
115extern crate alloc;
116
117// Core modules (always available)
118pub mod cycles;
119pub mod graph;
120pub mod layout;
121pub mod render;
122
123// Backward compatibility re-exports
124pub use graph::{DAG, RenderMode};
125
126#[cfg(test)]
127mod tests {
128    use crate::graph::DAG;
129
130    #[test]
131    fn test_empty_dag() {
132        let dag = DAG::new();
133        assert_eq!(dag.render(), "Empty DAG");
134    }
135
136    #[test]
137    fn test_simple_chain() {
138        let dag = DAG::from_edges(&[(1, "A"), (2, "B"), (3, "C")], &[(1, 2), (2, 3)]);
139
140        let output = dag.render();
141        assert!(output.contains("A"));
142        assert!(output.contains("B"));
143        assert!(output.contains("C"));
144    }
145
146    #[test]
147    fn test_cycle_detection() {
148        let mut dag = DAG::new();
149        dag.add_node(1, "A");
150        dag.add_node(2, "B");
151        dag.add_edge(1, 2);
152        dag.add_edge(2, 1); // Cycle!
153
154        assert!(dag.has_cycle());
155    }
156
157    #[test]
158    fn test_no_cycle() {
159        let dag = DAG::from_edges(&[(1, "A"), (2, "B")], &[(1, 2)]);
160
161        assert!(!dag.has_cycle());
162    }
163
164    #[test]
165    fn test_diamond() {
166        let dag = DAG::from_edges(
167            &[(1, "A"), (2, "B"), (3, "C"), (4, "D")],
168            &[(1, 2), (1, 3), (2, 4), (3, 4)],
169        );
170
171        assert!(!dag.has_cycle());
172        let output = dag.render();
173        assert!(output.contains("A"));
174        assert!(output.contains("D"));
175    }
176
177    #[test]
178    fn test_auto_created_nodes() {
179        let mut dag = DAG::new();
180        dag.add_node(1, "A");
181        dag.add_edge(1, 2); // Auto-creates node 2
182        dag.add_node(3, "C");
183        dag.add_edge(2, 3);
184
185        let output = dag.render();
186
187        // Normal nodes have square brackets
188        assert!(output.contains("[A]"));
189        assert!(output.contains("[C]"));
190
191        // Auto-created node has angle brackets
192        assert!(output.contains("⟨2⟩"));
193
194        // Verify auto_created tracking
195        assert!(dag.is_auto_created(2));
196        assert!(!dag.is_auto_created(1));
197        assert!(!dag.is_auto_created(3));
198    }
199
200    #[test]
201    fn test_no_auto_creation_when_explicit() {
202        let mut dag = DAG::new();
203        dag.add_node(1, "A");
204        dag.add_node(2, "B"); // Explicit!
205        dag.add_edge(1, 2);
206
207        let output = dag.render();
208
209        // Both should be square brackets
210        assert!(output.contains("[A]"));
211        assert!(output.contains("[B]"));
212        assert!(!output.contains("⟨")); // No angle brackets
213
214        // Verify nothing was auto-created
215        assert!(!dag.is_auto_created(1));
216        assert!(!dag.is_auto_created(2));
217    }
218
219    #[test]
220    fn test_edge_to_missing_node_no_panic() {
221        let mut dag = DAG::new();
222        dag.add_node(1, "A");
223        dag.add_edge(1, 2); // Node 2 doesn't exist - should auto-create
224
225        // Should NOT panic
226        let output = dag.render();
227
228        // Should render successfully
229        assert!(output.contains("[A]"));
230        assert!(output.contains("⟨2⟩"));
231    }
232
233    #[test]
234    fn test_cross_level_edges() {
235        let mut dag = DAG::new();
236
237        dag.add_node(1, "Root");
238        dag.add_node(2, "Middle");
239        dag.add_node(3, "End");
240
241        dag.add_edge(1, 2);
242        dag.add_edge(1, 3);
243        dag.add_edge(2, 3);
244
245        let output = dag.render();
246
247        assert!(output.contains("[Root]"));
248        assert!(output.contains("[Middle]"));
249        assert!(output.contains("[End]"));
250    }
251
252    #[test]
253    fn test_crossing_reduction() {
254        // Diamond graph to test that crossing reduction runs without panicking
255        let mut dag = DAG::new();
256
257        dag.add_node(1, "Top");
258        dag.add_node(2, "Right");
259        dag.add_node(3, "Left");
260        dag.add_node(4, "Bottom");
261
262        dag.add_edge(1, 3);
263        dag.add_edge(1, 2);
264        dag.add_edge(3, 4);
265        dag.add_edge(2, 4);
266
267        let output = dag.render();
268
269        // All nodes should appear
270        assert!(output.contains("[Top]"));
271        assert!(output.contains("[Left]"));
272        assert!(output.contains("[Right]"));
273        assert!(output.contains("[Bottom]"));
274
275        // The crossing reduction pass should complete without panic
276        // and produce a valid rendering (nodes are reordered to minimize crossings)
277        let lines: Vec<&str> = output.lines().collect();
278        assert!(
279            lines.len() >= 5,
280            "Should have multiple lines for diamond pattern"
281        );
282    }
283
284    #[test]
285    fn test_cycle_with_auto_created_nodes() {
286        let mut dag = DAG::new();
287        dag.add_node(1, "A");
288        // Node 2 will be auto-created
289        dag.add_edge(1, 2);
290        dag.add_edge(2, 1); // Creates cycle
291
292        let output = dag.render();
293
294        // Should show cycle warning
295        assert!(output.contains("CYCLE DETECTED"));
296
297        // Auto-created node should use ⟨2⟩ format in cycle output
298        assert!(output.contains("⟨2⟩"));
299
300        // Normal node should use [A] format
301        assert!(output.contains("[A]"));
302    }
303
304    #[test]
305    fn test_auto_created_node_promotion() {
306        let mut dag = DAG::new();
307
308        dag.add_node(1, "A");
309        dag.add_edge(1, 2); // Auto-creates node 2 as placeholder
310
311        // Verify initially auto-created
312        assert!(dag.is_auto_created(2));
313        let output = dag.render();
314        assert!(output.contains("⟨2⟩"), "Before promotion, should show ⟨2⟩");
315        assert!(
316            !output.contains("[B]"),
317            "Before promotion, should not show [B]"
318        );
319
320        // Now promote the placeholder
321        dag.add_node(2, "B");
322
323        // Verify promotion worked
324        assert!(
325            !dag.is_auto_created(2),
326            "After promotion, should not be auto-created"
327        );
328        let output_after = dag.render();
329        assert!(
330            output_after.contains("[B]"),
331            "After promotion, should show [B]"
332        );
333        assert!(
334            !output_after.contains("⟨2⟩"),
335            "After promotion, should not show ⟨2⟩"
336        );
337
338        // Verify no duplicate nodes were created
339        let node_count = dag.nodes.iter().filter(|(id, _)| *id == 2).count();
340        assert_eq!(node_count, 1, "Should only have one node with id=2");
341    }
342
343    #[test]
344    fn test_skewed_children_rendering_order() {
345        // Test that nodes are rendered left-to-right by x-coordinate,
346        // even when median centering moves nodes around.
347        let mut dag = DAG::new();
348
349        // Create a level with multiple nodes
350        dag.add_node(1, "Top");
351        dag.add_node(2, "A");
352        dag.add_node(3, "B");
353        dag.add_node(4, "C");
354
355        // Top connects to all children
356        dag.add_edge(1, 2);
357        dag.add_edge(1, 3);
358        dag.add_edge(1, 4);
359
360        let output = dag.render();
361
362        // All children should be on the same line
363        let lines: Vec<&str> = output.lines().collect();
364        let child_line = lines
365            .iter()
366            .find(|line| line.contains("[A]") && line.contains("[B]") && line.contains("[C]"))
367            .expect("Should find line with all children");
368
369        // Find positions of A, B, C on that line
370        let a_pos = child_line.find("[A]").unwrap();
371        let b_pos = child_line.find("[B]").unwrap();
372        let c_pos = child_line.find("[C]").unwrap();
373
374        // They should be in left-to-right order
375        assert!(a_pos < b_pos, "A should be left of B");
376        assert!(b_pos < c_pos, "B should be left of C");
377    }
378}