rust_diff_analyzer/output/
comment.rs1use std::fmt::Write;
5
6use crate::{
7 classifier::rules::exceeded_per_type_limits,
8 config::Config,
9 types::{AnalysisResult, Change, ExclusionReason},
10};
11
12fn escape_cell(text: &str) -> String {
17 text.replace('|', "\\|").replace('`', "'")
18}
19
20fn status_icon(exceeded: bool) -> &'static str {
22 if exceeded { "❌" } else { "✅" }
23}
24
25const COMMENT_MARKER: &str = "<!-- rust-diff-analyzer-comment -->";
26
27const MAX_SKIPPED_LISTED: usize = 10;
28
29pub fn format_comment(result: &AnalysisResult, config: &Config) -> String {
55 let summary = &result.summary;
56
57 let mut output = String::new();
58
59 output.push_str(COMMENT_MARKER);
60 output.push('\n');
61 output.push_str("## Rust Diff Analysis\n\n");
62
63 if summary.exceeds_limit {
64 output.push_str("> [!CAUTION]\n");
65 output.push_str(
66 "> **PR exceeds configured limits.** Consider splitting into smaller PRs.\n",
67 );
68
69 let mut exceeded = Vec::new();
70 if summary.total_prod_units() > config.limits.max_prod_units {
71 exceeded.push(format!(
72 "**{}** units (limit: {})",
73 summary.total_prod_units(),
74 config.limits.max_prod_units
75 ));
76 }
77 if summary.weighted_score > config.limits.max_weighted_score {
78 exceeded.push(format!(
79 "**{}** weighted score (limit: {})",
80 summary.weighted_score, config.limits.max_weighted_score
81 ));
82 }
83 if let Some(max_lines) = config.limits.max_prod_lines
84 && summary.prod_lines_added > max_lines
85 {
86 exceeded.push(format!(
87 "**{}** lines added (limit: {})",
88 summary.prod_lines_added, max_lines
89 ));
90 }
91 for (kind, count, limit) in exceeded_per_type_limits(&result.changes, config) {
92 exceeded.push(format!(
93 "**{}** changed units of type `{}` (limit: {})",
94 count, kind, limit
95 ));
96 }
97 if !exceeded.is_empty() {
98 output.push_str(">\n");
99 for item in &exceeded {
100 let _ = writeln!(output, "> - {}", item);
101 }
102 }
103 } else {
104 output.push_str("> [!TIP]\n");
105 output.push_str("> **PR size is within limits.** Good job keeping changes focused!\n");
106 }
107
108 output.push_str("\n<details>\n");
109 output.push_str(
110 "<summary><strong>Limits</strong> — configured thresholds for this \
111 repository</summary>\n\n",
112 );
113 output.push_str("> *Each metric is compared against its configured maximum. ");
114 output.push_str("If any limit is exceeded, the PR check fails.*\n\n");
115 output.push_str("| Metric | Value | Limit | Status |\n");
116 output.push_str("|--------|------:|------:|:------:|\n");
117
118 let _ = writeln!(
119 output,
120 "| Production Units | {} | {} | {} |",
121 summary.total_prod_units(),
122 config.limits.max_prod_units,
123 status_icon(summary.total_prod_units() > config.limits.max_prod_units)
124 );
125
126 let _ = writeln!(
127 output,
128 "| Weighted Score | {} | {} | {} |",
129 summary.weighted_score,
130 config.limits.max_weighted_score,
131 status_icon(summary.weighted_score > config.limits.max_weighted_score)
132 );
133
134 if let Some(max_lines) = config.limits.max_prod_lines {
135 let _ = writeln!(
136 output,
137 "| Lines Added | {} | {} | {} |",
138 summary.prod_lines_added,
139 max_lines,
140 status_icon(summary.prod_lines_added > max_lines)
141 );
142 }
143
144 output.push_str("\n**Understanding the metrics:**\n");
145 output.push_str(
146 "- **Production Units**: Functions, structs, enums, traits, and other semantic code \
147 units in production code\n",
148 );
149 output.push_str(
150 "- **Weighted Score**: Complexity score based on unit types (public APIs weigh more than \
151 private)\n",
152 );
153 output.push_str("- **Lines Added**: Raw count of new lines in production code\n");
154 output.push_str("\n</details>\n");
155
156 output.push_str("\n<details>\n");
157 output.push_str(
158 "<summary><strong>Summary</strong> — breakdown of changes by category</summary>\n\n",
159 );
160 output.push_str(
161 "> *Production code counts toward limits. Test code is tracked but doesn't affect \
162 limits.*\n\n",
163 );
164 output.push_str("| Metric | Production | Test |\n");
165 output.push_str("|--------|----------:|-----:|\n");
166 let _ = writeln!(output, "| Functions | {} | - |", summary.prod_functions);
167 let _ = writeln!(output, "| Structs/Enums | {} | - |", summary.prod_structs);
168 let _ = writeln!(output, "| Other | {} | - |", summary.prod_other);
169 let _ = writeln!(
170 output,
171 "| Lines added | +{} | +{} |",
172 summary.prod_lines_added, summary.test_lines_added
173 );
174 let _ = writeln!(
175 output,
176 "| Lines removed | -{} | -{} |",
177 summary.prod_lines_removed, summary.test_lines_removed
178 );
179 let _ = writeln!(
180 output,
181 "| **Total units** | **{}** | {} |",
182 summary.total_prod_units(),
183 summary.test_units
184 );
185 output.push_str("\n</details>\n");
186
187 if config.output.include_details && !result.changes.is_empty() {
188 let prod_changes: Vec<_> = result.production_changes().collect();
189 let test_changes: Vec<_> = result.test_changes().collect();
190
191 if !prod_changes.is_empty() {
192 output.push_str("\n<details>\n");
193 let _ = writeln!(
194 output,
195 "<summary><strong>Production Changes</strong> — {} units modified</summary>\n",
196 prod_changes.len()
197 );
198 output.push_str(
199 "> *Semantic units (functions, structs, etc.) that were added or modified in \
200 production code.*\n\n",
201 );
202 output.push_str("| File | Unit | Type | Changes |\n");
203 output.push_str("|------|------|:----:|--------:|\n");
204 for change in prod_changes {
205 write_change_row(&mut output, change);
206 }
207 output.push_str("\n</details>\n");
208 }
209
210 if !test_changes.is_empty() {
211 output.push_str("\n<details>\n");
212 let _ = writeln!(
213 output,
214 "<summary><strong>Test Changes</strong> — {} units modified</summary>\n",
215 test_changes.len()
216 );
217 output.push_str("> *Test code changes don't count toward PR size limits.*\n\n");
218 output.push_str("| File | Unit | Type | Changes |\n");
219 output.push_str("|------|------|:----:|--------:|\n");
220 for change in test_changes {
221 write_change_row(&mut output, change);
222 }
223 output.push_str("\n</details>\n");
224 }
225 }
226
227 format_scope_section(&mut output, result);
228
229 output.push_str("\n---\n");
230 output.push_str(
231 "<sub>[Rust Diff Analyzer](https://github.com/RAprogramm/rust-prod-diff-checker)</sub>\n",
232 );
233
234 output
235}
236
237fn write_change_row(output: &mut String, change: &Change) {
238 let span = &change.unit.span;
239 let _ = writeln!(
240 output,
241 "| `{}:{}-{}` | `{}` | {} | +{} -{} |",
242 escape_cell(&change.file_path.display().to_string()),
243 span.start,
244 span.end,
245 escape_cell(&change.unit.qualified_name()),
246 change.unit.kind.as_str(),
247 change.lines_added,
248 change.lines_removed
249 );
250}
251
252fn format_scope_section(output: &mut String, result: &AnalysisResult) {
253 let scope = &result.scope;
254
255 if scope.analyzed_files.is_empty()
256 && scope.skipped_files.is_empty()
257 && scope.exclusion_patterns.is_empty()
258 {
259 return;
260 }
261
262 output.push_str("\n<details>\n");
263 output.push_str("<summary>Analysis Scope</summary>\n\n");
264
265 if !scope.analyzed_files.is_empty() {
266 let _ = writeln!(
267 output,
268 "**Analyzed:** {} Rust files\n",
269 scope.analyzed_files.len()
270 );
271 }
272
273 if !scope.exclusion_patterns.is_empty() {
274 output.push_str("**Excluded patterns:**\n");
275 for pattern in &scope.exclusion_patterns {
276 let _ = writeln!(output, "- `{}`", escape_cell(pattern));
277 }
278 output.push('\n');
279 }
280
281 let non_rust = scope.non_rust_count();
282 let ignored = scope.ignored_count();
283 let deleted = scope.deleted_count();
284 let errored = scope.error_count();
285
286 if non_rust > 0 || ignored > 0 || deleted > 0 || errored > 0 {
287 output.push_str("**Skipped files:**\n");
288 if non_rust > 0 {
289 let _ = writeln!(output, "- {} non-Rust files", non_rust);
290 }
291 if ignored > 0 {
292 let _ = writeln!(output, "- {} files matched ignore patterns", ignored);
293 }
294 if deleted > 0 {
295 let _ = writeln!(output, "- {} deleted files", deleted);
296 }
297 if errored > 0 {
298 let _ = writeln!(output, "- {} files with read/parse errors", errored);
299 }
300 output.push('\n');
301 }
302
303 if !scope.skipped_files.is_empty() {
304 output.push_str("**Skipped file list:**\n");
305 for skipped in scope.skipped_files.iter().take(MAX_SKIPPED_LISTED) {
306 let reason = match &skipped.reason {
307 ExclusionReason::NonRust => "non-Rust".to_string(),
308 ExclusionReason::IgnorePattern(p) => format!("pattern: {}", escape_cell(p)),
309 ExclusionReason::Deleted => "deleted".to_string(),
310 ExclusionReason::ReadError(e) => format!("read error: {}", escape_cell(e)),
311 ExclusionReason::ParseError(e) => format!("parse error: {}", escape_cell(e)),
312 };
313 let _ = writeln!(
314 output,
315 "- `{}` ({})",
316 escape_cell(&skipped.path.display().to_string()),
317 reason
318 );
319 }
320 if scope.skipped_files.len() > MAX_SKIPPED_LISTED {
321 let _ = writeln!(
322 output,
323 "- …and {} more",
324 scope.skipped_files.len() - MAX_SKIPPED_LISTED
325 );
326 }
327 output.push('\n');
328 }
329
330 output.push_str("</details>\n");
331}
332
333pub fn get_comment_marker() -> &'static str {
348 COMMENT_MARKER
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::types::{AnalysisScope, Summary};
355
356 #[test]
357 fn test_format_comment() {
358 let result = AnalysisResult::new(vec![], Summary::default(), AnalysisScope::new());
359 let config = Config::default();
360 let output = format_comment(&result, &config);
361
362 assert!(output.contains(COMMENT_MARKER));
363 assert!(output.contains("Rust Diff Analysis"));
364 assert!(output.contains("Production"));
365 assert!(output.contains("Test"));
366 }
367
368 #[test]
369 fn test_format_comment_with_exceeded_limit() {
370 let summary = Summary {
371 exceeds_limit: true,
372 ..Default::default()
373 };
374 let result = AnalysisResult::new(vec![], summary, AnalysisScope::new());
375 let config = Config::default();
376 let output = format_comment(&result, &config);
377
378 assert!(output.contains("[!CAUTION]"));
379 assert!(output.contains("PR exceeds configured limits"));
380 }
381
382 #[test]
383 fn test_get_comment_marker() {
384 let marker = get_comment_marker();
385 assert!(marker.contains("rust-diff-analyzer"));
386 }
387
388 #[test]
389 fn test_escape_cell() {
390 assert_eq!(escape_cell("plain/path.rs"), "plain/path.rs");
391 assert_eq!(escape_cell("a|b.rs"), "a\\|b.rs");
392 assert_eq!(escape_cell("a`b.rs"), "a'b.rs");
393 }
394
395 #[test]
396 fn test_per_type_limit_reason_listed() {
397 use std::path::PathBuf;
398
399 use crate::{
400 config::PerTypeLimits,
401 types::{Change, CodeType, LineSpan, SemanticUnit, SemanticUnitKind, Visibility},
402 };
403
404 let make_change = || {
405 Change::new(
406 PathBuf::from("src/lib.rs"),
407 SemanticUnit::new(
408 SemanticUnitKind::Function,
409 "f".to_string(),
410 Visibility::Public,
411 LineSpan::new(1, 3),
412 vec![],
413 ),
414 CodeType::Production,
415 1,
416 0,
417 )
418 };
419
420 let mut config = Config::default();
421 config.limits.per_type = Some(PerTypeLimits {
422 functions: Some(1),
423 ..PerTypeLimits::default()
424 });
425
426 let summary = Summary {
427 exceeds_limit: true,
428 ..Default::default()
429 };
430 let result = AnalysisResult::new(
431 vec![make_change(), make_change()],
432 summary,
433 AnalysisScope::new(),
434 );
435 let output = format_comment(&result, &config);
436
437 assert!(output.contains("changed units of type `function` (limit: 1)"));
438 }
439
440 #[test]
441 fn test_skipped_file_list_truncated() {
442 use std::path::PathBuf;
443
444 let mut scope = AnalysisScope::new();
445 for i in 0..15 {
446 scope.add_skipped(PathBuf::from(format!("file{}.txt", i)), {
447 ExclusionReason::NonRust
448 });
449 }
450 let result = AnalysisResult::new(vec![], Summary::default(), scope);
451 let config = Config::default();
452 let output = format_comment(&result, &config);
453
454 assert!(output.contains("…and 5 more"));
455 assert!(output.contains("`file0.txt`"));
456 assert!(!output.contains("`file14.txt`"));
457 }
458}