Skip to main content

grip/
stdout_reporter.rs

1// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::io::{self, Write};
6
7use anyhow::Result;
8
9use crate::grip_report::GripReport;
10use crate::module_stats::ModuleStats;
11use crate::traits::reporter::Reporter;
12
13#[derive(Debug, Clone)]
14pub struct StdoutReporter {
15    json: bool,
16    verbose: bool,
17}
18
19impl StdoutReporter {
20    #[must_use]
21    pub fn new(json: bool, verbose: bool) -> Self {
22        Self { json, verbose }
23    }
24}
25
26impl Reporter for StdoutReporter {
27    fn render(&self, report: &GripReport) -> Result<String> {
28        if self.json {
29            Ok(serde_json::to_string_pretty(report)?)
30        } else {
31            Ok(self.render_human(report))
32        }
33    }
34
35    fn write(&self, report: &GripReport) -> Result<()> {
36        let out = self.render(report)?;
37        io::stdout().write_all(out.as_bytes())?;
38        io::stdout().write_all(b"\n")?;
39        Ok(())
40    }
41}
42
43impl StdoutReporter {
44    fn render_human(&self, report: &GripReport) -> String {
45        let mut lines = Vec::new();
46        let target = &report.target;
47        let version = &report.version;
48        let header = if self.verbose {
49            format!("grip {version} — {target} — verbose")
50        } else {
51            format!("cargo-grip4rust {version} -- {target}")
52        };
53        lines.push(format!(
54            "{header}\n══════════════════════════════════════════════════════\n"
55        ));
56
57        let overall = &report.overall;
58        lines.push(format!(
59            "Overall grip score:    {} / 100",
60            overall.grip_score
61        ));
62        lines.push(format!(
63            "Public surface:        {} items",
64            overall.public_items
65        ));
66        lines.push(format!(
67            "Total functions:       {}",
68            overall.total_functions
69        ));
70        lines.push(format!(
71            "Probably pure:         {} / {}  ({:.1}%)",
72            overall.pure_functions,
73            overall.total_functions,
74            overall.pure_ratio * 100.0
75        ));
76
77        let total_impl = overall.inherent_methods + overall.local_trait_methods;
78        if total_impl > 0 && overall.trait_ratio == 0.0 {
79            lines.push(format!(
80                "Trait methods:         {} / {} impl methods are trait-bound  (0.0%)",
81                overall.local_trait_methods, total_impl,
82            ));
83        } else if total_impl == 0 {
84            lines.push("Trait methods:         N/A    (no impl methods)".to_string());
85        } else {
86            lines.push(format!(
87                "Trait methods:         {} / {} impl methods are trait-bound  ({:.1}%)",
88                overall.local_trait_methods,
89                total_impl,
90                overall.trait_ratio * 100.0
91            ));
92        }
93
94        lines.push(format!(
95            "Hidden deps:           avg {:.2}  — {:.1}% clean  ({:.1}% avg contribution)",
96            overall.total_functions as f64 - overall.avg_contribution * overall.total_functions as f64,
97            overall.clean_fn_ratio * 100.0,
98            overall.avg_contribution * 100.0,
99        ));
100
101        lines.push("\nPer module:".to_string());
102        for module in &report.modules {
103            lines.push(self.render_module_line(module));
104        }
105
106        if !report.offenders.is_empty() {
107            lines.push(format!(
108                "\nOffenders (score < {}):",
109                report.offender_threshold
110            ));
111            for offender in &report.offenders {
112                lines.push(format!(
113                    "  {:<30}  grip: {:>3}  ❌",
114                    offender.path, offender.grip_score,
115                ));
116            }
117        }
118
119        if self.verbose && !report.functions.is_empty() {
120            lines.push("\nPer-function detail:".to_string());
121            let mut sorted = report.functions.clone();
122            sorted.sort_by(|a, b| a.file.cmp(&b.file).then(a.name.cmp(&b.name)));
123            let mut current_file = String::new();
124            for f in &sorted {
125                if f.file != current_file {
126                    current_file = f.file.clone();
127                    lines.push(format!("\n  {}:", current_file));
128                }
129                let marker = contribution_marker(f.hidden_deps);
130                let contr = crate::contribution_schedule::contribution(f.is_pure, f.has_trait_seam, f.dep_weight);
131                let labels = f.hidden_dep_labels.join(", ");
132                lines.push(format!(
133                    "    {:<35}  pure: {:>5}  seam: {:>5}  hidden: {:>2}  contr: {:>5.0}%  [{}]  {}",
134                    f.name,
135                    if f.is_pure { "yes" } else { "no" },
136                    if f.has_trait_seam { "yes" } else { "no " },
137                    f.hidden_deps,
138                    contr * 100.0,
139                    if labels.is_empty() { "-" } else { &labels },
140                    marker,
141                ));
142            }
143        }
144
145        lines.join("\n")
146    }
147
148    fn render_module_line(&self, module: &ModuleStats) -> String {
149        let marker = self.module_marker(module.grip_score);
150        let total_impl = module.inherent_methods + module.local_trait_methods;
151        let traits_display = if total_impl == 0 {
152            "   N/A".to_string()
153        } else {
154            format!("{:>5.1}%", module.trait_ratio * 100.0)
155        };
156        format!(
157            "  {:<30}  grip: {:>3}   pure: {:>5.1}%   pub: {:>3}   traits: {}   clean: {:>5.1}%  {}",
158            module.path,
159            module.grip_score,
160            module.pure_ratio * 100.0,
161            module.public_items,
162            traits_display,
163            module.clean_fn_ratio * 100.0,
164            marker,
165        )
166    }
167
168    fn module_marker(&self, score: u32) -> &'static str {
169        if score < 40 {
170            "❌"
171        } else if score < 70 {
172            "⚠️"
173        } else {
174            ""
175        }
176    }
177}
178
179fn contribution_marker(hidden_deps: usize) -> &'static str {
180    if hidden_deps == 0 {
181        "✅"
182    } else if hidden_deps == 1 {
183        "⚠️"
184    } else {
185        "❌"
186    }
187}