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    serde_json::json!({
210        "ruleId": input.rule_id,
211        "level": input.level,
212        "message": { "text": input.message },
213        "locations": [{ "physicalLocation": physical_location }],
214        "partialFingerprints": {
215            SARIF_FINGERPRINT_KEY: partial_fingerprint,
216            GHAS_SARIF_FINGERPRINT_KEY: partial_fingerprint_ghas
217        }
218    })
219}
220
221/// Build a SARIF result from a normalized finding.
222#[must_use]
223pub fn build_sarif_finding(input: SarifFindingInput<'_>) -> Value {
224    let mut result = build_sarif_result(SarifResultInput {
225        rule_id: input.rule_id,
226        level: input.level,
227        message: input.message,
228        uri: input.uri,
229        region: input.region,
230        snippet: input.snippet,
231    });
232    if let Some(finding_id) = input.finding_id {
233        result["partialFingerprints"][SARIF_FINDING_ID_KEY] = Value::from(finding_id);
234    }
235    if let Some(properties) = input.properties {
236        result["properties"] = properties;
237    }
238    result
239}
240
241/// Build a single SARIF result object with optional source snippet evidence.
242#[must_use]
243pub fn build_sarif_result_with_snippet(
244    rule_id: &str,
245    level: &str,
246    message: &str,
247    uri: &str,
248    region: Option<(u32, u32)>,
249    snippet: Option<&str>,
250) -> Value {
251    build_sarif_result(SarifResultInput {
252        rule_id,
253        level,
254        message,
255        uri,
256        region,
257        snippet,
258    })
259}
260
261/// Append SARIF findings by extracting normalized fields from typed issues.
262///
263/// Each item gives exactly one result, so the `finding_id` of the item becomes
264/// the [`SARIF_FINDING_ID_KEY`] of its result.
265pub fn append_sarif_findings<T: IdentifiedFinding>(
266    sarif_results: &mut Vec<Value>,
267    items: &[T],
268    snippets: &mut SarifSourceSnippetCache,
269    mut extract: impl FnMut(&T) -> SarifFindingFields,
270) {
271    for item in items {
272        let fields = extract(item);
273        let source_snippet = fields
274            .source_path
275            .as_deref()
276            .zip(fields.region)
277            .and_then(|(path, (line, _))| snippets.line(path, line));
278        let result = build_sarif_finding(SarifFindingInput {
279            issue_code: issue_code_from_rule_id(fields.rule_id),
280            rule_id: fields.rule_id,
281            level: fields.level,
282            message: &fields.message,
283            uri: &fields.uri,
284            region: fields.region,
285            snippet: source_snippet.as_deref(),
286            properties: fields.properties,
287            finding_id: item.finding_id(),
288        });
289        sarif_results.push(result);
290    }
291}
292
293/// Give every result in one run its own `partialFingerprints` value.
294///
295/// GitHub code scanning treats that value as alert identity, so two results
296/// sharing one are one alert and the second finding is never surfaced. Rule id,
297/// URI, snippet, and column already separate findings that differ anywhere a
298/// reader can see; what is left is a file that reports the same rule twice with
299/// byte-identical evidence, such as the same declaration written twice. The
300/// first occurrence keeps the value it computed, so an alert that already exists
301/// is never disturbed, and each repeat mixes in its occurrence index.
302pub fn ensure_unique_result_fingerprints(results: &mut [Value]) {
303    let mut occurrences: FxHashMap<String, u32> = FxHashMap::default();
304    for result in results {
305        let Some(fingerprint) = result
306            .get("partialFingerprints")
307            .and_then(|prints| prints.get(SARIF_FINGERPRINT_KEY))
308            .and_then(Value::as_str)
309            .map(str::to_owned)
310        else {
311            continue;
312        };
313        let occurrence = occurrences.entry(fingerprint.clone()).or_insert(0);
314        let index = *occurrence;
315        *occurrence += 1;
316        if index == 0 {
317            continue;
318        }
319        let unique = codeclimate_fingerprint_hash(&[&fingerprint, &index.to_string()]);
320        result["partialFingerprints"][SARIF_FINGERPRINT_KEY] = Value::from(unique.clone());
321        result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY] = Value::from(unique);
322    }
323}
324
325/// Build a SARIF rule object.
326#[must_use]
327pub fn build_sarif_rule(input: SarifRuleInput<'_>) -> Value {
328    let mut rule = serde_json::Map::new();
329    rule.insert("id".to_string(), serde_json::json!(input.id));
330    rule.insert(
331        "shortDescription".to_string(),
332        serde_json::json!({ "text": input.short_description }),
333    );
334    if let Some(full_description) = input.full_description {
335        rule.insert(
336            "fullDescription".to_string(),
337            serde_json::json!({ "text": full_description }),
338        );
339    }
340    if let Some(help_uri) = input.help_uri {
341        rule.insert("helpUri".to_string(), serde_json::json!(help_uri));
342    }
343    rule.insert(
344        "defaultConfiguration".to_string(),
345        serde_json::json!({ "level": input.level }),
346    );
347    Value::Object(rule)
348}
349
350fn issue_code_from_rule_id(rule_id: &str) -> &str {
351    rule_id.strip_prefix("fallow/").unwrap_or(rule_id)
352}
353
354/// Build a SARIF 2.1.0 document envelope.
355///
356/// Applies [`ensure_unique_result_fingerprints`], so every run this builds
357/// satisfies the one-alert-per-finding property. A caller that replaces
358/// `/runs/0/results` afterwards has to apply it again.
359#[must_use]
360pub fn build_sarif_document(input: SarifDocumentInput<'_>) -> Value {
361    let mut results = input.results.to_vec();
362    ensure_unique_result_fingerprints(&mut results);
363    serde_json::json!({
364        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
365        "version": "2.1.0",
366        "runs": [{
367            "tool": {
368                "driver": {
369                    "name": "fallow",
370                    "version": input.tool_version,
371                    "informationUri": "https://github.com/fallow-rs/fallow",
372                    "rules": input.rules
373                }
374            },
375            "results": results
376        }]
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn sarif_result_includes_location_and_fingerprints() {
386        let result = build_sarif_result(SarifResultInput {
387            rule_id: "fallow/test",
388            level: "warning",
389            message: "description",
390            uri: "src/app.ts",
391            region: Some((7, 3)),
392            snippet: Some("  export const value = 1;  "),
393        });
394
395        assert_eq!(result["ruleId"], "fallow/test");
396        assert_eq!(
397            result["locations"][0]["physicalLocation"]["region"]["startLine"],
398            7
399        );
400        assert!(result["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
401        assert!(result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY].is_string());
402    }
403
404    fn fingerprint_of(result: &Value) -> &str {
405        result["partialFingerprints"][SARIF_FINGERPRINT_KEY]
406            .as_str()
407            .expect("fingerprint")
408    }
409
410    /// A one-line re-export barrel, a one-line enum, and a compact
411    /// `package.json` all put two findings of one rule on one source line, so
412    /// the snippet is identical and only the column tells them apart. GitHub
413    /// code scanning keys alert identity on this value, so a shared value is a
414    /// lost alert.
415    #[test]
416    fn two_findings_on_one_line_get_different_fingerprints() {
417        let at_column = |col: u32| {
418            build_sarif_result(SarifResultInput {
419                rule_id: "fallow/unused-export",
420                level: "warning",
421                message: "Re-export is never imported by other modules",
422                uri: "src/barrel.ts",
423                region: Some((1, col)),
424                snippet: Some("export { alpha, beta } from './m';"),
425            })
426        };
427
428        assert_ne!(
429            fingerprint_of(&at_column(10)),
430            fingerprint_of(&at_column(17))
431        );
432    }
433
434    /// The column is the only position in the fingerprint: a snippet that
435    /// survives an edit above it has to keep its identity, or every open alert
436    /// on the file below the edit closes and reopens.
437    #[test]
438    fn moving_a_finding_to_another_line_keeps_its_fingerprint() {
439        let at_line = |line: u32| {
440            build_sarif_result(SarifResultInput {
441                rule_id: "fallow/unused-export",
442                level: "warning",
443                message: "Export is never imported by other modules",
444                uri: "src/lib.ts",
445                region: Some((line, 14)),
446                snippet: Some("export const alpha = 1;"),
447            })
448        };
449
450        assert_eq!(fingerprint_of(&at_line(3)), fingerprint_of(&at_line(41)));
451    }
452
453    /// Two byte-identical declarations in one file leave the snippet and the
454    /// column identical, so position alone cannot separate them.
455    #[test]
456    fn identical_results_are_separated_by_occurrence() {
457        let result = || {
458            build_sarif_result(SarifResultInput {
459                rule_id: "fallow/duplicate-export",
460                level: "warning",
461                message: "Export 'Video' appears in multiple modules",
462                uri: "src/types.ts",
463                region: Some((309, 18)),
464                snippet: Some("export type Video = {"),
465            })
466        };
467        let mut results = vec![result(), result(), result()];
468        let first_before = fingerprint_of(&results[0]).to_owned();
469
470        ensure_unique_result_fingerprints(&mut results);
471
472        assert_eq!(
473            fingerprint_of(&results[0]),
474            first_before,
475            "the first occurrence keeps the identity an existing alert was opened under"
476        );
477        let unique: std::collections::BTreeSet<&str> = results.iter().map(fingerprint_of).collect();
478        assert_eq!(unique.len(), 3, "{results:?}");
479        for result in &results {
480            assert_eq!(
481                result["partialFingerprints"][SARIF_FINGERPRINT_KEY],
482                result["partialFingerprints"][GHAS_SARIF_FINGERPRINT_KEY],
483                "both keys name the same alert"
484            );
485        }
486    }
487
488    /// Finding ids are unique by construction, so the uniqueness pass rewrites
489    /// only the two location-based keys and keeps each `finding_id` key.
490    #[test]
491    fn the_uniqueness_pass_keeps_the_finding_id_key() {
492        let result = |finding_id: &str| {
493            build_sarif_finding(SarifFindingInput {
494                issue_code: "unused-class-member",
495                rule_id: "fallow/unused-class-member",
496                level: "warning",
497                message: "Class member 'run' is never used",
498                uri: "src/service.ts",
499                region: Some((4, 3)),
500                snippet: Some("run() {}"),
501                properties: None,
502                finding_id: Some(finding_id),
503            })
504        };
505        let ids = [
506            "dc1:unused-class-member:0123456789abcdef",
507            "dc1:unused-class-member:0123456789abcdef~1",
508        ];
509        let mut results = ids.map(result).to_vec();
510
511        ensure_unique_result_fingerprints(&mut results);
512
513        assert_ne!(
514            fingerprint_of(&results[0]),
515            fingerprint_of(&results[1]),
516            "the pass must still separate the location-based keys"
517        );
518        for (result, id) in results.iter().zip(ids) {
519            assert_eq!(result["partialFingerprints"][SARIF_FINDING_ID_KEY], id);
520        }
521    }
522
523    /// The `finding_id` key is additive: every other byte of the result stays
524    /// the same, so GitHub code scanning keeps each existing alert.
525    #[test]
526    fn a_finding_id_adds_one_key_and_keeps_the_rest() {
527        let result = |finding_id: Option<&str>| {
528            build_sarif_finding(SarifFindingInput {
529                issue_code: "unused-export",
530                rule_id: "fallow/unused-export",
531                level: "warning",
532                message: "Export 'helper' is never imported by other modules",
533                uri: "src/utils.ts",
534                region: Some((3, 14)),
535                snippet: Some("export const helper = 1;"),
536                properties: None,
537                finding_id,
538            })
539        };
540        let without = result(None);
541        let mut with = result(Some("dc1:unused-export:0123456789abcdef"));
542
543        assert!(
544            without["partialFingerprints"]
545                .get(SARIF_FINDING_ID_KEY)
546                .is_none()
547        );
548        let removed = with["partialFingerprints"]
549            .as_object_mut()
550            .and_then(|prints| prints.remove(SARIF_FINDING_ID_KEY));
551        assert_eq!(
552            removed,
553            Some(Value::from("dc1:unused-export:0123456789abcdef"))
554        );
555        assert_eq!(with, without);
556    }
557
558    /// A run whose results already differ must come out byte-identical, so the
559    /// pass never churns an alert that was already unique.
560    #[test]
561    fn distinct_results_are_left_alone() {
562        let mut results = vec![
563            build_sarif_result(SarifResultInput {
564                rule_id: "fallow/unused-export",
565                level: "warning",
566                message: "Export 'alpha' is never imported by other modules",
567                uri: "src/lib.ts",
568                region: Some((1, 14)),
569                snippet: Some("export const alpha = 1;"),
570            }),
571            build_sarif_result(SarifResultInput {
572                rule_id: "fallow/unused-export",
573                level: "warning",
574                message: "Export 'beta' is never imported by other modules",
575                uri: "src/lib.ts",
576                region: Some((2, 14)),
577                snippet: Some("export const beta = 2;"),
578            }),
579        ];
580        let before = results.clone();
581
582        ensure_unique_result_fingerprints(&mut results);
583
584        assert_eq!(results, before);
585    }
586
587    #[test]
588    fn sarif_finding_includes_custom_properties() {
589        let finding = build_sarif_finding(SarifFindingInput {
590            issue_code: "unused-export",
591            rule_id: "fallow/unused-export",
592            level: "warning",
593            message: "Export is never imported",
594            uri: "src/app.ts",
595            region: Some((3, 14)),
596            snippet: Some("export const unused = 1;"),
597            properties: Some(serde_json::json!({ "is_re_export": true })),
598            finding_id: None,
599        });
600
601        assert_eq!(finding["ruleId"], "fallow/unused-export");
602        assert_eq!(finding["properties"]["is_re_export"], true);
603        assert!(finding["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
604    }
605
606    #[test]
607    fn sarif_finding_omits_empty_properties() {
608        let finding = build_sarif_finding(SarifFindingInput {
609            issue_code: "unused-file",
610            rule_id: "fallow/unused-file",
611            level: "error",
612            message: "File is unreachable",
613            uri: "src/unused.ts",
614            region: None,
615            snippet: None,
616            properties: None,
617            finding_id: None,
618        });
619
620        assert!(finding.get("properties").is_none());
621    }
622
623    #[test]
624    fn append_sarif_findings_attaches_snippet_and_properties() {
625        let temp = tempfile::tempdir().expect("tempdir");
626        let source = temp.path().join("src.ts");
627        std::fs::write(&source, "\nexport const unused = 1;\n").expect("write source");
628        let mut snippets = SarifSourceSnippetCache::default();
629        let mut results = Vec::new();
630        let mut finding = fallow_types::output_dead_code::UnusedFileFinding::with_actions(
631            fallow_types::results::UnusedFile { path: source },
632        );
633        finding.finding_id = Some("dc1:unused-file:0123456789abcdef".to_owned());
634
635        append_sarif_findings(
636            &mut results,
637            std::slice::from_ref(&finding),
638            &mut snippets,
639            |finding| SarifFindingFields {
640                rule_id: "fallow/unused-export",
641                level: "warning",
642                message: "Export is never imported".to_string(),
643                uri: "src.ts".to_string(),
644                region: Some((2, 1)),
645                source_path: Some(finding.file.path.clone()),
646                properties: Some(serde_json::json!({ "is_re_export": true })),
647            },
648        );
649
650        assert_eq!(results.len(), 1);
651        assert_eq!(results[0]["ruleId"], "fallow/unused-export");
652        assert_eq!(results[0]["properties"]["is_re_export"], true);
653        assert!(results[0]["partialFingerprints"][SARIF_FINGERPRINT_KEY].is_string());
654        assert_eq!(
655            results[0]["partialFingerprints"][SARIF_FINDING_ID_KEY],
656            "dc1:unused-file:0123456789abcdef"
657        );
658    }
659
660    #[test]
661    fn sarif_rule_omits_optional_docs_when_absent() {
662        let rule = build_sarif_rule(SarifRuleInput {
663            id: "fallow/test",
664            short_description: "short",
665            level: "warning",
666            full_description: None,
667            help_uri: None,
668        });
669
670        assert!(rule.get("fullDescription").is_none());
671        assert!(rule.get("helpUri").is_none());
672    }
673
674    #[test]
675    fn sarif_document_uses_supplied_version() {
676        let document = build_sarif_document(SarifDocumentInput {
677            results: &[],
678            rules: &[],
679            tool_version: "1.2.3",
680        });
681
682        assert_eq!(document["version"], "2.1.0");
683        assert_eq!(document["runs"][0]["tool"]["driver"]["version"], "1.2.3");
684    }
685}