Skip to main content

st/formatters/
relations.rs

1//! Formatters for relationship visualization
2//! "Making code relationships beautiful" - Trisha from Accounting
3
4use crate::relations::{FileRelation, RelationAnalyzer, RelationType};
5use anyhow::Result;
6use std::collections::{HashMap, HashSet};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10/// Format relationships as Mermaid diagram
11pub struct MermaidRelationFormatter;
12
13impl MermaidRelationFormatter {
14    pub fn format<W: Write>(
15        &self,
16        writer: &mut W,
17        analyzer: &RelationAnalyzer,
18        root_path: &Path,
19    ) -> Result<()> {
20        writeln!(writer, "```mermaid")?;
21        writeln!(writer, "graph TD")?;
22        writeln!(writer, "    %% Smart Tree Relationship Map 🌟")?;
23        writeln!(writer, "    %% Generated by st --relations --mode mermaid")?;
24        writeln!(writer)?;
25
26        // Collect unique files
27        let mut files = HashSet::new();
28        let mut file_ids = HashMap::new();
29        let mut id_counter = 0;
30
31        for rel in analyzer.get_relations() {
32            files.insert(&rel.source);
33            files.insert(&rel.target);
34        }
35
36        // Generate node definitions with styling
37        writeln!(writer, "    %% File nodes")?;
38        for file in files {
39            let relative = file.strip_prefix(root_path).unwrap_or(file);
40            let display_name = relative.to_string_lossy();
41            let id = format!("F{}", id_counter);
42            file_ids.insert(file, id.clone());
43            id_counter += 1;
44
45            // Style based on file type
46            let style = if display_name.contains("test") {
47                "fill:#90EE90,stroke:#228B22,stroke-width:2px" // Light green for tests
48            } else if display_name.ends_with(".rs") {
49                "fill:#FFE4B5,stroke:#FF8C00,stroke-width:2px" // Moccasin for Rust
50            } else if display_name.ends_with(".py") {
51                "fill:#87CEEB,stroke:#4682B4,stroke-width:2px" // Sky blue for Python
52            } else {
53                "fill:#F0F0F0,stroke:#696969,stroke-width:1px"
54            };
55
56            writeln!(
57                writer,
58                "    {}[\"{}\"]\n    style {} {}",
59                id, display_name, id, style
60            )?;
61        }
62
63        writeln!(writer)?;
64        writeln!(writer, "    %% Relationships")?;
65
66        // Generate relationships with labels
67        for rel in analyzer.get_relations() {
68            let source_id = &file_ids[&rel.source];
69            let target_id = &file_ids[&rel.target];
70
71            let (arrow, label) = match rel.relation_type {
72                RelationType::Imports => ("-->", "imports"),
73                RelationType::FunctionCall => ("-.->", "calls"),
74                RelationType::TypeUsage => ("-.->", "uses"),
75                RelationType::TestedBy => ("==>", "tested by"),
76                RelationType::Exports => ("<-->", "exports"),
77                RelationType::Coupled => ("<===>", "coupled!"),
78            };
79
80            if rel.items.is_empty() {
81                writeln!(
82                    writer,
83                    "    {} {}|{}| {}",
84                    source_id, arrow, label, target_id
85                )?;
86            } else {
87                let items = rel.items.join(", ");
88                writeln!(
89                    writer,
90                    "    {} {}|{}: {}| {}",
91                    source_id, arrow, label, items, target_id
92                )?;
93            }
94        }
95
96        writeln!(writer)?;
97        writeln!(writer, "    %% Legend")?;
98        writeln!(writer, "    subgraph Legend")?;
99        writeln!(writer, "        L1[Imports] --> L2[Target]")?;
100        writeln!(writer, "        L3[Caller] -.-> L4[Function]")?;
101        writeln!(writer, "        L5[Source] ==> L6[Tests]")?;
102        writeln!(writer, "        L7[Tightly] <==> L8[Coupled]")?;
103        writeln!(writer, "    end")?;
104
105        writeln!(writer, "```")?;
106
107        Ok(())
108    }
109}
110
111/// Format relationships as DOT/GraphViz
112pub struct DotRelationFormatter;
113
114impl DotRelationFormatter {
115    pub fn format<W: Write>(
116        &self,
117        writer: &mut W,
118        analyzer: &RelationAnalyzer,
119        root_path: &Path,
120    ) -> Result<()> {
121        writeln!(writer, "digraph CodeRelations {{")?;
122        writeln!(writer, "    // Smart Tree Relationship Graph")?;
123        writeln!(writer, "    rankdir=LR;")?;
124        writeln!(writer, "    node [shape=box, style=filled];")?;
125        writeln!(writer)?;
126
127        // Collect unique files
128        let mut files = HashSet::new();
129        for rel in analyzer.get_relations() {
130            files.insert(&rel.source);
131            files.insert(&rel.target);
132        }
133
134        // Node definitions
135        writeln!(writer, "    // Nodes")?;
136        for file in &files {
137            let relative = file.strip_prefix(root_path).unwrap_or(file);
138            let display_name = relative.to_string_lossy();
139            let color = if display_name.contains("test") {
140                "lightgreen"
141            } else if display_name.ends_with(".rs") {
142                "lightyellow"
143            } else if display_name.ends_with(".py") {
144                "lightblue"
145            } else {
146                "lightgray"
147            };
148
149            writeln!(
150                writer,
151                "    \"{}\" [fillcolor=\"{}\"];",
152                display_name, color
153            )?;
154        }
155
156        writeln!(writer)?;
157        writeln!(writer, "    // Edges")?;
158
159        for rel in analyzer.get_relations() {
160            let source = rel.source.strip_prefix(root_path).unwrap_or(&rel.source);
161            let target = rel.target.strip_prefix(root_path).unwrap_or(&rel.target);
162
163            let style = match rel.relation_type {
164                RelationType::Imports => "solid",
165                RelationType::FunctionCall => "dashed",
166                RelationType::TypeUsage => "dotted",
167                RelationType::TestedBy => "bold",
168                RelationType::Exports => "solid",
169                RelationType::Coupled => "bold",
170            };
171
172            let color = match rel.relation_type {
173                RelationType::TestedBy => "green",
174                RelationType::Coupled => "red",
175                _ => "black",
176            };
177
178            writeln!(
179                writer,
180                "    \"{}\" -> \"{}\" [style={}, color={}, label=\"{:?}\"];",
181                source.to_string_lossy(),
182                target.to_string_lossy(),
183                style,
184                color,
185                rel.relation_type
186            )?;
187        }
188
189        writeln!(writer, "}}")?;
190
191        Ok(())
192    }
193}
194
195/// Format relationships in compressed AI-friendly format
196pub struct CompressedRelationFormatter;
197
198impl CompressedRelationFormatter {
199    pub fn format<W: Write>(
200        &self,
201        writer: &mut W,
202        analyzer: &RelationAnalyzer,
203        root_path: &Path,
204    ) -> Result<()> {
205        writeln!(writer, "RELATIONS_V1:")?;
206
207        // Create file index
208        let mut files = HashSet::new();
209        for rel in analyzer.get_relations() {
210            files.insert(&rel.source);
211            files.insert(&rel.target);
212        }
213
214        let mut file_index: HashMap<&PathBuf, usize> = HashMap::new();
215        writeln!(writer, "FILES:")?;
216        for (idx, file) in files.iter().enumerate() {
217            let relative = file.strip_prefix(root_path).unwrap_or(file);
218            writeln!(writer, "{:x}:{}", idx, relative.to_string_lossy())?;
219            file_index.insert(file, idx);
220        }
221
222        writeln!(writer, "RELS:")?;
223        // Format: source_idx,target_idx,type,strength[:items]
224        for rel in analyzer.get_relations() {
225            let source_idx = file_index[&rel.source];
226            let target_idx = file_index[&rel.target];
227            let type_code = match rel.relation_type {
228                RelationType::Imports => 'I',
229                RelationType::FunctionCall => 'F',
230                RelationType::TypeUsage => 'T',
231                RelationType::TestedBy => 'X',
232                RelationType::Exports => 'E',
233                RelationType::Coupled => 'C',
234            };
235
236            if rel.items.is_empty() {
237                writeln!(
238                    writer,
239                    "{:x},{:x},{},{}",
240                    source_idx, target_idx, type_code, rel.strength
241                )?;
242            } else {
243                writeln!(
244                    writer,
245                    "{:x},{:x},{},{}:{}",
246                    source_idx,
247                    target_idx,
248                    type_code,
249                    rel.strength,
250                    rel.items.join(",")
251                )?;
252            }
253        }
254
255        writeln!(writer, "END_RELATIONS")?;
256
257        Ok(())
258    }
259}
260
261/// Format relationships as a text summary
262pub struct TextRelationFormatter;
263
264impl TextRelationFormatter {
265    pub fn format<W: Write>(
266        &self,
267        writer: &mut W,
268        analyzer: &RelationAnalyzer,
269        root_path: &Path,
270    ) -> Result<()> {
271        writeln!(writer, "🔗 Code Relationship Analysis")?;
272        writeln!(writer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")?;
273        writeln!(writer)?;
274
275        // Group by file
276        let mut file_relations: HashMap<&PathBuf, Vec<&FileRelation>> = HashMap::new();
277        for rel in analyzer.get_relations() {
278            file_relations.entry(&rel.source).or_default().push(rel);
279        }
280
281        // Calculate total files before moving file_relations
282        let total_files = file_relations.len();
283
284        for (file, relations) in file_relations {
285            let relative = file.strip_prefix(root_path).unwrap_or(file);
286            writeln!(writer, "📄 {}", relative.to_string_lossy())?;
287
288            // Group by type
289            for rel_type in &[
290                RelationType::Imports,
291                RelationType::FunctionCall,
292                RelationType::TypeUsage,
293                RelationType::TestedBy,
294                RelationType::Exports,
295                RelationType::Coupled,
296            ] {
297                let typed_rels: Vec<_> = relations
298                    .iter()
299                    .filter(|r| &r.relation_type == rel_type)
300                    .collect();
301
302                if !typed_rels.is_empty() {
303                    let emoji = match rel_type {
304                        RelationType::Imports => "├─→ imports:",
305                        RelationType::FunctionCall => "├─→ calls:",
306                        RelationType::TypeUsage => "├─→ uses types from:",
307                        RelationType::TestedBy => "├─→ tested by:",
308                        RelationType::Exports => "├─→ exports to:",
309                        RelationType::Coupled => "├─⚠️  tightly coupled with:",
310                    };
311
312                    writeln!(writer, "  {}", emoji)?;
313                    for rel in typed_rels {
314                        let target = rel.target.strip_prefix(root_path).unwrap_or(&rel.target);
315                        if rel.items.is_empty() {
316                            writeln!(writer, "    • {}", target.to_string_lossy())?;
317                        } else {
318                            writeln!(
319                                writer,
320                                "    • {} ({})",
321                                target.to_string_lossy(),
322                                rel.items.join(", ")
323                            )?;
324                        }
325                    }
326                }
327            }
328            writeln!(writer)?;
329        }
330
331        // Summary statistics
332        writeln!(writer, "📊 Summary")?;
333        writeln!(writer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")?;
334
335        let total_relations = analyzer.get_relations().len();
336        let coupled_count = analyzer
337            .get_relations()
338            .iter()
339            .filter(|r| r.relation_type == RelationType::Coupled)
340            .count();
341        let tested_count = analyzer
342            .get_relations()
343            .iter()
344            .filter(|r| r.relation_type == RelationType::TestedBy)
345            .count();
346
347        writeln!(writer, "Total files analyzed: {}", total_files)?;
348        writeln!(writer, "Total relationships: {}", total_relations)?;
349        writeln!(writer, "Tightly coupled pairs: {}", coupled_count)?;
350        writeln!(writer, "Files with tests: {}", tested_count)?;
351
352        Ok(())
353    }
354}