debtmap/io/
view_formatters.rs

1//! Output formatters that consume PreparedDebtView.
2//!
3//! This module implements spec 252: Output Format Unification.
4//! All formatters accept a `PreparedDebtView` and produce formatted output.
5//!
6//! # Architecture
7//!
8//! ```text
9//! UnifiedAnalysis → prepare_view() → PreparedDebtView → formatter → output
10//!                                          │
11//!                                          ├→ format_terminal()
12//!                                          ├→ format_json()
13//!                                          └→ format_markdown()
14//! ```
15//!
16//! # Benefits
17//!
18//! - Single view preparation, multiple output formats
19//! - Consistent data across all outputs
20//! - No duplicate filtering logic
21//! - Pure transformation from view to string/struct
22
23use crate::priority::view::{PreparedDebtView, ViewItem, ViewSummary};
24use serde::{Deserialize, Serialize};
25
26// ============================================================================
27// JSON OUTPUT FORMAT
28// ============================================================================
29
30/// JSON output structure for PreparedDebtView.
31///
32/// Provides structured JSON output with metadata, summary, and items.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct JsonOutput {
35    pub format_version: String,
36    pub metadata: JsonMetadata,
37    pub summary: JsonSummary,
38    pub items: Vec<JsonItem>,
39}
40
41/// Metadata about the analysis run.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct JsonMetadata {
44    pub debtmap_version: String,
45    pub generated_at: String,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub project_root: Option<String>,
48}
49
50/// Summary statistics.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct JsonSummary {
53    pub total_items: usize,
54    pub total_items_before_filter: usize,
55    pub total_debt_score: f64,
56    pub debt_density: f64,
57    pub total_lines_of_code: usize,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub overall_coverage: Option<f64>,
60    pub score_distribution: JsonScoreDistribution,
61    pub category_counts: JsonCategoryCounts,
62}
63
64/// Score distribution by severity.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct JsonScoreDistribution {
67    pub critical: usize,
68    pub high: usize,
69    pub medium: usize,
70    pub low: usize,
71}
72
73/// Item counts by category.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct JsonCategoryCounts {
76    pub architecture: usize,
77    pub testing: usize,
78    pub performance: usize,
79    pub code_quality: usize,
80}
81
82/// JSON representation of a debt item.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(tag = "type")]
85pub enum JsonItem {
86    Function(Box<JsonFunctionItem>),
87    File(Box<JsonFileItem>),
88}
89
90/// Function-level debt item in JSON format.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct JsonFunctionItem {
93    pub score: f64,
94    pub severity: String,
95    pub category: String,
96    pub location: JsonLocation,
97    pub metrics: JsonFunctionMetrics,
98    pub recommendation: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub tier: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub scoring_details: Option<JsonScoringDetails>,
103}
104
105/// File-level debt item in JSON format.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct JsonFileItem {
108    pub score: f64,
109    pub severity: String,
110    pub category: String,
111    pub location: JsonLocation,
112    pub metrics: JsonFileMetrics,
113    pub recommendation: String,
114}
115
116/// Unified location structure.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct JsonLocation {
119    pub file: String,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub line: Option<usize>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub function: Option<String>,
124}
125
126/// Function metrics in JSON format.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct JsonFunctionMetrics {
129    pub cyclomatic_complexity: u32,
130    pub cognitive_complexity: u32,
131    pub function_length: usize,
132    pub nesting_depth: u32,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub coverage: Option<f64>,
135}
136
137/// File metrics in JSON format.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct JsonFileMetrics {
140    pub total_lines: usize,
141    pub function_count: usize,
142    pub avg_complexity: f64,
143    pub max_complexity: u32,
144    pub coverage_percent: f64,
145}
146
147/// Scoring details for verbose output.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct JsonScoringDetails {
150    pub complexity_factor: f64,
151    pub coverage_factor: f64,
152    pub dependency_factor: f64,
153    pub role_multiplier: f64,
154    pub final_score: f64,
155}
156
157// ============================================================================
158// FORMAT FUNCTIONS
159// ============================================================================
160
161/// Formats a PreparedDebtView as JSON.
162///
163/// # Arguments
164///
165/// * `view` - The prepared view to format
166/// * `include_scoring_details` - Whether to include detailed scoring breakdown
167///
168/// # Returns
169///
170/// JSON string representation of the view.
171pub fn format_json(view: &PreparedDebtView, include_scoring_details: bool) -> String {
172    let output = to_json_output(view, include_scoring_details);
173    serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
174}
175
176/// Converts PreparedDebtView to JsonOutput structure.
177pub fn to_json_output(view: &PreparedDebtView, include_scoring_details: bool) -> JsonOutput {
178    JsonOutput {
179        format_version: "3.0".to_string(),
180        metadata: JsonMetadata {
181            debtmap_version: env!("CARGO_PKG_VERSION").to_string(),
182            generated_at: chrono::Utc::now().to_rfc3339(),
183            project_root: None,
184        },
185        summary: convert_summary(&view.summary),
186        items: view
187            .items
188            .iter()
189            .map(|item| convert_item(item, include_scoring_details))
190            .collect(),
191    }
192}
193
194fn convert_summary(summary: &ViewSummary) -> JsonSummary {
195    JsonSummary {
196        total_items: summary.total_items_after_filter,
197        total_items_before_filter: summary.total_items_before_filter,
198        total_debt_score: summary.total_debt_score,
199        debt_density: summary.debt_density,
200        total_lines_of_code: summary.total_lines_of_code,
201        overall_coverage: summary.overall_coverage,
202        score_distribution: JsonScoreDistribution {
203            critical: summary.score_distribution.critical,
204            high: summary.score_distribution.high,
205            medium: summary.score_distribution.medium,
206            low: summary.score_distribution.low,
207        },
208        category_counts: JsonCategoryCounts {
209            architecture: summary.category_counts.architecture,
210            testing: summary.category_counts.testing,
211            performance: summary.category_counts.performance,
212            code_quality: summary.category_counts.code_quality,
213        },
214    }
215}
216
217fn convert_item(item: &ViewItem, include_scoring_details: bool) -> JsonItem {
218    match item {
219        ViewItem::Function(func) => {
220            let loc = item.location();
221            JsonItem::Function(Box::new(JsonFunctionItem {
222                score: func.unified_score.final_score.value(),
223                severity: item.severity().as_str().to_lowercase(),
224                category: item.category().to_string(),
225                location: JsonLocation {
226                    file: loc.file.to_string_lossy().to_string(),
227                    line: Some(loc.line.unwrap_or(0)),
228                    function: loc.function.clone(),
229                },
230                metrics: JsonFunctionMetrics {
231                    cyclomatic_complexity: func.cyclomatic_complexity,
232                    cognitive_complexity: func.cognitive_complexity,
233                    function_length: func.function_length,
234                    nesting_depth: func.nesting_depth,
235                    coverage: func.transitive_coverage.as_ref().map(|c| c.direct),
236                },
237                recommendation: func.recommendation.primary_action.clone(),
238                tier: func
239                    .tier
240                    .as_ref()
241                    .map(|t| format!("{:?}", t).to_lowercase()),
242                scoring_details: if include_scoring_details {
243                    Some(JsonScoringDetails {
244                        complexity_factor: func.unified_score.complexity_factor,
245                        coverage_factor: func.unified_score.coverage_factor,
246                        dependency_factor: func.unified_score.dependency_factor,
247                        role_multiplier: func.unified_score.role_multiplier,
248                        final_score: func.unified_score.final_score.value(),
249                    })
250                } else {
251                    None
252                },
253            }))
254        }
255        ViewItem::File(file) => {
256            let loc = item.location();
257            JsonItem::File(Box::new(JsonFileItem {
258                score: file.score,
259                severity: item.severity().as_str().to_lowercase(),
260                category: item.category().to_string(),
261                location: JsonLocation {
262                    file: loc.file.to_string_lossy().to_string(),
263                    line: None,
264                    function: None,
265                },
266                metrics: JsonFileMetrics {
267                    total_lines: file.metrics.total_lines,
268                    function_count: file.metrics.function_count,
269                    avg_complexity: file.metrics.avg_complexity,
270                    max_complexity: file.metrics.max_complexity,
271                    coverage_percent: file.metrics.coverage_percent,
272                },
273                recommendation: file.recommendation.clone(),
274            }))
275        }
276    }
277}
278
279// ============================================================================
280// TERMINAL FORMAT
281// ============================================================================
282
283/// Configuration for terminal output.
284pub struct TerminalConfig {
285    pub verbosity: u8,
286    pub use_color: bool,
287    pub summary_mode: bool,
288}
289
290impl Default for TerminalConfig {
291    fn default() -> Self {
292        Self {
293            verbosity: 0,
294            use_color: true,
295            summary_mode: false,
296        }
297    }
298}
299
300/// Formats a PreparedDebtView for terminal output.
301///
302/// # Arguments
303///
304/// * `view` - The prepared view to format
305/// * `config` - Terminal formatting configuration
306///
307/// # Returns
308///
309/// Formatted string for terminal display.
310pub fn format_terminal(view: &PreparedDebtView, config: &TerminalConfig) -> String {
311    use std::fmt::Write;
312    let mut output = String::new();
313
314    // Header
315    writeln!(
316        output,
317        "\n═══════════════════════════════════════════════════════════════════════════════"
318    )
319    .ok();
320    writeln!(output, "                           TECHNICAL DEBT ANALYSIS").ok();
321    writeln!(
322        output,
323        "═══════════════════════════════════════════════════════════════════════════════\n"
324    )
325    .ok();
326
327    // Summary
328    format_terminal_summary(&mut output, &view.summary);
329
330    if view.is_empty() {
331        writeln!(
332            output,
333            "\nNo technical debt items found matching current thresholds."
334        )
335        .ok();
336        return output;
337    }
338
339    // Items
340    if config.summary_mode {
341        format_terminal_summary_mode(&mut output, view);
342    } else {
343        format_terminal_items(&mut output, view, config.verbosity);
344    }
345
346    output
347}
348
349fn format_terminal_summary(output: &mut String, summary: &ViewSummary) {
350    use std::fmt::Write;
351
352    writeln!(output, "Summary:").ok();
353    writeln!(
354        output,
355        "  Total items: {} (of {} analyzed)",
356        summary.total_items_after_filter, summary.total_items_before_filter
357    )
358    .ok();
359    writeln!(
360        output,
361        "  Total debt score: {:.1}",
362        summary.total_debt_score
363    )
364    .ok();
365    writeln!(
366        output,
367        "  Debt density: {:.2} per 1k LOC",
368        summary.debt_density
369    )
370    .ok();
371    if let Some(coverage) = summary.overall_coverage {
372        writeln!(output, "  Overall coverage: {:.1}%", coverage * 100.0).ok();
373    }
374    writeln!(output).ok();
375
376    writeln!(
377        output,
378        "  By severity: {} critical, {} high, {} medium, {} low",
379        summary.score_distribution.critical,
380        summary.score_distribution.high,
381        summary.score_distribution.medium,
382        summary.score_distribution.low
383    )
384    .ok();
385    writeln!(output).ok();
386}
387
388fn format_terminal_summary_mode(output: &mut String, view: &PreparedDebtView) {
389    use std::fmt::Write;
390
391    // Group by tier and show counts
392    let mut tier_counts: std::collections::HashMap<String, usize> =
393        std::collections::HashMap::new();
394
395    for item in &view.items {
396        let tier = item
397            .tier()
398            .map(|t| format!("{:?}", t))
399            .unwrap_or_else(|| "Unclassified".to_string());
400        *tier_counts.entry(tier).or_insert(0) += 1;
401    }
402
403    writeln!(output, "Items by Recommendation Tier:").ok();
404    for (tier, count) in &tier_counts {
405        writeln!(output, "  {}: {}", tier, count).ok();
406    }
407}
408
409fn format_terminal_items(output: &mut String, view: &PreparedDebtView, verbosity: u8) {
410    use std::fmt::Write;
411
412    writeln!(
413        output,
414        "───────────────────────────────────────────────────────────────────────────────"
415    )
416    .ok();
417    writeln!(output, "Top Priority Items:").ok();
418    writeln!(
419        output,
420        "───────────────────────────────────────────────────────────────────────────────\n"
421    )
422    .ok();
423
424    for (i, item) in view.items.iter().enumerate() {
425        format_terminal_item(output, item, i + 1, verbosity);
426    }
427}
428
429fn format_terminal_item(output: &mut String, item: &ViewItem, rank: usize, verbosity: u8) {
430    use std::fmt::Write;
431
432    let loc = item.location();
433    let severity = item.severity().as_str().to_uppercase();
434
435    writeln!(
436        output,
437        "{}. [{}] {:.1} - {}",
438        rank,
439        severity,
440        item.score(),
441        loc.file.display()
442    )
443    .ok();
444
445    if let Some(func) = &loc.function {
446        writeln!(
447            output,
448            "   Function: {} (line {})",
449            func,
450            loc.line.unwrap_or(0)
451        )
452        .ok();
453    }
454
455    match item {
456        ViewItem::Function(f) => {
457            writeln!(
458                output,
459                "   Complexity: cyclomatic={}, cognitive={}, nesting={}",
460                f.cyclomatic_complexity, f.cognitive_complexity, f.nesting_depth
461            )
462            .ok();
463            if verbosity >= 1 {
464                writeln!(
465                    output,
466                    "   Recommendation: {}",
467                    f.recommendation.primary_action
468                )
469                .ok();
470            }
471        }
472        ViewItem::File(f) => {
473            writeln!(
474                output,
475                "   File metrics: {} lines, {} functions, avg complexity={:.1}",
476                f.metrics.total_lines, f.metrics.function_count, f.metrics.avg_complexity
477            )
478            .ok();
479            if verbosity >= 1 {
480                writeln!(output, "   Recommendation: {}", f.recommendation).ok();
481            }
482        }
483    }
484
485    writeln!(output).ok();
486}
487
488// ============================================================================
489// MARKDOWN FORMAT
490// ============================================================================
491
492/// Configuration for markdown output.
493#[derive(Default)]
494pub struct MarkdownConfig {
495    pub verbosity: u8,
496    pub show_filter_stats: bool,
497}
498
499/// Formats a PreparedDebtView as Markdown.
500///
501/// # Arguments
502///
503/// * `view` - The prepared view to format
504/// * `config` - Markdown formatting configuration
505///
506/// # Returns
507///
508/// Formatted markdown string.
509pub fn format_markdown(view: &PreparedDebtView, config: &MarkdownConfig) -> String {
510    use std::fmt::Write;
511    let mut output = String::new();
512
513    // Header
514    writeln!(output, "# Technical Debt Analysis Report\n").ok();
515
516    // Summary section
517    format_markdown_summary(&mut output, &view.summary);
518
519    if view.is_empty() {
520        writeln!(
521            output,
522            "\n*No technical debt items found matching current thresholds.*"
523        )
524        .ok();
525        return output;
526    }
527
528    // Items section
529    writeln!(output, "## Debt Items\n").ok();
530    for (i, item) in view.items.iter().enumerate() {
531        format_markdown_item(&mut output, item, i + 1, config.verbosity);
532    }
533
534    // Filter stats if requested
535    if config.show_filter_stats {
536        format_markdown_filter_stats(&mut output, &view.summary);
537    }
538
539    output
540}
541
542fn format_markdown_summary(output: &mut String, summary: &ViewSummary) {
543    use std::fmt::Write;
544
545    writeln!(output, "## Summary\n").ok();
546    writeln!(
547        output,
548        "**Total Debt Items:** {}\n",
549        summary.total_items_after_filter
550    )
551    .ok();
552    writeln!(output, "| Metric | Value |").ok();
553    writeln!(output, "|--------|-------|").ok();
554    writeln!(
555        output,
556        "| Total Debt Score | {:.1} |",
557        summary.total_debt_score
558    )
559    .ok();
560    writeln!(
561        output,
562        "| Debt Density | {:.2} per 1k LOC |",
563        summary.debt_density
564    )
565    .ok();
566    writeln!(
567        output,
568        "| Lines of Code | {} |",
569        summary.total_lines_of_code
570    )
571    .ok();
572    if let Some(coverage) = summary.overall_coverage {
573        writeln!(output, "| Overall Coverage | {:.1}% |", coverage * 100.0).ok();
574    }
575    writeln!(output).ok();
576
577    // Score distribution
578    writeln!(output, "### Score Distribution\n").ok();
579    writeln!(output, "| Severity | Count |").ok();
580    writeln!(output, "|----------|-------|").ok();
581    writeln!(
582        output,
583        "| Critical | {} |",
584        summary.score_distribution.critical
585    )
586    .ok();
587    writeln!(output, "| High | {} |", summary.score_distribution.high).ok();
588    writeln!(output, "| Medium | {} |", summary.score_distribution.medium).ok();
589    writeln!(output, "| Low | {} |", summary.score_distribution.low).ok();
590    writeln!(output).ok();
591}
592
593fn format_markdown_item(output: &mut String, item: &ViewItem, rank: usize, verbosity: u8) {
594    use std::fmt::Write;
595
596    let loc = item.location();
597    let severity = item.severity().as_str();
598
599    writeln!(
600        output,
601        "### {}. {} (Score: {:.1})\n",
602        rank,
603        severity,
604        item.score()
605    )
606    .ok();
607    writeln!(output, "**File:** `{}`", loc.file.display()).ok();
608    if let Some(func) = &loc.function {
609        writeln!(
610            output,
611            "**Function:** `{}` (line {})",
612            func,
613            loc.line.unwrap_or(0)
614        )
615        .ok();
616    }
617    writeln!(output).ok();
618
619    match item {
620        ViewItem::Function(f) => {
621            writeln!(output, "| Metric | Value |").ok();
622            writeln!(output, "|--------|-------|").ok();
623            writeln!(
624                output,
625                "| Cyclomatic Complexity | {} |",
626                f.cyclomatic_complexity
627            )
628            .ok();
629            writeln!(
630                output,
631                "| Cognitive Complexity | {} |",
632                f.cognitive_complexity
633            )
634            .ok();
635            writeln!(output, "| Nesting Depth | {} |", f.nesting_depth).ok();
636            writeln!(output, "| Function Length | {} lines |", f.function_length).ok();
637            if let Some(cov) = f.transitive_coverage.as_ref() {
638                writeln!(output, "| Coverage | {:.1}% |", cov.direct * 100.0).ok();
639            }
640            writeln!(output).ok();
641
642            if verbosity >= 1 {
643                writeln!(
644                    output,
645                    "**Recommendation:** {}\n",
646                    f.recommendation.primary_action
647                )
648                .ok();
649            }
650        }
651        ViewItem::File(f) => {
652            writeln!(output, "| Metric | Value |").ok();
653            writeln!(output, "|--------|-------|").ok();
654            writeln!(output, "| Total Lines | {} |", f.metrics.total_lines).ok();
655            writeln!(output, "| Function Count | {} |", f.metrics.function_count).ok();
656            writeln!(
657                output,
658                "| Avg Complexity | {:.1} |",
659                f.metrics.avg_complexity
660            )
661            .ok();
662            writeln!(output, "| Max Complexity | {} |", f.metrics.max_complexity).ok();
663            writeln!(
664                output,
665                "| Coverage | {:.1}% |",
666                f.metrics.coverage_percent * 100.0
667            )
668            .ok();
669            writeln!(output).ok();
670
671            if verbosity >= 1 {
672                writeln!(output, "**Recommendation:** {}\n", f.recommendation).ok();
673            }
674        }
675    }
676}
677
678fn format_markdown_filter_stats(output: &mut String, summary: &ViewSummary) {
679    use std::fmt::Write;
680
681    writeln!(output, "## Filtering Summary\n").ok();
682    writeln!(
683        output,
684        "- Total items analyzed: {}",
685        summary.total_items_before_filter
686    )
687    .ok();
688    writeln!(
689        output,
690        "- Items included: {}",
691        summary.total_items_after_filter
692    )
693    .ok();
694    writeln!(output, "- Filtered by score: {}", summary.filtered_by_score).ok();
695    writeln!(output, "- Filtered by tier: {}", summary.filtered_by_tier).ok();
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::priority::call_graph::CallGraph;
702    use crate::priority::tiers::TierConfig;
703    use crate::priority::view::ViewConfig;
704    use crate::priority::view_pipeline::prepare_view;
705    use crate::priority::UnifiedAnalysis;
706
707    fn create_empty_view() -> PreparedDebtView {
708        let call_graph = CallGraph::new();
709        let analysis = UnifiedAnalysis::new(call_graph);
710        prepare_view(&analysis, &ViewConfig::default(), &TierConfig::default())
711    }
712
713    #[test]
714    fn test_format_json_empty_view() {
715        let view = create_empty_view();
716        let json = format_json(&view, false);
717        // Pretty-printed JSON has spaces after colons
718        assert!(json.contains("\"format_version\": \"3.0\""));
719        assert!(json.contains("\"total_items\": 0"));
720    }
721
722    #[test]
723    fn test_format_terminal_empty_view() {
724        let view = create_empty_view();
725        let config = TerminalConfig::default();
726        let output = format_terminal(&view, &config);
727        assert!(output.contains("TECHNICAL DEBT ANALYSIS"));
728        assert!(output.contains("No technical debt items found"));
729    }
730
731    #[test]
732    fn test_format_markdown_empty_view() {
733        let view = create_empty_view();
734        let config = MarkdownConfig::default();
735        let output = format_markdown(&view, &config);
736        assert!(output.contains("# Technical Debt Analysis Report"));
737        assert!(output.contains("No technical debt items found"));
738    }
739
740    #[test]
741    fn test_to_json_output_structure() {
742        let view = create_empty_view();
743        let output = to_json_output(&view, false);
744        assert_eq!(output.format_version, "3.0");
745        assert!(output.items.is_empty());
746    }
747
748    #[test]
749    fn test_format_json_with_scoring_details() {
750        let view = create_empty_view();
751        let json_without = format_json(&view, false);
752        let json_with = format_json(&view, true);
753        // Both should be valid JSON, scoring details only visible with items
754        assert!(json_without.contains("format_version"));
755        assert!(json_with.contains("format_version"));
756    }
757
758    #[test]
759    fn test_terminal_config_default() {
760        let config = TerminalConfig::default();
761        assert_eq!(config.verbosity, 0);
762        assert!(config.use_color);
763        assert!(!config.summary_mode);
764    }
765
766    #[test]
767    fn test_markdown_config_default() {
768        let config = MarkdownConfig::default();
769        assert_eq!(config.verbosity, 0);
770        assert!(!config.show_filter_stats);
771    }
772}