1use 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}