tldr_cli/commands/inheritance.rs
1//! Inheritance command - Extract and visualize class hierarchies
2//!
3//! Analyzes class inheritance relationships across a codebase:
4//! - Python: class inheritance, ABC, Protocol, metaclasses
5//! - TypeScript: class extends, implements, interfaces
6//! - Go: struct embedding (modeled as inheritance)
7//! - Rust: trait implementations
8//!
9//! # Output Formats
10//!
11//! - JSON: Full structured output (default)
12//! - DOT: Graphviz format for visualization
13//! - text: Human-readable tree format
14//!
15//! # Mitigations Addressed
16//!
17//! - A2: Diamond detection uses BFS + set intersection (O(|ancestors|))
18//! - A12: Python metaclass extraction
19//! - A14: Go struct embedding as Embeds relationships
20//! - A16: Rust trait impl blocks
21//! - A17: --depth requires --class validation
22//! - A19: DOT output properly escapes special characters
23
24use std::path::PathBuf;
25
26use anyhow::Result;
27use clap::Args;
28
29use tldr_core::inheritance::{extract_inheritance, format_dot, format_text, InheritanceOptions};
30use tldr_core::Language;
31
32use crate::output::{OutputFormat, OutputWriter};
33
34/// Extract class inheritance hierarchies
35#[derive(Debug, Args)]
36pub struct InheritanceArgs {
37 /// Path to file or directory to analyze (default: current directory)
38 #[arg(default_value = ".")]
39 pub path: PathBuf,
40
41 /// Programming language (auto-detect if not specified)
42 #[arg(long, short = 'l')]
43 pub lang: Option<Language>,
44
45 /// Focus on specific class (shows ancestors + descendants)
46 #[arg(long, short = 'c')]
47 pub class: Option<String>,
48
49 /// Limit traversal depth (requires --class)
50 #[arg(long, short = 'd')]
51 pub depth: Option<usize>,
52
53 /// Skip ABC/Protocol/mixin/diamond detection
54 #[arg(long)]
55 pub no_patterns: bool,
56
57 /// Skip external base resolution
58 #[arg(long)]
59 pub no_external: bool,
60
61 /// Output format override (backwards compatibility, prefer global --format/-f)
62 #[arg(long = "output", short = 'o', hide = true, value_parser = parse_inheritance_format)]
63 pub output: Option<InheritanceFormat>,
64}
65
66/// Inheritance-specific output formats (includes DOT)
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum InheritanceFormat {
69 Json,
70 Text,
71 Dot,
72}
73
74fn parse_inheritance_format(s: &str) -> Result<InheritanceFormat, String> {
75 match s.to_lowercase().as_str() {
76 "json" => Ok(InheritanceFormat::Json),
77 "text" => Ok(InheritanceFormat::Text),
78 "dot" | "graphviz" => Ok(InheritanceFormat::Dot),
79 _ => Err(format!(
80 "Invalid format '{}'. Expected: json, text, or dot",
81 s
82 )),
83 }
84}
85
86impl InheritanceArgs {
87 /// Run the inheritance command
88 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
89 let writer = OutputWriter::new(format, quiet);
90
91 writer.progress(&format!(
92 "Analyzing inheritance in {}...",
93 self.path.display()
94 ));
95
96 // Build options
97 let options = InheritanceOptions {
98 class_filter: self.class.clone(),
99 depth: self.depth,
100 no_patterns: self.no_patterns,
101 no_external: self.no_external,
102 ..Default::default()
103 };
104
105 // Run analysis
106 let report = extract_inheritance(&self.path, self.lang, &options)?;
107
108 // Determine output format
109 // surface-gaps-v1 (BUG-19): honor the global `--format dot` flag in
110 // addition to the legacy `-o dot` switch. Inheritance graphs are the
111 // canonical class-hierarchy DOT use case.
112 let inh_format = self.output.unwrap_or_else(|| {
113 if writer.is_text() {
114 InheritanceFormat::Text
115 } else if writer.is_dot() {
116 InheritanceFormat::Dot
117 } else {
118 InheritanceFormat::Json
119 }
120 });
121
122 // Output based on format
123 match inh_format {
124 InheritanceFormat::Json => {
125 writer.write(&report)?;
126 }
127 InheritanceFormat::Text => {
128 let text = format_text(&report);
129 writer.write_text(&text)?;
130 }
131 InheritanceFormat::Dot => {
132 let dot = format_dot(&report);
133 writer.write_text(&dot)?;
134 }
135 }
136
137 // determinism-and-stderr-hygiene-v1 (BUG-18): the summary
138 // ("Found N classes in Mms") and diamond-inheritance warning
139 // were unconditionally written to stderr, which contaminated
140 // the JSON-mode contract — `tldr inheritance <path> 2>/dev/null
141 // > out.json` produced a clean JSON file but a non-empty stderr
142 // stream, breaking shell pipelines that gate on stderr-empty.
143 // Gate on text format: text consumers still see the summary
144 // (now via a writer-aware path), JSON consumers get a clean
145 // stream. The information is already in the JSON
146 // (`report.count`, `report.scan_time_ms`, `report.diamonds`),
147 // so no data loss for downstream tooling.
148 if !quiet && writer.is_text() {
149 eprintln!(
150 "Found {} classes in {}ms",
151 report.count, report.scan_time_ms
152 );
153
154 if !report.diamonds.is_empty() {
155 eprintln!(
156 "Warning: {} diamond inheritance pattern(s) detected",
157 report.diamonds.len()
158 );
159 }
160 }
161
162 Ok(())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn test_parse_inheritance_format() {
172 assert_eq!(
173 parse_inheritance_format("json").unwrap(),
174 InheritanceFormat::Json
175 );
176 assert_eq!(
177 parse_inheritance_format("text").unwrap(),
178 InheritanceFormat::Text
179 );
180 assert_eq!(
181 parse_inheritance_format("dot").unwrap(),
182 InheritanceFormat::Dot
183 );
184 assert_eq!(
185 parse_inheritance_format("graphviz").unwrap(),
186 InheritanceFormat::Dot
187 );
188 assert_eq!(
189 parse_inheritance_format("DOT").unwrap(),
190 InheritanceFormat::Dot
191 );
192 assert!(parse_inheritance_format("invalid").is_err());
193 }
194}