Skip to main content

rust_diff_analyzer/output/
comment.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::fmt::Write;
5
6use crate::{
7    classifier::rules::exceeded_per_type_limits,
8    config::Config,
9    types::{AnalysisResult, Change, ExclusionReason},
10};
11
12/// Escapes text for a markdown table cell rendered as inline code
13///
14/// A pipe would break the table row and a backtick would terminate the code
15/// span, so both are replaced with safe stand-ins.
16fn escape_cell(text: &str) -> String {
17    text.replace('|', "\\|").replace('`', "'")
18}
19
20/// Returns the status icon for a limit comparison
21fn 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
29/// Formats analysis result as a markdown PR comment
30///
31/// # Arguments
32///
33/// * `result` - Analysis result to format
34/// * `config` - Configuration for formatting
35///
36/// # Returns
37///
38/// Formatted markdown string for PR comment
39///
40/// # Examples
41///
42/// ```
43/// use rust_diff_analyzer::{
44///     config::Config,
45///     output::comment::format_comment,
46///     types::{AnalysisResult, AnalysisScope, Summary},
47/// };
48///
49/// let result = AnalysisResult::new(vec![], Summary::default(), AnalysisScope::new());
50/// let config = Config::default();
51/// let output = format_comment(&result, &config);
52/// assert!(output.contains("Rust Diff Analysis"));
53/// ```
54pub 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
333/// Returns the comment marker for finding existing comments
334///
335/// # Returns
336///
337/// The marker string used to identify analyzer comments
338///
339/// # Examples
340///
341/// ```
342/// use rust_diff_analyzer::output::comment::get_comment_marker;
343///
344/// let marker = get_comment_marker();
345/// assert!(marker.contains("rust-diff-analyzer"));
346/// ```
347pub 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}