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}