Skip to main content

fastapi_output/components/
dependency_tree.rs

1//! Dependency injection tree display component.
2//!
3//! Renders a hierarchical view of dependencies with optional
4//! cycle highlighting and scope/caching annotations.
5
6use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8
9const ANSI_RESET: &str = "\x1b[0m";
10
11/// A single dependency node in the tree.
12#[derive(Debug, Clone)]
13pub struct DependencyNode {
14    /// Display name of the dependency.
15    pub name: String,
16    /// Child dependencies.
17    pub children: Vec<DependencyNode>,
18    /// Whether the dependency is cached for the request.
19    pub cached: bool,
20    /// Optional scope label (e.g., "request", "function").
21    pub scope: Option<String>,
22    /// Optional note or detail.
23    pub note: Option<String>,
24    /// Whether this node represents a cycle edge.
25    pub cycle: bool,
26}
27
28impl DependencyNode {
29    /// Create a new dependency node with no children.
30    #[must_use]
31    pub fn new(name: impl Into<String>) -> Self {
32        Self {
33            name: name.into(),
34            children: Vec::new(),
35            cached: false,
36            scope: None,
37            note: None,
38            cycle: false,
39        }
40    }
41
42    /// Add a child dependency.
43    #[must_use]
44    pub fn child(mut self, child: DependencyNode) -> Self {
45        self.children.push(child);
46        self
47    }
48
49    /// Replace children for the node.
50    #[must_use]
51    pub fn children(mut self, children: Vec<DependencyNode>) -> Self {
52        self.children = children;
53        self
54    }
55
56    /// Mark this dependency as cached.
57    #[must_use]
58    pub fn cached(mut self) -> Self {
59        self.cached = true;
60        self
61    }
62
63    /// Set the dependency scope label.
64    #[must_use]
65    pub fn scope(mut self, scope: impl Into<String>) -> Self {
66        self.scope = Some(scope.into());
67        self
68    }
69
70    /// Add a note to this dependency.
71    #[must_use]
72    pub fn note(mut self, note: impl Into<String>) -> Self {
73        self.note = Some(note.into());
74        self
75    }
76
77    /// Mark this node as a cycle edge.
78    #[must_use]
79    pub fn cycle(mut self) -> Self {
80        self.cycle = true;
81        self
82    }
83}
84
85/// Display configuration for dependency trees.
86#[derive(Debug, Clone)]
87pub struct DependencyTreeDisplay {
88    mode: OutputMode,
89    theme: FastApiTheme,
90    roots: Vec<DependencyNode>,
91    show_cached: bool,
92    show_scopes: bool,
93    show_notes: bool,
94    title: Option<String>,
95    cycle_paths: Vec<Vec<String>>,
96}
97
98impl DependencyTreeDisplay {
99    /// Create a new dependency tree display.
100    #[must_use]
101    pub fn new(mode: OutputMode, roots: Vec<DependencyNode>) -> Self {
102        Self {
103            mode,
104            theme: FastApiTheme::default(),
105            roots,
106            show_cached: true,
107            show_scopes: true,
108            show_notes: true,
109            title: Some("Dependency Tree".to_string()),
110            cycle_paths: Vec::new(),
111        }
112    }
113
114    /// Set the theme.
115    #[must_use]
116    pub fn theme(mut self, theme: FastApiTheme) -> Self {
117        self.theme = theme;
118        self
119    }
120
121    /// Hide cached markers.
122    #[must_use]
123    pub fn hide_cached(mut self) -> Self {
124        self.show_cached = false;
125        self
126    }
127
128    /// Hide scope labels.
129    #[must_use]
130    pub fn hide_scopes(mut self) -> Self {
131        self.show_scopes = false;
132        self
133    }
134
135    /// Hide notes.
136    #[must_use]
137    pub fn hide_notes(mut self) -> Self {
138        self.show_notes = false;
139        self
140    }
141
142    /// Set a custom title (None to disable).
143    #[must_use]
144    pub fn title(mut self, title: Option<String>) -> Self {
145        self.title = title;
146        self
147    }
148
149    /// Add a cycle path for summary output.
150    #[must_use]
151    pub fn with_cycle_path(mut self, path: Vec<String>) -> Self {
152        if !path.is_empty() {
153            self.cycle_paths.push(path);
154        }
155        self
156    }
157
158    /// Render the dependency tree to a string.
159    #[must_use]
160    pub fn render(&self) -> String {
161        if self.roots.is_empty() {
162            return "No dependencies registered.".to_string();
163        }
164
165        let glyphs = TreeGlyphs::for_mode(self.mode);
166        let mut lines = Vec::new();
167
168        if let Some(title) = &self.title {
169            lines.push(title.clone());
170            lines.push("-".repeat(title.len()));
171        }
172
173        for (idx, root) in self.roots.iter().enumerate() {
174            let is_last = idx + 1 == self.roots.len();
175            self.render_node(&mut lines, "", root, is_last, &glyphs);
176        }
177
178        if !self.cycle_paths.is_empty() {
179            lines.push(String::new());
180            lines.push(self.render_cycles_header());
181            for cycle in &self.cycle_paths {
182                lines.push(format!("  {}", cycle.join(" -> ")));
183            }
184        }
185
186        lines.join("\n")
187    }
188
189    fn render_cycles_header(&self) -> String {
190        if self.mode.uses_ansi() {
191            let error = self.theme.error.to_ansi_fg();
192            format!("{error}Cycles detected:{ANSI_RESET}")
193        } else {
194            "Cycles detected:".to_string()
195        }
196    }
197
198    fn render_node(
199        &self,
200        lines: &mut Vec<String>,
201        prefix: &str,
202        node: &DependencyNode,
203        is_last: bool,
204        glyphs: &TreeGlyphs,
205    ) {
206        let connector = if is_last { glyphs.last } else { glyphs.branch };
207        let label = self.render_label(node);
208        lines.push(format!("{prefix}{connector} {label}"));
209
210        let next_prefix = if is_last {
211            format!("{prefix}{}", glyphs.spacer)
212        } else {
213            format!("{prefix}{}", glyphs.vertical)
214        };
215
216        for (idx, child) in node.children.iter().enumerate() {
217            let child_is_last = idx + 1 == node.children.len();
218            self.render_node(lines, &next_prefix, child, child_is_last, glyphs);
219        }
220    }
221
222    fn render_label(&self, node: &DependencyNode) -> String {
223        let mut parts = Vec::new();
224        let name = if self.mode.uses_ansi() {
225            format!(
226                "{}{}{}",
227                self.theme.primary.to_ansi_fg(),
228                node.name,
229                ANSI_RESET
230            )
231        } else {
232            node.name.clone()
233        };
234        parts.push(name);
235
236        if self.show_cached && node.cached {
237            let cached = if self.mode.uses_ansi() {
238                format!("{}[cached]{}", self.theme.muted.to_ansi_fg(), ANSI_RESET)
239            } else {
240                "[cached]".to_string()
241            };
242            parts.push(cached);
243        }
244
245        if self.show_scopes {
246            if let Some(scope) = &node.scope {
247                let scope_text = if self.mode.uses_ansi() {
248                    format!(
249                        "{}(scope: {}){}",
250                        self.theme.secondary.to_ansi_fg(),
251                        scope,
252                        ANSI_RESET
253                    )
254                } else {
255                    format!("(scope: {scope})")
256                };
257                parts.push(scope_text);
258            }
259        }
260
261        if node.cycle {
262            let cycle = if self.mode.uses_ansi() {
263                format!("{}[cycle]{}", self.theme.error.to_ansi_fg(), ANSI_RESET)
264            } else {
265                "[cycle]".to_string()
266            };
267            parts.push(cycle);
268        }
269
270        if self.show_notes {
271            if let Some(note) = &node.note {
272                parts.push(format!("- {note}"));
273            }
274        }
275
276        parts.join(" ")
277    }
278}
279
280struct TreeGlyphs {
281    branch: &'static str,
282    last: &'static str,
283    vertical: &'static str,
284    spacer: &'static str,
285}
286
287impl TreeGlyphs {
288    fn for_mode(mode: OutputMode) -> Self {
289        match mode {
290            OutputMode::Plain => Self {
291                branch: "+-",
292                last: "\\-",
293                vertical: "| ",
294                spacer: "  ",
295            },
296            OutputMode::Minimal | OutputMode::Rich => Self {
297                branch: "├─",
298                last: "└─",
299                vertical: "│ ",
300                spacer: "  ",
301            },
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::testing::{assert_contains, assert_has_ansi, assert_no_ansi};
310
311    // =================================================================
312    // DependencyNode Builder Tests
313    // =================================================================
314
315    #[test]
316    fn test_dependency_node_new() {
317        let node = DependencyNode::new("TestService");
318        assert_eq!(node.name, "TestService");
319        assert!(node.children.is_empty());
320        assert!(!node.cached);
321        assert!(node.scope.is_none());
322        assert!(node.note.is_none());
323        assert!(!node.cycle);
324    }
325
326    #[test]
327    fn test_dependency_node_child() {
328        let node = DependencyNode::new("Parent")
329            .child(DependencyNode::new("Child1"))
330            .child(DependencyNode::new("Child2"));
331        assert_eq!(node.children.len(), 2);
332        assert_eq!(node.children[0].name, "Child1");
333        assert_eq!(node.children[1].name, "Child2");
334    }
335
336    #[test]
337    fn test_dependency_node_children() {
338        let children = vec![
339            DependencyNode::new("A"),
340            DependencyNode::new("B"),
341            DependencyNode::new("C"),
342        ];
343        let node = DependencyNode::new("Root").children(children);
344        assert_eq!(node.children.len(), 3);
345        assert_eq!(node.children[2].name, "C");
346    }
347
348    #[test]
349    fn test_dependency_node_cached() {
350        let node = DependencyNode::new("Cached").cached();
351        assert!(node.cached);
352    }
353
354    #[test]
355    fn test_dependency_node_scope() {
356        let node = DependencyNode::new("Scoped").scope("singleton");
357        assert_eq!(node.scope, Some("singleton".to_string()));
358    }
359
360    #[test]
361    fn test_dependency_node_note() {
362        let node = DependencyNode::new("Noted").note("Important service");
363        assert_eq!(node.note, Some("Important service".to_string()));
364    }
365
366    #[test]
367    fn test_dependency_node_cycle() {
368        let node = DependencyNode::new("Circular").cycle();
369        assert!(node.cycle);
370    }
371
372    #[test]
373    fn test_dependency_node_full_builder() {
374        let node = DependencyNode::new("FullService")
375            .cached()
376            .scope("request")
377            .note("Main service entry")
378            .cycle()
379            .child(DependencyNode::new("Dep1"));
380
381        assert_eq!(node.name, "FullService");
382        assert!(node.cached);
383        assert_eq!(node.scope, Some("request".to_string()));
384        assert_eq!(node.note, Some("Main service entry".to_string()));
385        assert!(node.cycle);
386        assert_eq!(node.children.len(), 1);
387    }
388
389    // =================================================================
390    // DependencyTreeDisplay Configuration Tests
391    // =================================================================
392
393    #[test]
394    fn test_empty_roots() {
395        let display = DependencyTreeDisplay::new(OutputMode::Plain, vec![]);
396        let output = display.render();
397        assert_eq!(output, "No dependencies registered.");
398    }
399
400    #[test]
401    fn test_custom_title() {
402        let roots = vec![DependencyNode::new("Service")];
403        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots)
404            .title(Some("Custom DI Tree".to_string()));
405        let output = display.render();
406        assert_contains(&output, "Custom DI Tree");
407        assert!(!output.contains("Dependency Tree"));
408    }
409
410    #[test]
411    fn test_no_title() {
412        let roots = vec![DependencyNode::new("Service")];
413        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).title(None);
414        let output = display.render();
415        assert!(!output.contains("Dependency Tree"));
416        assert_contains(&output, "Service");
417    }
418
419    #[test]
420    fn test_hide_cached() {
421        let roots = vec![DependencyNode::new("Cached").cached()];
422        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).hide_cached();
423        let output = display.render();
424        assert!(!output.contains("[cached]"));
425    }
426
427    #[test]
428    fn test_hide_scopes() {
429        let roots = vec![DependencyNode::new("Scoped").scope("singleton")];
430        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).hide_scopes();
431        let output = display.render();
432        assert!(!output.contains("scope:"));
433    }
434
435    #[test]
436    fn test_hide_notes() {
437        let roots = vec![DependencyNode::new("Noted").note("Important note")];
438        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).hide_notes();
439        let output = display.render();
440        assert!(!output.contains("Important note"));
441    }
442
443    // =================================================================
444    // Rendering Mode Tests
445    // =================================================================
446
447    #[test]
448    fn renders_plain_tree() {
449        let roots = vec![
450            DependencyNode::new("Database")
451                .cached()
452                .scope("request")
453                .child(DependencyNode::new("Config")),
454        ];
455        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots);
456        let output = display.render();
457
458        assert_contains(&output, "Dependency Tree");
459        assert_contains(&output, "Database");
460        assert_contains(&output, "[cached]");
461        assert_contains(&output, "scope: request");
462        assert_contains(&output, "Config");
463        assert_no_ansi(&output);
464        // Plain mode uses ASCII glyphs
465        assert!(output.contains("+-") || output.contains("\\-"));
466    }
467
468    #[test]
469    fn renders_rich_tree_with_ansi() {
470        let roots = vec![DependencyNode::new("Service").cached().scope("request")];
471        let display = DependencyTreeDisplay::new(OutputMode::Rich, roots);
472        let output = display.render();
473
474        assert_has_ansi(&output);
475        assert_contains(&output, "Service");
476        // Rich mode uses Unicode glyphs
477        assert!(output.contains("├─") || output.contains("└─"));
478    }
479
480    #[test]
481    fn renders_minimal_tree_with_unicode() {
482        let roots = vec![DependencyNode::new("Service")];
483        let display = DependencyTreeDisplay::new(OutputMode::Minimal, roots);
484        let output = display.render();
485
486        // Minimal uses same Unicode glyphs as Rich
487        assert!(output.contains("└─"));
488    }
489
490    // =================================================================
491    // Tree Structure Tests
492    // =================================================================
493
494    #[test]
495    fn test_multiple_roots() {
496        let roots = vec![
497            DependencyNode::new("Root1"),
498            DependencyNode::new("Root2"),
499            DependencyNode::new("Root3"),
500        ];
501        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots);
502        let output = display.render();
503
504        assert_contains(&output, "Root1");
505        assert_contains(&output, "Root2");
506        assert_contains(&output, "Root3");
507    }
508
509    #[test]
510    fn test_deep_nesting() {
511        let roots = vec![
512            DependencyNode::new("Level0").child(
513                DependencyNode::new("Level1")
514                    .child(DependencyNode::new("Level2").child(DependencyNode::new("Level3"))),
515            ),
516        ];
517        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots);
518        let output = display.render();
519
520        assert_contains(&output, "Level0");
521        assert_contains(&output, "Level1");
522        assert_contains(&output, "Level2");
523        assert_contains(&output, "Level3");
524    }
525
526    #[test]
527    fn test_wide_tree() {
528        let children = (0..5)
529            .map(|i| DependencyNode::new(format!("Child{i}")))
530            .collect();
531        let roots = vec![DependencyNode::new("Root").children(children)];
532        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots);
533        let output = display.render();
534
535        for i in 0..5 {
536            assert_contains(&output, &format!("Child{i}"));
537        }
538    }
539
540    // =================================================================
541    // Cycle Detection Tests
542    // =================================================================
543
544    #[test]
545    fn renders_cycle_marker() {
546        let roots = vec![
547            DependencyNode::new("Auth")
548                .child(DependencyNode::new("Db").cycle())
549                .child(DependencyNode::new("Cache")),
550        ];
551        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).with_cycle_path(vec![
552            "Auth".into(),
553            "Db".into(),
554            "Auth".into(),
555        ]);
556        let output = display.render();
557
558        assert_contains(&output, "[cycle]");
559        assert_contains(&output, "Cycles detected:");
560        assert_contains(&output, "Auth -> Db -> Auth");
561    }
562
563    #[test]
564    fn test_multiple_cycle_paths() {
565        let roots = vec![
566            DependencyNode::new("A")
567                .child(DependencyNode::new("B").cycle())
568                .child(DependencyNode::new("C").cycle()),
569        ];
570        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots)
571            .with_cycle_path(vec!["A".into(), "B".into(), "A".into()])
572            .with_cycle_path(vec!["A".into(), "C".into(), "A".into()]);
573        let output = display.render();
574
575        assert_contains(&output, "A -> B -> A");
576        assert_contains(&output, "A -> C -> A");
577    }
578
579    #[test]
580    fn test_empty_cycle_path_ignored() {
581        let roots = vec![DependencyNode::new("Root")];
582        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots).with_cycle_path(vec![]);
583        let output = display.render();
584
585        // Empty cycle path should not add cycles section
586        assert!(!output.contains("Cycles detected:"));
587    }
588
589    // =================================================================
590    // Label Formatting Tests
591    // =================================================================
592
593    #[test]
594    fn test_node_with_all_annotations() {
595        let roots = vec![
596            DependencyNode::new("FullAnnotated")
597                .cached()
598                .scope("singleton")
599                .note("Main dependency")
600                .cycle(),
601        ];
602        let display = DependencyTreeDisplay::new(OutputMode::Plain, roots);
603        let output = display.render();
604
605        assert_contains(&output, "FullAnnotated");
606        assert_contains(&output, "[cached]");
607        assert_contains(&output, "(scope: singleton)");
608        assert_contains(&output, "[cycle]");
609        assert_contains(&output, "- Main dependency");
610    }
611
612    #[test]
613    fn test_rich_mode_cycles_header_styled() {
614        let roots = vec![DependencyNode::new("A").cycle()];
615        let display = DependencyTreeDisplay::new(OutputMode::Rich, roots)
616            .with_cycle_path(vec!["A".into(), "B".into()]);
617        let output = display.render();
618
619        // Cycles header should have ANSI codes in Rich mode
620        assert_has_ansi(&output);
621        assert_contains(&output, "Cycles detected:");
622    }
623}