Skip to main content

cargo_sonar/
sonar.rs

1use crate::Issue as _;
2use std::collections::HashMap;
3
4#[derive(Debug, serde::Serialize, serde::Deserialize)]
5#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
6// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
7#[allow(dead_code)]
8#[non_exhaustive]
9pub enum Severity {
10    High,
11    Medium,
12    Low,
13}
14
15impl From<crate::Severity> for Severity {
16    #[inline]
17    fn from(severity: crate::Severity) -> Self {
18        match severity {
19            crate::Severity::Blocker | crate::Severity::Critical => Self::High,
20            crate::Severity::Major => Self::Medium,
21            crate::Severity::Minor | crate::Severity::Info => Self::Low,
22        }
23    }
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct TextRange {
29    pub start_line: usize,
30    pub end_line: usize,
31    pub start_column: usize,
32    pub end_column: usize,
33}
34
35impl TextRange {
36    #[inline]
37    #[must_use]
38    pub fn new(
39        (start_line, start_column): (usize, usize),
40        (end_line, end_column): (usize, usize),
41    ) -> Self {
42        Self {
43            start_line,
44            end_line,
45            start_column,
46            // sonar-scanner needs a range from at least one character
47            end_column: end_column.saturating_add(usize::from(
48                start_line == end_line && start_column == end_column,
49            )),
50        }
51    }
52}
53
54impl From<crate::TextRange> for TextRange {
55    #[inline]
56    fn from(range: crate::TextRange) -> Self {
57        Self::new(
58            (range.start.line, range.start.column),
59            (range.end.line, range.end.column),
60        )
61    }
62}
63
64#[derive(Debug, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Location {
67    pub message: String,
68    pub file_path: String,
69    pub text_range: TextRange,
70}
71
72impl From<crate::Location> for Location {
73    #[inline]
74    fn from(location: crate::Location) -> Self {
75        Self {
76            message: location.message,
77            file_path: location.path.to_string_lossy().into_owned(),
78            text_range: TextRange::from(location.range),
79        }
80    }
81}
82
83// See https://docs.sonarcloud.io/improving/clean-code/
84#[derive(Debug, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
86// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
87#[allow(dead_code)]
88#[non_exhaustive]
89pub enum CleanCodeAttribute {
90    // Consistent
91    Formatted,
92    Conventional,
93    Identifiable,
94    // Intentional
95    Clear,
96    Logical,
97    Complete,
98    Efficient,
99    // Adaptable
100    Focused,
101    Distinct,
102    Modular,
103    Tested,
104    // Responsible
105    Lawful,
106    Trustworthy,
107    Respectful,
108}
109
110// See https://docs.sonarcloud.io/improving/clean-code/
111#[derive(Debug, serde::Serialize, serde::Deserialize)]
112#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
113// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
114#[allow(dead_code)]
115#[non_exhaustive]
116pub enum SoftwareQuality {
117    Maintainability,
118    Reliability,
119    Security,
120}
121
122impl From<crate::Category> for SoftwareQuality {
123    #[inline]
124    fn from(category: crate::Category) -> Self {
125        match category {
126            crate::Category::Bug | crate::Category::Performance => SoftwareQuality::Reliability,
127            crate::Category::Complexity | crate::Category::Duplication | crate::Category::Style => {
128                SoftwareQuality::Maintainability
129            }
130            crate::Category::Security => SoftwareQuality::Security,
131        }
132    }
133}
134
135#[derive(Debug, serde::Serialize, serde::Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct Impact {
138    pub software_quality: SoftwareQuality,
139    pub severity: Severity,
140}
141
142#[derive(Debug, serde::Serialize, serde::Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct Rule {
145    pub id: String,
146    pub name: String,
147    pub description: String,
148    pub engine_id: String,
149    pub clean_code_attribute: CleanCodeAttribute,
150    pub impacts: Vec<Impact>,
151}
152
153#[derive(Debug, serde::Serialize, serde::Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct Issue {
156    pub rule_id: String,
157    pub primary_location: Location,
158    pub secondary_locations: Vec<Location>,
159}
160
161#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
162pub struct Issues {
163    rules: Vec<Rule>,
164    issues: Vec<Issue>,
165}
166
167impl Issues {
168    /// Give the number of generated issues
169    #[must_use]
170    #[inline]
171    pub fn len(&self) -> usize {
172        self.issues.len()
173    }
174
175    /// Returns if there is no issues
176    #[must_use]
177    #[inline]
178    pub fn is_empty(&self) -> bool {
179        self.issues.is_empty()
180    }
181}
182
183#[derive(Debug)]
184pub struct Sonar;
185
186impl<'c> crate::FromIssues<'c> for Sonar {
187    type Report = Issues;
188
189    fn from_issues(issues: impl IntoIterator<Item = crate::IssueType<'c>>) -> Self::Report {
190        let (rules, issues) = issues.into_iter().fold(
191            (HashMap::new(), Vec::new()),
192            |(mut rules, mut issues), issue| {
193                let Some(location) = issue.location() else {
194                    return (rules, issues);
195                };
196                rules.entry(issue.issue_uid()).or_insert_with(|| Rule {
197                    id: issue.issue_uid(),
198                    name: issue.issue_id(),
199                    description: location.message.clone(),
200                    engine_id: issue.analyzer_id(),
201                    clean_code_attribute: CleanCodeAttribute::Clear,
202                    impacts: vec![Impact {
203                        software_quality: SoftwareQuality::from(issue.category()),
204                        severity: Severity::from(issue.severity()),
205                    }],
206                });
207                issues.push(Issue {
208                    rule_id: issue.issue_uid(),
209                    primary_location: Location::from(location),
210                    secondary_locations: issue
211                        .other_locations()
212                        .into_iter()
213                        .map(Location::from)
214                        .collect(),
215                });
216                (rules, issues)
217            },
218        );
219        Self::Report {
220            rules: rules.into_values().collect(),
221            issues,
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::{Severity, Sonar};
229    use crate::{
230        sonar::{CleanCodeAttribute, SoftwareQuality},
231        test::Issue,
232        FromIssues as _, IssueType,
233    };
234    use test_log::test;
235
236    #[test]
237    fn simple_issue() {
238        let issues = Sonar::from_issues(vec![IssueType::Test(Issue)]);
239        assert_eq!(issues.rules.len(), 1);
240        let rule = &issues.rules.first().unwrap();
241        assert_eq!(rule.id, "fake::fake_issue");
242        assert_eq!(rule.engine_id, "fake");
243        assert_eq!(rule.name, "fake_issue");
244        assert_eq!(rule.description, "this issue is bad!");
245        assert!(matches!(
246            rule.clean_code_attribute,
247            CleanCodeAttribute::Clear
248        ));
249        assert_eq!(rule.impacts.len(), 1);
250        let impact = &rule.impacts.first().unwrap();
251        assert!(matches!(
252            impact.software_quality,
253            SoftwareQuality::Maintainability
254        ));
255        assert!(matches!(impact.severity, Severity::Low));
256        assert_eq!(issues.issues.len(), 1);
257        let issue = &issues.issues.first().unwrap();
258        assert_eq!(issue.rule_id, "fake::fake_issue");
259        assert_eq!(issue.primary_location.message, "this issue is bad!");
260        assert_eq!(issue.primary_location.file_path, "Cargo.lock");
261        assert_eq!(issue.primary_location.text_range.start_line, 1);
262        assert_eq!(issue.primary_location.text_range.end_line, 2);
263        assert_eq!(issue.primary_location.text_range.start_column, 1);
264        assert_eq!(issue.primary_location.text_range.end_column, 42);
265        assert!(issue.secondary_locations.is_empty());
266    }
267}