fallow_output/
json_paths.rs1pub 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 (key, value) in map.iter_mut() {
18 if key == VERBATIM_KEY {
19 continue;
20 }
21 strip_root_prefix(value, prefix);
22 }
23 }
24 _ => {}
25 }
26}
27
28const VERBATIM_KEY: &str = "request_outcomes";
36
37fn strip_root_prefix_from_string(value: &mut String, prefix: &str) {
38 if let Some(rest) = value.strip_prefix(prefix) {
39 *value = rest.to_string();
40 return;
41 }
42
43 let normalized = normalize_output_path(value);
44 let normalized_prefix = normalize_output_path(prefix);
45 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
46 *value = rest.to_string();
47 } else if let Some(stripped) = strip_embedded_root_prefixes(&normalized, &normalized_prefix) {
48 *value = stripped;
49 }
50}
51
52fn normalize_output_path(path: &str) -> String {
53 normalize_uri(path)
54}
55
56#[must_use]
63pub fn normalize_uri(path: &str) -> String {
64 path.replace('\\', "/")
65 .replace('[', "%5B")
66 .replace(']', "%5D")
67}
68
69fn strip_embedded_root_prefixes(value: &str, prefix: &str) -> Option<String> {
70 let mut output = String::with_capacity(value.len());
71 let mut changed = false;
72 let mut last = 0;
73 let mut search_from = 0;
74
75 while let Some(offset) = value[search_from..].find(prefix) {
76 let index = search_from + offset;
77 let can_strip = index > 0
78 && value[..index]
79 .chars()
80 .next_back()
81 .is_some_and(is_embedded_path_boundary);
82
83 if can_strip {
84 output.push_str(&value[last..index]);
85 last = index + prefix.len();
86 changed = true;
87 }
88
89 search_from = index + prefix.len();
90 }
91
92 if changed {
93 output.push_str(&value[last..]);
94 Some(output)
95 } else {
96 None
97 }
98}
99
100fn is_embedded_path_boundary(c: char) -> bool {
101 c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | ':' | '=')
102}
103
104#[cfg(test)]
105mod tests {
106 use serde_json::json;
107
108 use super::*;
109
110 #[test]
111 fn strips_root_from_nested_strings() {
112 let mut value = json!({
113 "path": "/project/src/index.ts",
114 "items": ["/project/src/a.ts", { "path": "/project/src/b.ts" }]
115 });
116
117 strip_root_prefix(&mut value, "/project/");
118
119 assert_eq!(value["path"], "src/index.ts");
120 assert_eq!(value["items"][0], "src/a.ts");
121 assert_eq!(value["items"][1]["path"], "src/b.ts");
122 }
123
124 #[test]
125 fn normalizes_windows_separators_before_stripping() {
126 let mut value = json!("C:\\repo\\src\\index.ts");
127
128 strip_root_prefix(&mut value, "C:/repo/");
129
130 assert_eq!(value, json!("src/index.ts"));
131 }
132
133 #[test]
134 fn rewrites_embedded_path_strings() {
135 let mut value = json!("See /project/src/a.ts and /project/src/b.ts");
136
137 strip_root_prefix(&mut value, "/project/");
138
139 assert_eq!(value, json!("See src/a.ts and src/b.ts"));
140 }
141
142 #[test]
143 fn leaves_request_outcomes_verbatim_while_stripping_its_siblings() {
144 let mut value = json!({
145 "path": "/project/src/index.ts",
146 "request_outcomes": {
147 "sarif-file": {
148 "requested": "/project/out/results.sarif",
149 "message": "failed to write SARIF file '/project/out/results.sarif'"
150 }
151 }
152 });
153
154 strip_root_prefix(&mut value, "/project/");
155
156 assert_eq!(value["path"], "src/index.ts");
157 let entry = &value["request_outcomes"]["sarif-file"];
158 assert_eq!(entry["requested"], "/project/out/results.sarif");
159 assert_eq!(
160 entry["message"],
161 "failed to write SARIF file '/project/out/results.sarif'"
162 );
163 }
164
165 #[test]
166 fn leaves_non_matching_strings_unchanged() {
167 let mut value = json!("src/index.ts");
168
169 strip_root_prefix(&mut value, "/project/");
170
171 assert_eq!(value, json!("src/index.ts"));
172 }
173
174 #[test]
175 fn normalize_uri_rewrites_backslashes_and_brackets() {
176 assert_eq!(
177 normalize_uri("app\\[lang]\\posts\\[id].tsx"),
178 "app/%5Blang%5D/posts/%5Bid%5D.tsx"
179 );
180 }
181}