Skip to main content

fallow_output/
sarif.rs

1use std::path::{Path, PathBuf};
2
3use fallow_types::identity::IdentifiedFinding;
4use rustc_hash::FxHashMap;
5use serde_json::Value;
6
7use crate::codeclimate::codeclimate_fingerprint_hash;
8
9/// Fingerprint key used in SARIF partialFingerprints and other CI formats.
10pub const SARIF_FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
11
12/// Conventional SARIF key consumed by GitHub Code Scanning.
13pub const GHAS_SARIF_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
14
15/// `partialFingerprints` key that holds the stable dead-code `finding_id`.
16///
17/// Unlike the two location-based keys, the value does not depend on the line,
18/// the column or the source text. The uniqueness pass never rewrites it.
19pub const SARIF_FINDING_ID_KEY: &str = "fallowFinding/v1";
20
21/// Fields needed to build one SARIF result object.
22#[derive(Debug, Clone, Copy)]
23pub struct SarifResultInput<'a> {
24    /// SARIF rule id the result references, e.g. `fallow/unused-file`.
25    pub rule_id: &'a str,
26    /// SARIF level: `error`, `warning`, or `note`.
27    pub level: &'a str,
28    /// Human-readable result message text.
29    pub message: &'a str,
30    /// Artifact URI relative to the analysed root.
31    pub uri: &'a str,
32    /// 1-based `(start_line, start_column)` region, when known.
33    pub region: Option<(u32, u32)>,
34    /// Source snippet that feeds the stable fingerprint and region context.
35    pub snippet: Option<&'a str>,
36}
37
38/// Normalized finding input for output-owned SARIF result assembly.
39#[derive(Debug, Clone)]
40pub struct SarifFindingInput<'a> {
41    /// Fallow issue code the finding originated from, e.g. `unused-file`.
42    pub issue_code: &'a str,
43    /// SARIF rule id the result references.
44    pub rule_id: &'a str,
45    /// SARIF level: `error`, `warning`, or `note`.
46    pub level: &'a str,
47    /// Human-readable result message text.
48    pub message: &'a str,
49    /// Artifact URI relative to the analysed root.
50    pub uri: &'a str,
51    /// 1-based `(start_line, start_column)` region, when known.
52    pub region: Option<(u32, u32)>,
53    /// Source snippet that feeds the stable fingerprint and region context.
54    pub snippet: Option<&'a str>,
55    /// Extra `properties` bag copied onto the SARIF result verbatim.
56    pub properties: Option<Value>,
57    /// Stable dead-code `finding_id`, written under [`SARIF_FINDING_ID_KEY`].
58    /// `None` omits the key.
59    pub finding_id: Option<&'a str>,
60}
61
62/// Intermediate fields extracted from one issue for SARIF result construction.
63#[derive(Debug, Clone)]
64pub struct SarifFindingFields {
65    /// SARIF rule id the result references.
66    pub rule_id: &'static str,
67    /// SARIF level: `error`, `warning`, or `note`.
68    pub level: &'static str,
69    /// Human-readable result message text.
70    pub message: String,
71    /// Artifact URI relative to the analysed root.
72    pub uri: String,
73    /// 1-based `(start_line, start_column)` region, when known.
74    pub region: Option<(u32, u32)>,
75    /// Absolute source path used to load the fingerprint snippet.
76    pub source_path: Option<PathBuf>,
77    /// Extra `properties` bag copied onto the SARIF result verbatim.
78    pub properties: Option<Value>,
79}
80
81/// Fields needed to build one SARIF rule object.
82#[derive(Debug, Clone, Copy)]
83pub struct SarifRuleInput<'a> {
84    /// SARIF rule id, e.g. `fallow/unused-file`.
85    pub id: &'a str,
86    /// One-line rule description shown in SARIF viewers.
87    pub short_description: &'a str,
88    /// Default SARIF level for the rule's `defaultConfiguration`.
89    pub level: &'a str,
90    /// Longer rule description, when the rule has one.
91    pub full_description: Option<&'a str>,
92    /// Public documentation URL for the rule.
93    pub help_uri: Option<&'a str>,
94}
95
96/// Fields needed to build a SARIF document envelope.
97#[derive(Debug, Clone, Copy)]
98pub struct SarifDocumentInput<'a> {
99    /// Pre-built SARIF result objects for the single run.
100    pub results: &'a [Value],
101    /// Pre-built tool-driver rule objects for the single run.
102    pub rules: &'a [Value],
103    /// Fallow version reported as the SARIF tool driver version.
104    pub tool_version: &'a str,
105}
106
107/// Normalize a source snippet before it contributes to stable SARIF identity.
108#[must_use]
109pub fn normalize_sarif_snippet(snippet: &str) -> String {
110    snippet
111        .lines()
112        .map(str::trim)
113        .filter(|line| !line.is_empty())
114        .collect::<Vec<_>>()
115        .join("\n")
116}
117
118/// Stable SARIF fingerprint for a finding with source snippet evidence.
119///
120/// `col` is the 1-based start column the finding reports, and is what separates
121/// two findings of the same rule that share a source line.
122#[must_use]
123pub fn sarif_finding_fingerprint(rule_id: &str, path: &str, snippet: &str, col: u32) -> String {
124    let normalized = normalize_sarif_snippet(snippet);
125    codeclimate_fingerprint_hash(&[rule_id, path, &normalized, &col.to_string()])
126}
127
128/// Lazily reads source files so SARIF result builders can attach stable line snippets.
129#[derive(Debug, Default)]
130pub struct SarifSourceSnippetCache {
131    root: Option<PathBuf>,
132    files: FxHashMap<PathBuf, Vec<String>>,
133}
134
135impl SarifSourceSnippetCache {
136    /// Create a snippet cache that resolves relative finding paths against the
137    /// analyzed project root.
138    #[must_use]
139    pub fn with_root(root: &Path) -> Self {
140        Self {
141            root: Some(root.to_path_buf()),
142            files: FxHashMap::default(),
143        }
144    }
145
146    /// Return the 1-based source line from a file, caching the file contents.
147    pub fn line(&mut self, path: &Path, line: u32) -> Option<String> {
148        if line == 0 {
149            return None;
150        }
151        let resolved = if path.is_relative() {
152            self.root
153                .as_deref()
154                .map_or_else(|| path.to_path_buf(), |root| root.join(path))
155        } else {
156            path.to_path_buf()
157        };
158        if !self.files.contains_key(&resolved) {
159            let lines = std::fs::read_to_string(&resolved)
160                .ok()
161                .map(|source| source.lines().map(str::to_owned).collect())
162                .unwrap_or_default();
163            self.files.insert(resolved.clone(), lines);
164        }
165        self.files
166            .get(&resolved)
167            .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
168            .cloned()
169    }
170}
171
172/// Build a single SARIF result object.
173///
174/// When `region` is `Some((line, col))`, a `region` block with 1-based
175/// `startLine` and `startColumn` is included in the physical location.
176#[must_use]
177pub fn build_sarif_result(input: SarifResultInput<'_>) -> Value {
178    let mut physical_location = serde_json::json!({
179        "artifactLocation": { "uri": input.uri }
180    });
181    if let Some((line, col)) = input.region {
182        physical_location["region"] = serde_json::json!({
183            "startLine": line,
184            "startColumn": col
185        });
186    }
187    let line = input
188        .region
189        .map_or_else(String::new, |(line, _)| line.to_string());
190    let col = input
191        .region
192        .map_or_else(String::new, |(_, col)| col.to_string());
193    let normalized_snippet = input
194        .snippet
195        .map(normalize_sarif_snippet)
196        .filter(|snippet| !snippet.is_empty());
197    // The snippet replaces the LINE, which moves under any edit above it, and
198    // not the COLUMN, which is as stable as the snippet itself: two findings on
199    // one line are two alerts, and GitHub code scanning treats one
200    // `partialFingerprints` value as one alert identity. Dropping the column
201    // here collapsed every re-export in a one-line barrel, every member of a
202    // one-line enum, and every dependency in a compact `package.json` into a
203    // single alert.
204    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
205        || codeclimate_fingerprint_hash(&[input.rule_id, input.uri, &line, &col]),
206        |snippet| codeclimate_fingerprint_hash(&[input.rule_id, input.uri, snippet, &col]),
207    );
208    let partial_fingerprint_ghas = partial_fingerprint.clone();
209    let locations = if input.uri.is_empty() {
210        Vec::new()
211    } else {
212        vec![serde_json::json!({ "physicalLocation": physical_location })]
213    };
214    serde_json::json!({
215        "ruleId": input.rule_id,
216        "level": input.level,
217        "message": { "text": input.message },
218        "locations": locations,
219        "partialFingerprints": {
220            SARIF_FINGERPRINT_KEY: partial_fingerprint,
221            GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
222        }
223    })
224}
225
226/// Build a SARIF result from a normalized finding.
227#[must_use]
228pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
229    let mut result = build_sarif_result(SarifResultInput {
230        rule_id: input.rule_id,
231        level: input.level,
232        message: input.message,
233        uri: input.uri,
234        region: input.region,
235        snippet: input.snippet,
236    });
237    if let Some(finding_id) = input.finding_id {
238        result["partialFingerprints"][SARIF_FINDING_ID_KEY] = Value::from(finding_id);
239    }
240    if let Some(properties) = input.properties {
241        result["properties"] = properties;
242    }
243    result
244}
245
246/// Build a single SARIF result object with optional source snippet evidence.
247#[must_use]
248pub fn build_sarif_result_with_snippet(
249    rule_id: &str,
250    level: &str,
251    message: &str,
252    uri: &str,
253    region: Option<(u32, u32)>,
254    snippet: Option<&str>,
255) -> Value {
256    build_sarif_result(SarifResultInput {
257        rule_id,
258        level,
259        message,
260        uri,
261        region,
262        snippet,
263    })
264}
265
266/// Append SARIF findings by extracting normalized fields from typed issues.
267///
268/// Each item gives exactly one result, so the `finding_id` of the item becomes
269/// the [`SARIF_FINDING_ID_KEY`] of its result.
270pub fn append_sarif_findings<T: IdentifiedFinding>(
271    sarif_results: &mut Vec<Value>,
272    items: &[T],
273    snippets: &mut SarifSourceSnippetCache,
274    mut extract: impl FnMut(&T) -> SarifFindingFields,
275) {
276    for item in items {
277        let fields = extract(item);
278        let source_snippet = fields
279            .source_path
280            .as_deref()
281            .zip(fields.region)
282            .and_then(|(path, (line, _))| snippets.line(path, line));
283        let result = build_sarif_finding(SarifFindingInput {
284            issue_code: issue_code_from_rule_id(fields.rule_id),
285            rule_id: fields.rule_id,
286            level: fields.level,
287            message: &fields.message,
288            uri: &fields.uri,
289            region: fields.region,
290            snippet: source_snippet.as_deref(),
291            properties: fields.properties,
292            finding_id: item.finding_id(),
293        });
294        sarif_results.push(result);
295    }
296}
297
298/// Give every result in one run its own `partialFingerprints` value.
299///
300/// GitHub code scanning treats that value as alert identity, so two results
301/// sharing one are one alert and the second finding is never surfaced. Rule id,
302/// URI, snippet, and column already separate findings that differ anywhere a
303/// reader can see; what is left is a file that reports the same rule twice with
304/// byte-identical evidence, such as the same declaration written twice. The
305/// first occurrence keeps the value it computed, so an alert that already exists
306/// is never disturbed, and each repeat mixes in its occurrence index.
307pub fn ensure_unique_result_fingerprints(results: &mut [Value]) {
308    let mut occurrences: FxHashMap<String, u32> = FxHashMap::default();
309    for result in results {
310        let Some(fingerprint) = result
311            .get("partialFingerprints")
312            .and_then(|prints| prints.get(SARIF_FINGERPRINT_KEY))
313            .and_then(Value::as_str)
314            .map(str::to_owned)
315        else {
316            continue;
317        };
318        let occurrence = occurrences.entry(fingerprint.clone()).or_insert(0);
319        let index = *occurrence;
320        *occurrence += 1;
321        if index == 0 {
322            continue;
323        }
324        let unique = codeclimate_fingerprint_hash(&[&fingerprint, &index.to_string()]);
325        result["partialFingerprints"][SARIF_FINGERPRINT_KEY] = Value::from(unique.clone());
326        result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY] = Value::from(unique);
327    }
328}
329
330/// Build a SARIF rule object.
331#[must_use]
332pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
333    let mut rule = serde_json::Map::new();
334    rule.insert("id".to_string(), serde_json::json!(input.id));
335    rule.insert(
336        "shortDescription".to_string(),
337        serde_json::json!({ "text": input.short_description }),
338    );
339    if let Some(full_description) = input.full_description {
340        rule.insert(
341            "fullDescription".to_string(),
342            serde_json::json!({ "text": full_description }),
343        );
344    }
345    if let Some(help_uri) = input.help_uri {
346        rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
347    }
348    rule.insert(
349        "defaultConfiguration".to_string(),
350        serde_json::json!({ "level": input.level }),
351    );
352    Value::Object(rule)
353}
354
355fn issue_code_from_rule_id(rule_id: &str) -> &str {
356    rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
357}
358
359/// Build a SARIF 2.1.0 document envelope.
360///
361/// Applies [`ensure_unique_result_fingerprints`], so every run this builds
362/// satisfies the one-alert-per-finding property. A caller that replaces
363/// `/runs/0/results` afterwards has to apply it again.
364#[must_use]
365pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
366    let mut results = input.results.to_vec();
367    ensure_unique_result_fingerprints(&mut results);
368    serde_json::json!({
369        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
370        "version": "2.1.0",
371        "runs": [{
372            "tool": {
373                "driver": {
374                    "name": "fallow",
375                    "version": input.tool_version,
376                    "informationUri": "https://github.com/fallow-rs/fallow",
377                    "rules": input.rules
378                }
379            },
380            "results": results
381        }]
382    })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn sarif_result_includes_location_and_fingerprints() {
391        let result = build_sarif_result(SarifResultInput {
392            rule_id: "fallow/test",
393            level: "warning",
394            message: "description",
395            uri: "src/app.ts",
396            region: Some((7, 3)),
397            snippet: Some("  export const value = 1;  "),
398        });
399
400        assert_eq!(result["ruleId"], "fallow/test");
401        assert_eq!(
402            result["locations"][0]["physicalLocation"]["region"]["startLine"],
403            7
404        );
405        assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
406        assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
407    }
408
409    fn fingerprint_of(result: &Value) -> &str {
410        result["partialFingerprints"][SARIF_FINGERPRINT_KEY]
411            .as_str()
412            .expect("fingerprint")
413    }
414
415    /// A one-line re-export barrel, a one-line enum, and a compact
416    /// `package.json` all put two findings of one rule on one source line, so
417    /// the snippet is identical and only the column tells them apart. GitHub
418    /// code scanning keys alert identity on this value, so a shared value is a
419    /// lost alert.
420    #[test]
421    fn two_findings_on_one_line_get_different_fingerprints() {
422        let at_column = |col: u32| {
423            build_sarif_result(SarifResultInput {
424                rule_id: "fallow/unused-export",
425                level: "warning",
426                message: "Re-export is never imported by other modules",
427                uri: "src/barrel.ts",
428                region: Some((1, col)),
429                snippet: Some("export { alpha, beta } from './m';"),
430            })
431        };
432
433        assert_ne!(
434            fingerprint_of(&at_column(10)),
435            fingerprint_of(&at_column(17))
436        );
437    }
438
439    /// The column is the only position in the fingerprint: a snippet that
440    /// survives an edit above it has to keep its identity, or every open alert
441    /// on the file below the edit closes and reopens.
442    #[test]
443    fn moving_a_finding_to_another_line_keeps_its_fingerprint() {
444        let at_line = |line: u32| {
445            build_sarif_result(SarifResultInput {
446                rule_id: "fallow/unused-export",
447                level: "warning",
448                message: "Export is never imported by other modules",
449                uri: "src/lib.ts",
450                region: Some((line, 14)),
451                snippet: Some("export const alpha = 1;"),
452            })
453        };
454
455        assert_eq!(fingerprint_of(&at_line(3)), fingerprint_of(&at_line(41)));
456    }
457
458    /// Two byte-identical declarations in one file leave the snippet and the
459    /// column identical, so position alone cannot separate them.
460    #[test]
461    fn identical_results_are_separated_by_occurrence() {
462        let result = || {
463            build_sarif_result(SarifResultInput {
464                rule_id: "fallow/duplicate-export",
465                level: "warning",
466                message: "Export 'Video' appears in multiple modules",
467                uri: "src/types.ts",
468                region: Some((309, 18)),
469                snippet: Some("export type Video = {"),
470            })
471        };
472        let mut results = vec![result(), result(), result()];
473        let first_before = fingerprint_of(&results[0]).to_owned();
474
475        ensure_unique_result_fingerprints(&mut results);
476
477        assert_eq!(
478            fingerprint_of(&results[0]),
479            first_before,
480            "the first occurrence keeps the identity an existing alert was opened under"
481        );
482        let unique: std::collections::BTreeSet<&str> = results.iter().map(fingerprint_of).collect();
483        assert_eq!(unique.len(), 3, "{results:?}");
484        for result in &results {
485            assert_eq!(
486                result["partialFingerprints"][SARIF_FINGERPRINT_KEY],
487                result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY],
488                "both keys name the same alert"
489            );
490        }
491    }
492
493    /// Finding ids are unique by construction, so the uniqueness pass rewrites
494    /// only the two location-based keys and keeps each `finding_id` key.
495    #[test]
496    fn the_uniqueness_pass_keeps_the_finding_id_key() {
497        let result = |finding_id: &str| {
498            build_sarif_finding(SarifFindingInput {
499                issue_code: "unused-class-member",
500                rule_id: "fallow/unused-class-member",
501                level: "warning",
502                message: "Class member 'run' is never used",
503                uri: "src/service.ts",
504                region: Some((4, 3)),
505                snippet: Some("run() {}"),
506                properties: None,
507                finding_id: Some(finding_id),
508            })
509        };
510        let ids = [
511            "dc1:unused-class-member:0123456789abcdef",
512            "dc1:unused-class-member:0123456789abcdef~1",
513        ];
514        let mut results = ids.map(result).to_vec();
515
516        ensure_unique_result_fingerprints(&mut results);
517
518        assert_ne!(
519            fingerprint_of(&results[0]),
520            fingerprint_of(&results[1]),
521            "the pass must still separate the location-based keys"
522        );
523        for (result, id) in results.iter().zip(ids) {
524            assert_eq!(result["partialFingerprints"][SARIF_FINDING_ID_KEY], id);
525        }
526    }
527
528    /// The `finding_id` key is additive: every other byte of the result stays
529    /// the same, so GitHub code scanning keeps each existing alert.
530    #[test]
531    fn a_finding_id_adds_one_key_and_keeps_the_rest() {
532        let result = |finding_id: Option<&str>| {
533            build_sarif_finding(SarifFindingInput {
534                issue_code: "unused-export",
535                rule_id: "fallow/unused-export",
536                level: "warning",
537                message: "Export 'helper' is never imported by other modules",
538                uri: "src/utils.ts",
539                region: Some((3, 14)),
540                snippet: Some("export const helper = 1;"),
541                properties: None,
542                finding_id,
543            })
544        };
545        let without = result(None);
546        let mut with = result(Some("dc1:unused-export:0123456789abcdef"));
547
548        assert!(
549            without["partialFingerprints"]
550                .get(SARIF_FINDING_ID_KEY)
551                .is_none()
552        );
553        let removed = with["partialFingerprints"]
554            .as_object_mut()
555            .and_then(|prints| prints.remove(SARIF_FINDING_ID_KEY));
556        assert_eq!(
557            removed,
558            Some(Value::from("dc1:unused-export:0123456789abcdef"))
559        );
560        assert_eq!(with, without);
561    }
562
563    /// A run whose results already differ must come out byte-identical, so the
564    /// pass never churns an alert that was already unique.
565    #[test]
566    fn distinct_results_are_left_alone() {
567        let mut results = vec![
568            build_sarif_result(SarifResultInput {
569                rule_id: "fallow/unused-export",
570                level: "warning",
571                message: "Export 'alpha' is never imported by other modules",
572                uri: "src/lib.ts",
573                region: Some((1, 14)),
574                snippet: Some("export const alpha = 1;"),
575            }),
576            build_sarif_result(SarifResultInput {
577                rule_id: "fallow/unused-export",
578                level: "warning",
579                message: "Export 'beta' is never imported by other modules",
580                uri: "src/lib.ts",
581                region: Some((2, 14)),
582                snippet: Some("export const beta = 2;"),
583            }),
584        ];
585        let before = results.clone();
586
587        ensure_unique_result_fingerprints(&mut results);
588
589        assert_eq!(results, before);
590    }
591
592    #[test]
593    fn sarif_finding_includes_custom_properties() {
594        let finding = build_sarif_finding(SarifFindingInput {
595            issue_code: "unused-export",
596            rule_id: "fallow/unused-export",
597            level: "warning",
598            message: "Export is never imported",
599            uri: "src/app.ts",
600            region: Some((3, 14)),
601            snippet: Some("export const unused = 1;"),
602            properties: Some(serde_json::json!({ "is_re_export": true })),
603            finding_id: None,
604        });
605
606        assert_eq!(finding["ruleId"], "fallow/unused-export");
607        assert_eq!(finding["properties"]["is_re_export"], true);
608        assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
609    }
610
611    #[test]
612    fn sarif_finding_omits_empty_properties() {
613        let finding = build_sarif_finding(SarifFindingInput {
614            issue_code: "unused-file",
615            rule_id: "fallow/unused-file",
616            level: "error",
617            message: "File is unreachable",
618            uri: "src/unused.ts",
619            region: None,
620            snippet: None,
621            properties: None,
622            finding_id: None,
623        });
624
625        assert!(finding.get("properties").is_none());
626    }
627
628    #[test]
629    fn append_sarif_findings_attaches_snippet_and_properties() {
630        let temp = tempfile::tempdir().expect("tempdir");
631        let source = temp.path().join("src.ts");
632        std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
633        let mut snippets = SarifSourceSnippetCache::default();
634        let mut results = Vec::new();
635        let mut finding = fallow_types::output_dead_code::UnusedFileFinding::with_actions(
636            fallow_types::results::UnusedFile { path: source },
637        );
638        finding.finding_id = Some("dc1:unused-file:0123456789abcdef".to_owned());
639
640        append_sarif_findings(
641            &mut results,
642            std::slice::from_ref(&finding),
643            &mut snippets,
644            |finding| SarifFindingFields {
645                rule_id: "fallow/unused-export",
646                level: "warning",
647                message: "Export is never imported".to_string(),
648                uri: "src.ts".to_string(),
649                region: Some((2, 1)),
650                source_path: Some(finding.file.path.clone()),
651                properties: Some(serde_json::json!({ "is_re_export": true })),
652            },
653        );
654
655        assert_eq!(results.len(), 1);
656        assert_eq!(results[0]["ruleId"], "fallow/unused-export");
657        assert_eq!(results[0]["properties"]["is_re_export"], true);
658        assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
659        assert_eq!(
660            results[0]["partialFingerprints"][SARIF_FINDING_ID_KEY],
661            "dc1:unused-file:0123456789abcdef"
662        );
663    }
664
665    #[test]
666    fn sarif_rule_omits_optional_docs_when_absent() {
667        let rule = build_sarif_rule(SarifRuleInput {
668            id: "fallow/test",
669            short_description: "short",
670            level: "warning",
671            full_description: None,
672            help_uri: None,
673        });
674
675        assert!(rule.get("fullDescription").is_none());
676        assert!(rule.get("helpUri").is_none());
677    }
678
679    #[test]
680    fn sarif_document_uses_supplied_version() {
681        let document = build_sarif_document(SarifDocumentInput {
682            results: &[],
683            rules: &[],
684            tool_version: "1.2.3",
685        });
686
687        assert_eq!(document["version"], "2.1.0");
688        assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
689    }
690}