Skip to main content

fallow_output/
json_paths.rs

1//! Shared JSON path post-processing for output contracts.
2
3/// Recursively strip a project-root prefix from all string values in a JSON
4/// tree.
5///
6/// This keeps machine output relative to the analyzed root even when upstream
7/// analysis stages temporarily carry absolute paths.
8pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
9    match value {
10        serde_json::Value::String(s) => strip_root_prefix_from_string(s, prefix),
11        serde_json::Value::Array(items) => {
12            for item in items {
13                strip_root_prefix(item, prefix);
14            }
15        }
16        serde_json::Value::Object(map) => {
17            for value in map.values_mut() {
18                strip_root_prefix(value, prefix);
19            }
20        }
21        _ => {}
22    }
23}
24
25fn strip_root_prefix_from_string(value: &mut String, prefix: &str) {
26    if let Some(rest) = value.strip_prefix(prefix) {
27        *value = rest.to_string();
28        return;
29    }
30
31    let normalized = normalize_output_path(value);
32    let normalized_prefix = normalize_output_path(prefix);
33    if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
34        *value = rest.to_string();
35    } else if let Some(stripped) = strip_embedded_root_prefixes(&normalized, &normalized_prefix) {
36        *value = stripped;
37    }
38}
39
40fn normalize_output_path(path: &str) -> String {
41    normalize_uri(path)
42}
43
44/// Normalize a path string to a valid URI: forward slashes and percent-encoded
45/// brackets.
46///
47/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and
48/// cause SARIF / CodeClimate validation warnings for framework routes such as
49/// Next.js dynamic segments.
50#[must_use]
51pub fn normalize_uri(path: &str) -> String {
52    path.replace('\\', "/")
53        .replace('[', "%5B")
54        .replace(']', "%5D")
55}
56
57fn strip_embedded_root_prefixes(value: &str, prefix: &str) -> Option<String> {
58    let mut output = String::with_capacity(value.len());
59    let mut changed = false;
60    let mut last = 0;
61    let mut search_from = 0;
62
63    while let Some(offset) = value[search_from..].find(prefix) {
64        let index = search_from + offset;
65        let can_strip = index > 0
66            && value[..index]
67                .chars()
68                .next_back()
69                .is_some_and(is_embedded_path_boundary);
70
71        if can_strip {
72            output.push_str(&value[last..index]);
73            last = index + prefix.len();
74            changed = true;
75        }
76
77        search_from = index + prefix.len();
78    }
79
80    if changed {
81        output.push_str(&value[last..]);
82        Some(output)
83    } else {
84        None
85    }
86}
87
88fn is_embedded_path_boundary(c: char) -> bool {
89    c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | ':' | '=')
90}
91
92#[cfg(test)]
93mod tests {
94    use serde_json::json;
95
96    use super::*;
97
98    #[test]
99    fn strips_root_from_nested_strings() {
100        let mut value = json!({
101            "path": "/project/src/index.ts",
102            "items": ["/project/src/a.ts", { "path": "/project/src/b.ts" }]
103        });
104
105        strip_root_prefix(&mut value, "/project/");
106
107        assert_eq!(value["path"], "src/index.ts");
108        assert_eq!(value["items"][0], "src/a.ts");
109        assert_eq!(value["items"][1]["path"], "src/b.ts");
110    }
111
112    #[test]
113    fn normalizes_windows_separators_before_stripping() {
114        let mut value = json!("C:\\repo\\src\\index.ts");
115
116        strip_root_prefix(&mut value, "C:/repo/");
117
118        assert_eq!(value, json!("src/index.ts"));
119    }
120
121    #[test]
122    fn rewrites_embedded_path_strings() {
123        let mut value = json!("See /project/src/a.ts and /project/src/b.ts");
124
125        strip_root_prefix(&mut value, "/project/");
126
127        assert_eq!(value, json!("See src/a.ts and src/b.ts"));
128    }
129
130    #[test]
131    fn leaves_non_matching_strings_unchanged() {
132        let mut value = json!("src/index.ts");
133
134        strip_root_prefix(&mut value, "/project/");
135
136        assert_eq!(value, json!("src/index.ts"));
137    }
138
139    #[test]
140    fn normalize_uri_rewrites_backslashes_and_brackets() {
141        assert_eq!(
142            normalize_uri("app\\[lang]\\posts\\[id].tsx"),
143            "app/%5Blang%5D/posts/%5Bid%5D.tsx"
144        );
145    }
146}