rust_diff_analyzer/output/
comment.rs1use crate::{
5 config::Config,
6 types::{AnalysisResult, Change, ExclusionReason, SemanticUnitKind},
7};
8
9const COMMENT_MARKER: &str = "<!-- rust-diff-analyzer-comment -->";
10
11pub fn format_comment(result: &AnalysisResult, config: &Config) -> String {
37 let summary = &result.summary;
38
39 let mut output = String::new();
40
41 output.push_str(COMMENT_MARKER);
42 output.push('\n');
43 output.push_str("## Rust Diff Analysis\n\n");
44
45 if summary.exceeds_limit {
47 output.push_str("> [!CAUTION]\n");
48 output.push_str(
49 "> **PR exceeds configured limits.** Consider splitting into smaller PRs.\n",
50 );
51
52 let mut exceeded = Vec::new();
53 if summary.total_prod_units() > config.limits.max_prod_units {
54 exceeded.push(format!(
55 "**{}** units (limit: {})",
56 summary.total_prod_units(),
57 config.limits.max_prod_units
58 ));
59 }
60 if summary.weighted_score > config.limits.max_weighted_score {
61 exceeded.push(format!(
62 "**{}** weighted score (limit: {})",
63 summary.weighted_score, config.limits.max_weighted_score
64 ));
65 }
66 if let Some(max_lines) = config.limits.max_prod_lines
67 && summary.prod_lines_added > max_lines
68 {
69 exceeded.push(format!(
70 "**{}** lines added (limit: {})",
71 summary.prod_lines_added, max_lines
72 ));
73 }
74 if !exceeded.is_empty() {
75 output.push_str(">\n");
76 for item in &exceeded {
77 output.push_str(&format!("> - {}\n", item));
78 }
79 }
80 } else {
81 output.push_str("> [!TIP]\n");
82 output.push_str("> **PR size is within limits.** Good job keeping changes focused!\n");
83 }
84
85 output.push_str("\n<details>\n");
87 output.push_str(
88 "<summary><strong>Limits</strong> — configured thresholds for this \
89 repository</summary>\n\n",
90 );
91 output.push_str("> *Each metric is compared against its configured maximum. ");
92 output.push_str("If any limit is exceeded, the PR check fails.*\n\n");
93 output.push_str("| Metric | Value | Limit | Status |\n");
94 output.push_str("|--------|------:|------:|:------:|\n");
95
96 let units_status = if summary.total_prod_units() > config.limits.max_prod_units {
97 "❌"
98 } else {
99 "✅"
100 };
101 output.push_str(&format!(
102 "| Production Units | {} | {} | {} |\n",
103 summary.total_prod_units(),
104 config.limits.max_prod_units,
105 units_status
106 ));
107
108 let score_status = if summary.weighted_score > config.limits.max_weighted_score {
109 "❌"
110 } else {
111 "✅"
112 };
113 output.push_str(&format!(
114 "| Weighted Score | {} | {} | {} |\n",
115 summary.weighted_score, config.limits.max_weighted_score, score_status
116 ));
117
118 if let Some(max_lines) = config.limits.max_prod_lines {
119 let lines_status = if summary.prod_lines_added > max_lines {
120 "❌"
121 } else {
122 "✅"
123 };
124 output.push_str(&format!(
125 "| Lines Added | {} | {} | {} |\n",
126 summary.prod_lines_added, max_lines, lines_status
127 ));
128 }
129
130 output.push_str("\n**Understanding the metrics:**\n");
131 output.push_str(
132 "- **Production Units**: Functions, structs, enums, traits, and other semantic code \
133 units in production code\n",
134 );
135 output.push_str(
136 "- **Weighted Score**: Complexity score based on unit types (public APIs weigh more than \
137 private)\n",
138 );
139 output.push_str("- **Lines Added**: Raw count of new lines in production code\n");
140 output.push_str("\n</details>\n");
141
142 output.push_str("\n<details>\n");
144 output.push_str(
145 "<summary><strong>Summary</strong> — breakdown of changes by category</summary>\n\n",
146 );
147 output.push_str(
148 "> *Production code counts toward limits. Test code is tracked but doesn't affect \
149 limits.*\n\n",
150 );
151 output.push_str("| Metric | Production | Test |\n");
152 output.push_str("|--------|----------:|-----:|\n");
153 output.push_str(&format!("| Functions | {} | - |\n", summary.prod_functions));
154 output.push_str(&format!(
155 "| Structs/Enums | {} | - |\n",
156 summary.prod_structs
157 ));
158 output.push_str(&format!("| Other | {} | - |\n", summary.prod_other));
159 output.push_str(&format!(
160 "| Lines added | +{} | +{} |\n",
161 summary.prod_lines_added, summary.test_lines_added
162 ));
163 output.push_str(&format!(
164 "| Lines removed | -{} | -{} |\n",
165 summary.prod_lines_removed, summary.test_lines_removed
166 ));
167 output.push_str(&format!(
168 "| **Total units** | **{}** | {} |\n",
169 summary.total_prod_units(),
170 summary.test_units
171 ));
172 output.push_str("\n</details>\n");
173
174 if config.output.include_details && !result.changes.is_empty() {
176 let prod_changes: Vec<_> = result.production_changes().collect();
177 let test_changes: Vec<_> = result.test_changes().collect();
178
179 if !prod_changes.is_empty() {
180 output.push_str("\n<details>\n");
181 output.push_str(&format!(
182 "<summary><strong>Production Changes</strong> — {} units modified</summary>\n\n",
183 prod_changes.len()
184 ));
185 output.push_str(
186 "> *Semantic units (functions, structs, etc.) that were added or modified in \
187 production code.*\n\n",
188 );
189 output.push_str("| File | Unit | Type | Changes |\n");
190 output.push_str("|------|------|:----:|--------:|\n");
191 for change in prod_changes {
192 output.push_str(&format_change_row(change));
193 }
194 output.push_str("\n</details>\n");
195 }
196
197 if !test_changes.is_empty() {
198 output.push_str("\n<details>\n");
199 output.push_str(&format!(
200 "<summary><strong>Test Changes</strong> — {} units modified</summary>\n\n",
201 test_changes.len()
202 ));
203 output.push_str("> *Test code changes don't count toward PR size limits.*\n\n");
204 output.push_str("| File | Unit | Type | Changes |\n");
205 output.push_str("|------|------|:----:|--------:|\n");
206 for change in test_changes {
207 output.push_str(&format_change_row(change));
208 }
209 output.push_str("\n</details>\n");
210 }
211 }
212
213 format_scope_section(&mut output, result);
214
215 output.push_str("\n---\n");
216 output.push_str(
217 "<sub>[Rust Diff Analyzer](https://github.com/RAprogramm/rust-prod-diff-checker)</sub>\n",
218 );
219
220 output
221}
222
223fn format_change_row(change: &Change) -> String {
224 let kind = match change.unit.kind {
225 SemanticUnitKind::Function => "function",
226 SemanticUnitKind::Struct => "struct",
227 SemanticUnitKind::Enum => "enum",
228 SemanticUnitKind::Trait => "trait",
229 SemanticUnitKind::Impl => "impl",
230 SemanticUnitKind::Const => "const",
231 SemanticUnitKind::Static => "static",
232 SemanticUnitKind::TypeAlias => "type",
233 SemanticUnitKind::Macro => "macro",
234 SemanticUnitKind::Module => "module",
235 };
236
237 let span = &change.unit.span;
238 let file_with_lines = format!(
239 "`{}:{}-{}`",
240 change.file_path.display(),
241 span.start,
242 span.end
243 );
244
245 let changes = format!("+{} -{}", change.lines_added, change.lines_removed);
246
247 format!(
248 "| {} | `{}` | {} | {} |\n",
249 file_with_lines,
250 change.unit.qualified_name(),
251 kind,
252 changes
253 )
254}
255
256fn format_scope_section(output: &mut String, result: &AnalysisResult) {
257 let scope = &result.scope;
258
259 if scope.analyzed_files.is_empty()
260 && scope.skipped_files.is_empty()
261 && scope.exclusion_patterns.is_empty()
262 {
263 return;
264 }
265
266 output.push_str("\n<details>\n");
267 output.push_str("<summary>Analysis Scope</summary>\n\n");
268
269 if !scope.analyzed_files.is_empty() {
270 output.push_str(&format!(
271 "**Analyzed:** {} Rust files\n\n",
272 scope.analyzed_files.len()
273 ));
274 }
275
276 if !scope.exclusion_patterns.is_empty() {
277 output.push_str("**Excluded patterns:**\n");
278 for pattern in &scope.exclusion_patterns {
279 output.push_str(&format!("- `{}`\n", pattern));
280 }
281 output.push('\n');
282 }
283
284 let non_rust = scope.non_rust_count();
285 let ignored = scope.ignored_count();
286
287 if non_rust > 0 || ignored > 0 {
288 output.push_str("**Skipped files:**\n");
289 if non_rust > 0 {
290 output.push_str(&format!("- {} non-Rust files\n", non_rust));
291 }
292 if ignored > 0 {
293 output.push_str(&format!("- {} files matched ignore patterns\n", ignored));
294 }
295 output.push('\n');
296 }
297
298 if !scope.skipped_files.is_empty() && scope.skipped_files.len() <= 10 {
299 output.push_str("**Skipped file list:**\n");
300 for skipped in &scope.skipped_files {
301 let reason = match &skipped.reason {
302 ExclusionReason::NonRust => "non-Rust".to_string(),
303 ExclusionReason::IgnorePattern(p) => format!("pattern: {}", p),
304 };
305 output.push_str(&format!("- `{}` ({})\n", skipped.path.display(), reason));
306 }
307 output.push('\n');
308 }
309
310 output.push_str("</details>\n");
311}
312
313pub fn get_comment_marker() -> &'static str {
328 COMMENT_MARKER
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::types::{AnalysisScope, Summary};
335
336 #[test]
337 fn test_format_comment() {
338 let result = AnalysisResult::new(vec![], Summary::default(), AnalysisScope::new());
339 let config = Config::default();
340 let output = format_comment(&result, &config);
341
342 assert!(output.contains(COMMENT_MARKER));
343 assert!(output.contains("Rust Diff Analysis"));
344 assert!(output.contains("Production"));
345 assert!(output.contains("Test"));
346 }
347
348 #[test]
349 fn test_format_comment_with_exceeded_limit() {
350 let summary = Summary {
351 exceeds_limit: true,
352 ..Default::default()
353 };
354 let result = AnalysisResult::new(vec![], summary, AnalysisScope::new());
355 let config = Config::default();
356 let output = format_comment(&result, &config);
357
358 assert!(output.contains("[!CAUTION]"));
359 assert!(output.contains("PR exceeds configured limits"));
360 }
361
362 #[test]
363 fn test_get_comment_marker() {
364 let marker = get_comment_marker();
365 assert!(marker.contains("rust-diff-analyzer"));
366 }
367}