Skip to main content

big_code_analysis/output/
sarif.rs

1//! SARIF 2.1.0 writer for [`OffenderRecord`] batches.
2//!
3//! SARIF (Static Analysis Results Interchange Format) is the OASIS
4//! standard ingested natively by GitHub Code Scanning and most modern
5//! IDE/security tooling. Lizard does not have a SARIF output, so this
6//! is the obvious modern target for `big-code-analysis` integrations.
7//!
8//! We model only the subset of SARIF we actually emit as a small set
9//! of `Serialize` structs (no `sarif` crate dependency). The shape:
10//!
11//! ```json
12//! {
13//!   "version": "2.1.0",
14//!   "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
15//!   "runs": [{
16//!     "tool": { "driver": { "name": "big-code-analysis", "version": "...",
17//!                            "rules": [ { "id": "cyclomatic", ... } ] } },
18//!     "results": [ { "ruleId": "...", "level": "warning", ... } ]
19//!   }]
20//! }
21//! ```
22
23use std::collections::BTreeSet;
24use std::io::{self, Write};
25
26use serde::Serialize;
27
28use crate::metric_catalog::lookup;
29#[cfg(test)]
30use crate::output::offenders::Severity;
31use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
32
33/// SARIF schema URL — pinned to 2.1.0 (the version GitHub Code
34/// Scanning ingests).
35const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
36const SARIF_VERSION: &str = "2.1.0";
37
38/// A `C:`-style Windows drive prefix that must be emitted as `file:///C:`
39/// so the leading drive letter is not parsed as a URI scheme.
40fn is_windows_drive_abs(bytes: &[u8]) -> bool {
41    bytes.len() >= 2
42        && bytes[0].is_ascii_alphabetic()
43        && bytes[1] == b':'
44        && (bytes.len() == 2 || bytes[2] == b'/' || bytes[2] == b'\\')
45}
46
47/// A relative reference (no leading separator, not a Windows drive) whose
48/// first path segment carries a colon is scheme-ambiguous under RFC 3986
49/// §4.2; the `./` prefix in [`path_to_uri_reference`] neutralizes it
50/// (#798). The first segment ends at the first `/` or `\` separator.
51fn relative_first_segment_has_colon(bytes: &[u8]) -> bool {
52    !is_windows_drive_abs(bytes)
53        && bytes.first() != Some(&b'/')
54        && bytes.first() != Some(&b'\\')
55        && bytes
56            .iter()
57            .take_while(|&&b| b != b'/' && b != b'\\')
58            .any(|&b| b == b':')
59}
60
61/// Convert an OS path string into a SARIF `artifactLocation.uri`
62/// value (an RFC 3986 URI reference).
63///
64/// SARIF 2.1.0 §3.4.4 requires `artifactLocation.uri` be a valid URI
65/// reference. Backslash separators (Windows paths) and characters
66/// outside the URI unreserved/reserved sets break that — the
67/// json-schema validator GitHub Code Scanning uses rejects them
68/// under the `uri-reference` format. We:
69///
70/// - Normalize separators to `/`.
71/// - Percent-encode any byte outside the URI unreserved set + `/`
72///   so spaces and other path characters survive validation.
73/// - For absolute Windows paths beginning with a drive letter
74///   (`C:\…` → `C:/…`), prefix with `file:///` so the leading `C:`
75///   is not interpreted as a URI scheme.
76/// - For a relative path whose first segment contains a colon
77///   (`a:b/c.rs`), prefix with `./` so the colon no longer sits in
78///   the first segment: RFC 3986 §4.2 reads a bare `a:b/c.rs` as
79///   scheme `a:`, but `./a:b/c.rs` is an unambiguous relative-ref
80///   (`./` is a complete colon-free first segment). A colon *after*
81///   the first `/` is already RFC-legal and passes through untouched.
82fn path_to_uri_reference(path: &str) -> String {
83    let bytes = path.as_bytes();
84    let is_windows_drive_abs = is_windows_drive_abs(bytes);
85    let first_segment_has_colon = relative_first_segment_has_colon(bytes);
86
87    let mut out = String::with_capacity(
88        path.len()
89            + if is_windows_drive_abs { 8 } else { 0 }
90            + usize::from(first_segment_has_colon) * 2,
91    );
92    if is_windows_drive_abs {
93        out.push_str("file:///");
94    } else if first_segment_has_colon {
95        out.push_str("./");
96    }
97    for &b in bytes {
98        match b {
99            b'\\' => out.push('/'),
100            // RFC 3986 unreserved + path separator + segment-safe sub-delims +
101            // ':' '@' (allowed in path) + '%' would need its own escaping but
102            // raw paths from the OS will not contain it pre-encoded.
103            b'A'..=b'Z'
104            | b'a'..=b'z'
105            | b'0'..=b'9'
106            | b'-'
107            | b'.'
108            | b'_'
109            | b'~'
110            | b'/'
111            | b':'
112            | b'@' => out.push(b as char),
113            _ => {
114                let hi = b >> 4;
115                let lo = b & 0xF;
116                out.push('%');
117                out.push(hex_digit(hi));
118                out.push(hex_digit(lo));
119            }
120        }
121    }
122    out
123}
124
125fn hex_digit(nibble: u8) -> char {
126    match nibble {
127        0..=9 => (b'0' + nibble) as char,
128        10..=15 => (b'A' + nibble - 10) as char,
129        _ => '0',
130    }
131}
132
133/// Write a SARIF 2.1.0 document for `offenders` to `writer`.
134///
135/// Offenders whose path is not valid UTF-8 are skipped with a warning
136/// to stderr (SARIF `artifactLocation.uri` requires a UTF-8 string).
137/// The empty case emits a well-formed run with empty `results: []` and
138/// `rules: []` so snapshots are stable and CI consumers can already
139/// integrate before the threshold engine (#96) lands.
140///
141/// # Errors
142///
143/// Returns any [`io::Error`] produced by `writer` while emitting the
144/// SARIF JSON document, or a `serde_json::Error` (mapped to `io::Error`
145/// via `io::Error::other`) if a record cannot be serialised.
146pub fn write_sarif<W: Write>(offenders: &[OffenderRecord], writer: W) -> io::Result<()> {
147    write_sarif_with_suppressed(offenders, &[], &[], writer)
148}
149
150/// Write a SARIF 2.1.0 document that also surfaces *suppressed* offenders.
151///
152/// `active` offenders are emitted as ordinary results (open findings).
153/// `in_source` and `baseline` offenders are emitted with a SARIF
154/// `suppressions` entry (`kind: "inSource"` and `kind: "external"`
155/// respectively) so consumers such as GitHub Code Scanning render them as
156/// suppressed/closed alerts rather than active findings — the debt stays
157/// visible without counting against the open-alert total. This backs
158/// `bca check --report-suppressed`, which keeps suppression-marker and
159/// baseline-covered offenders out of the gate yet records them in the
160/// code-scan report.
161///
162/// `write_sarif` is the `active`-only special case.
163///
164/// # Errors
165///
166/// Same contract as [`write_sarif`]: any [`io::Error`] from `writer`, or a
167/// serialisation failure mapped to `io::Error`.
168pub fn write_sarif_with_suppressed<W: Write>(
169    active: &[OffenderRecord],
170    in_source: &[OffenderRecord],
171    baseline: &[OffenderRecord],
172    mut writer: W,
173) -> io::Result<()> {
174    let mut results: Vec<SarifResult<'_>> =
175        Vec::with_capacity(active.len() + in_source.len() + baseline.len());
176    // BTreeSet so the rules array is deterministic (alphabetical by id).
177    let mut rule_ids: BTreeSet<&str> = BTreeSet::new();
178
179    for (offenders, origin) in [
180        (active, None),
181        (in_source, Some(SuppressionOrigin::InSource)),
182        (baseline, Some(SuppressionOrigin::Baseline)),
183    ] {
184        collect_results(offenders, origin, &mut results, &mut rule_ids);
185    }
186
187    let rules: Vec<Rule<'_>> = rule_ids
188        .iter()
189        .map(|id| Rule {
190            id,
191            short_description: Description {
192                text: lookup(id).map_or(*id, |info| info.long_description),
193            },
194        })
195        .collect();
196
197    let log = SarifLog {
198        schema: SARIF_SCHEMA,
199        version: SARIF_VERSION,
200        runs: vec![Run {
201            tool: Tool {
202                driver: Driver {
203                    name: TOOL_ID,
204                    version: env!("CARGO_PKG_VERSION"),
205                    rules,
206                },
207            },
208            results,
209        }],
210    };
211
212    serde_json::to_writer_pretty(&mut writer, &log)
213        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
214    // `serde_json::to_writer_pretty` does not append a trailing
215    // newline; add one so the output is POSIX-friendly and snapshot
216    // diffs stay clean.
217    writer.write_all(b"\n")
218}
219
220/// Append one [`SarifResult`] per offender in `offenders`, tagging each
221/// with `origin` (when `Some`) via the SARIF `suppressions` property.
222/// Non-UTF-8 paths are skipped with a warning (SARIF `uri` must be UTF-8).
223fn collect_results<'a>(
224    offenders: &'a [OffenderRecord],
225    origin: Option<SuppressionOrigin>,
226    results: &mut Vec<SarifResult<'a>>,
227    rule_ids: &mut BTreeSet<&'a str>,
228) {
229    for record in offenders {
230        let Some(path_str) = warn_non_utf8_path("SARIF", &record.path) else {
231            continue;
232        };
233        rule_ids.insert(record.metric.as_str());
234
235        let logical_locations = record.function.as_deref().map(|name| {
236            vec![LogicalLocation {
237                fully_qualified_name: name,
238            }]
239        });
240
241        results.push(SarifResult {
242            rule_id: &record.metric,
243            level: record.severity.as_str(),
244            message: Message {
245                text: record.default_message(),
246            },
247            locations: vec![Location {
248                physical_location: PhysicalLocation {
249                    artifact_location: ArtifactLocation {
250                        uri: path_to_uri_reference(path_str),
251                    },
252                    region: Region {
253                        start_line: record.start_line.max(1),
254                        end_line: Some(record.end_line.max(record.start_line.max(1))),
255                        // SARIF §3.30.6 requires `startColumn >= 1`;
256                        // clamp symmetrically with `startLine` so a
257                        // future 0-based-column producer can never emit
258                        // `startColumn: 0` (#698). `start_col` is `None`
259                        // in production today, so this is latent.
260                        start_column: record.start_col.map(|c| c.max(1)),
261                    },
262                },
263                logical_locations,
264            }],
265            suppressions: origin.map(|o| {
266                vec![Suppression {
267                    kind: o.sarif_kind(),
268                    justification: o.justification(&record.metric),
269                }]
270            }),
271        });
272    }
273}
274
275/// Why a surfaced offender is exempt from the `bca check` gate, mapped to a
276/// SARIF `suppressions[].kind`. Emitted by [`write_sarif_with_suppressed`]
277/// so suppressed debt appears as a *closed* code-scan alert.
278#[derive(Debug, Clone, Copy)]
279enum SuppressionOrigin {
280    /// Silenced by an in-source `bca: suppress` / `suppress-file` marker.
281    InSource,
282    /// Within a recorded `.bca-baseline.toml` entry.
283    Baseline,
284}
285
286impl SuppressionOrigin {
287    /// SARIF 2.1.0 `suppressions[].kind`: `inSource` for source markers,
288    /// `external` for the out-of-band baseline file.
289    fn sarif_kind(self) -> &'static str {
290        match self {
291            Self::InSource => "inSource",
292            Self::Baseline => "external",
293        }
294    }
295
296    fn justification(self, metric: &str) -> String {
297        match self {
298            Self::InSource => {
299                format!("metric '{metric}' silenced by an in-source bca suppression marker")
300            }
301            Self::Baseline => format!("metric '{metric}' within the recorded bca baseline"),
302        }
303    }
304}
305
306#[derive(Serialize)]
307struct SarifLog<'a> {
308    #[serde(rename = "$schema")]
309    schema: &'a str,
310    version: &'a str,
311    runs: Vec<Run<'a>>,
312}
313
314#[derive(Serialize)]
315struct Run<'a> {
316    tool: Tool<'a>,
317    results: Vec<SarifResult<'a>>,
318}
319
320#[derive(Serialize)]
321struct Tool<'a> {
322    driver: Driver<'a>,
323}
324
325#[derive(Serialize)]
326struct Driver<'a> {
327    name: &'a str,
328    version: &'a str,
329    rules: Vec<Rule<'a>>,
330}
331
332#[derive(Serialize)]
333struct Rule<'a> {
334    id: &'a str,
335    #[serde(rename = "shortDescription")]
336    short_description: Description<'a>,
337}
338
339#[derive(Serialize)]
340struct Description<'a> {
341    text: &'a str,
342}
343
344#[derive(Serialize)]
345#[serde(rename_all = "camelCase")]
346struct SarifResult<'a> {
347    rule_id: &'a str,
348    level: &'static str,
349    message: Message,
350    locations: Vec<Location<'a>>,
351    /// SARIF `suppressions`: present (non-empty) marks the result as a
352    /// suppressed/closed alert. Elided for active findings so existing
353    /// `write_sarif` output is byte-for-byte unchanged.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    suppressions: Option<Vec<Suppression>>,
356}
357
358/// One SARIF 2.1.0 `suppressions` entry. A non-empty `suppressions` array
359/// tells consumers the result is suppressed; `kind` distinguishes in-source
360/// markers (`inSource`) from the external baseline file (`external`).
361#[derive(Serialize)]
362struct Suppression {
363    kind: &'static str,
364    justification: String,
365}
366
367#[derive(Serialize)]
368struct Message {
369    text: String,
370}
371
372#[derive(Serialize)]
373#[serde(rename_all = "camelCase")]
374struct Location<'a> {
375    physical_location: PhysicalLocation,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    logical_locations: Option<Vec<LogicalLocation<'a>>>,
378}
379
380#[derive(Serialize)]
381#[serde(rename_all = "camelCase")]
382struct PhysicalLocation {
383    artifact_location: ArtifactLocation,
384    region: Region,
385}
386
387#[derive(Serialize)]
388struct ArtifactLocation {
389    uri: String,
390}
391
392#[derive(Serialize)]
393#[serde(rename_all = "camelCase")]
394struct Region {
395    start_line: u32,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    end_line: Option<u32>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    start_column: Option<u32>,
400}
401
402#[derive(Serialize)]
403#[serde(rename_all = "camelCase")]
404struct LogicalLocation<'a> {
405    fully_qualified_name: &'a str,
406}
407
408#[cfg(test)]
409#[allow(
410    clippy::float_cmp,
411    clippy::cast_precision_loss,
412    clippy::cast_possible_truncation,
413    clippy::cast_sign_loss,
414    clippy::similar_names,
415    clippy::doc_markdown,
416    clippy::needless_raw_string_hashes,
417    clippy::too_many_lines
418)]
419mod tests {
420    use super::*;
421    use std::path::PathBuf;
422
423    fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
424        OffenderRecord {
425            path: PathBuf::from(path),
426            function: Some("f".into()),
427            start_line: 42,
428            end_line: 50,
429            start_col: Some(5),
430            metric: metric.into(),
431            value,
432            limit,
433            severity: Severity::Warning,
434        }
435    }
436
437    fn render(offenders: &[OffenderRecord]) -> String {
438        let mut buf = Vec::new();
439        write_sarif(offenders, &mut buf).expect("writing to Vec is infallible");
440        String::from_utf8(buf).expect("output is UTF-8")
441    }
442
443    #[test]
444    fn empty_emits_minimal_valid_run() {
445        let out = render(&[]);
446        // Round-trips cleanly through serde_json so we know it parses.
447        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
448        assert_eq!(v["version"], "2.1.0");
449        assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "big-code-analysis");
450        assert!(
451            v["runs"][0]["results"]
452                .as_array()
453                .expect("array")
454                .is_empty()
455        );
456        assert!(
457            v["runs"][0]["tool"]["driver"]["rules"]
458                .as_array()
459                .expect("array")
460                .is_empty()
461        );
462    }
463
464    #[test]
465    fn single_offender_includes_rule_and_result() {
466        let offenders = vec![rec("src/foo.rs", "cyclomatic", 17.0, 15.0)];
467        let out = render(&offenders);
468        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
469        let result = &v["runs"][0]["results"][0];
470        assert_eq!(result["ruleId"], "cyclomatic");
471        assert_eq!(result["level"], "warning");
472        assert_eq!(result["message"]["text"], "cyclomatic 17 exceeds limit 15");
473        let loc = &result["locations"][0];
474        assert_eq!(
475            loc["physicalLocation"]["artifactLocation"]["uri"],
476            "src/foo.rs"
477        );
478        assert_eq!(loc["physicalLocation"]["region"]["startLine"], 42);
479        assert_eq!(loc["physicalLocation"]["region"]["endLine"], 50);
480        assert_eq!(loc["physicalLocation"]["region"]["startColumn"], 5);
481        assert_eq!(loc["logicalLocations"][0]["fullyQualifiedName"], "f");
482
483        let rule = &v["runs"][0]["tool"]["driver"]["rules"][0];
484        assert_eq!(rule["id"], "cyclomatic");
485        assert!(rule["shortDescription"]["text"].is_string());
486    }
487
488    #[test]
489    fn write_sarif_omits_suppressions_for_active_results() {
490        // The active-only `write_sarif` path must never emit a
491        // `suppressions` key, so existing Code Scanning output (and
492        // snapshot consumers) stay byte-for-byte unchanged.
493        let out = render(&[rec("src/foo.rs", "cyclomatic", 17.0, 15.0)]);
494        assert!(
495            !out.contains("suppressions"),
496            "active result must not carry a suppressions array:\n{out}"
497        );
498    }
499
500    #[test]
501    fn suppressed_offenders_carry_suppressions_with_kind() {
502        let active = vec![rec("src/active.rs", "cyclomatic", 17.0, 15.0)];
503        let in_source = vec![rec("src/marked.rs", "halstead.effort", 9e4, 5e4)];
504        let baseline = vec![rec("src/legacy.rs", "cognitive", 26.0, 25.0)];
505
506        let mut buf = Vec::new();
507        write_sarif_with_suppressed(&active, &in_source, &baseline, &mut buf)
508            .expect("writing to Vec is infallible");
509        let v: serde_json::Value =
510            serde_json::from_str(&String::from_utf8(buf).expect("utf8")).expect("valid JSON");
511        let results = v["runs"][0]["results"].as_array().expect("array");
512        assert_eq!(results.len(), 3);
513
514        // Active result: no suppressions key (open finding).
515        let active_r = results
516            .iter()
517            .find(|r| {
518                r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/active.rs"
519            })
520            .expect("active result present");
521        assert!(active_r.get("suppressions").is_none());
522
523        // In-source marker → kind "inSource".
524        let marked = results
525            .iter()
526            .find(|r| {
527                r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/marked.rs"
528            })
529            .expect("in-source result present");
530        assert_eq!(marked["suppressions"][0]["kind"], "inSource");
531        assert!(
532            marked["suppressions"][0]["justification"]
533                .as_str()
534                .expect("justification string")
535                .contains("in-source")
536        );
537
538        // Baseline-covered → kind "external".
539        let legacy = results
540            .iter()
541            .find(|r| {
542                r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/legacy.rs"
543            })
544            .expect("baseline result present");
545        assert_eq!(legacy["suppressions"][0]["kind"], "external");
546    }
547
548    #[test]
549    fn error_severity_maps_to_error_level() {
550        let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
551        r.severity = Severity::Error;
552        let out = render(&[r]);
553        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
554        assert_eq!(v["runs"][0]["results"][0]["level"], "error");
555    }
556
557    #[test]
558    fn missing_column_omits_field() {
559        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
560        r.start_col = None;
561        let out = render(&[r]);
562        assert!(!out.contains("startColumn"), "{out}");
563    }
564
565    #[test]
566    fn missing_function_omits_logical_locations() {
567        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
568        r.function = None;
569        let out = render(&[r]);
570        assert!(!out.contains("logicalLocations"), "{out}");
571    }
572
573    #[test]
574    fn start_column_zero_is_clamped_to_one() {
575        // SARIF §3.30.6 requires `startColumn >= 1`. A 0-based-column
576        // producer feeding `start_col = Some(0)` must be clamped to 1,
577        // symmetric with the already-clamped `startLine` (#698).
578        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
579        r.start_col = Some(0);
580        let out = render(&[r]);
581        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
582        let region = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
583        assert_eq!(
584            region["startColumn"], 1,
585            "startColumn must clamp 0 -> 1, got: {region}"
586        );
587    }
588
589    #[test]
590    fn rules_deduplicate_per_metric() {
591        let offenders = vec![
592            rec("a.rs", "cyclomatic", 17.0, 15.0),
593            rec("b.rs", "cyclomatic", 20.0, 15.0),
594            rec("a.rs", "loc.lloc", 250.0, 100.0),
595        ];
596        let out = render(&offenders);
597        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
598        let rules = v["runs"][0]["tool"]["driver"]["rules"]
599            .as_array()
600            .expect("array");
601        assert_eq!(rules.len(), 2);
602        // BTreeSet iteration order: alphabetical.
603        assert_eq!(rules[0]["id"], "cyclomatic");
604        assert_eq!(rules[1]["id"], "loc.lloc");
605    }
606
607    #[test]
608    fn unknown_metric_falls_back_to_metric_name_as_description() {
609        let r = rec("a.rs", "made.up.metric", 1.0, 0.0);
610        let out = render(&[r]);
611        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
612        assert_eq!(
613            v["runs"][0]["tool"]["driver"]["rules"][0]["shortDescription"]["text"],
614            "made.up.metric"
615        );
616    }
617
618    #[test]
619    fn start_line_zero_is_clamped_to_one() {
620        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
621        r.start_line = 0;
622        r.end_line = 0;
623        let out = render(&[r]);
624        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
625        assert_eq!(
626            v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"],
627            1
628        );
629    }
630
631    #[test]
632    fn driver_version_matches_pkg_version() {
633        let out = render(&[]);
634        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
635        assert_eq!(
636            v["runs"][0]["tool"]["driver"]["version"],
637            env!("CARGO_PKG_VERSION")
638        );
639    }
640
641    #[test]
642    fn windows_drive_path_becomes_file_uri() {
643        // Windows absolute path: backslashes flip to /, drive letter
644        // gets wrapped in `file:///` so it isn't parsed as a scheme.
645        assert_eq!(
646            path_to_uri_reference(r"C:\Users\RUNNER~1\AppData\Local\Temp\fixture.rs"),
647            "file:///C:/Users/RUNNER~1/AppData/Local/Temp/fixture.rs"
648        );
649    }
650
651    #[test]
652    fn posix_relative_path_is_unchanged() {
653        assert_eq!(path_to_uri_reference("src/foo.rs"), "src/foo.rs");
654    }
655
656    #[test]
657    fn posix_absolute_path_keeps_leading_slash() {
658        assert_eq!(path_to_uri_reference("/tmp/foo.rs"), "/tmp/foo.rs");
659    }
660
661    #[test]
662    fn space_is_percent_encoded() {
663        assert_eq!(path_to_uri_reference("src/my file.rs"), "src/my%20file.rs");
664    }
665
666    #[test]
667    fn relative_path_with_colon_in_first_segment_is_not_scheme_ambiguous() {
668        // RFC 3986 §4.2: a bare `a:b/c.rs` parses as scheme `a:`. The
669        // `./` prefix makes it an unambiguous relative-ref (#798).
670        assert_eq!(path_to_uri_reference("a:b/c.rs"), "./a:b/c.rs");
671        assert_eq!(path_to_uri_reference("foo:bar/baz.rs"), "./foo:bar/baz.rs");
672    }
673
674    #[test]
675    fn relative_path_with_colon_after_first_slash_is_unchanged() {
676        // A colon *after* the first segment is RFC-3986-legal in a
677        // relative-ref (only the first segment may not contain one), so
678        // it must pass through without a `./` prefix (#798).
679        assert_eq!(path_to_uri_reference("a/b:c.rs"), "a/b:c.rs");
680    }
681
682    #[test]
683    fn normal_relative_path_keeps_no_dot_slash_prefix() {
684        // No colon in the first segment → untouched (#798 guard).
685        assert_eq!(path_to_uri_reference("a/b/c.rs"), "a/b/c.rs");
686    }
687
688    #[test]
689    fn empty_snapshot_is_stable() {
690        insta::assert_snapshot!("sarif_empty", render(&[]));
691    }
692
693    #[test]
694    fn multi_offender_snapshot_is_stable() {
695        let mut err = rec("src/zeta.rs", "cognitive", 30.0, 15.0);
696        err.severity = Severity::Error;
697        err.start_col = None;
698        err.function = None;
699        let offenders = vec![
700            rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
701            rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
702            err,
703        ];
704        insta::assert_snapshot!("sarif_multi", render(&offenders));
705    }
706}