rust_diff_analyzer/output/
comment.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use crate::{
5    config::Config,
6    types::{AnalysisResult, Change, ExclusionReason, SemanticUnitKind},
7};
8
9const COMMENT_MARKER: &str = "<!-- rust-diff-analyzer-comment -->";
10
11/// Formats analysis result as a markdown PR comment
12///
13/// # Arguments
14///
15/// * `result` - Analysis result to format
16/// * `config` - Configuration for formatting
17///
18/// # Returns
19///
20/// Formatted markdown string for PR comment
21///
22/// # Examples
23///
24/// ```
25/// use rust_diff_analyzer::{
26///     config::Config,
27///     output::comment::format_comment,
28///     types::{AnalysisResult, AnalysisScope, Summary},
29/// };
30///
31/// let result = AnalysisResult::new(vec![], Summary::default(), AnalysisScope::new());
32/// let config = Config::default();
33/// let output = format_comment(&result, &config);
34/// assert!(output.contains("Rust Diff Analysis"));
35/// ```
36pub 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    // Verdict at the top - most important info first
46    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    // Limits section - collapsible
86    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    // Summary section - collapsible
143    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    // Changed units - collapsible
175    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
313/// Returns the comment marker for finding existing comments
314///
315/// # Returns
316///
317/// The marker string used to identify analyzer comments
318///
319/// # Examples
320///
321/// ```
322/// use rust_diff_analyzer::output::comment::get_comment_marker;
323///
324/// let marker = get_comment_marker();
325/// assert!(marker.contains("rust-diff-analyzer"));
326/// ```
327pub 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}