Skip to main content

fallow_types/
serde_path.rs

1//! Custom serde serializers for `PathBuf`, `Option<PathBuf>`, and `Vec<PathBuf>` that always
2//! output forward slashes, regardless of platform. This ensures consistent
3//! JSON/SARIF output on Windows.
4
5use std::path::{Path, PathBuf};
6
7use serde::Serializer;
8
9/// Serialize a `Path` with forward slashes for cross-platform consistency.
10///
11/// # Errors
12///
13/// Returns any serializer error produced while writing the normalized path string.
14pub fn serialize<S: Serializer>(path: &Path, s: S) -> Result<S::Ok, S::Error> {
15    s.serialize_str(&path.to_string_lossy().replace('\\', "/"))
16}
17
18/// Serialize an `Option<PathBuf>` with forward slashes for cross-platform consistency.
19///
20/// # Errors
21///
22/// Returns any serializer error produced while writing the normalized optional path.
23pub fn serialize_option<S: Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
24    match path {
25        Some(path) => s.serialize_some(&path.to_string_lossy().replace('\\', "/")),
26        None => s.serialize_none(),
27    }
28}
29
30/// Serialize a `Vec<PathBuf>` with forward slashes for cross-platform consistency.
31///
32/// # Errors
33///
34/// Returns any serializer error produced while writing the normalized path list.
35pub fn serialize_vec<S: Serializer>(paths: &[PathBuf], s: S) -> Result<S::Ok, S::Error> {
36    use serde::ser::SerializeSeq;
37    let mut seq = s.serialize_seq(Some(paths.len()))?;
38    for p in paths {
39        seq.serialize_element(&p.to_string_lossy().replace('\\', "/"))?;
40    }
41    seq.end()
42}
43
44#[cfg(test)]
45mod tests {
46    use std::path::Path;
47
48    /// The core logic of `serialize` is `path.to_string_lossy().replace('\\', "/")`.
49    /// Test that transformation directly since `serde_json` is not a dependency of this crate.
50    fn normalize(path: &Path) -> String {
51        path.to_string_lossy().replace('\\', "/")
52    }
53
54    #[test]
55    fn unix_path_unchanged() {
56        assert_eq!(
57            normalize(Path::new("src/utils/index.ts")),
58            "src/utils/index.ts"
59        );
60    }
61
62    #[test]
63    fn empty_path() {
64        assert_eq!(normalize(Path::new("")), "");
65    }
66
67    #[test]
68    fn single_component_path() {
69        assert_eq!(normalize(Path::new("file.ts")), "file.ts");
70    }
71
72    #[test]
73    fn deep_nested_path() {
74        assert_eq!(normalize(Path::new("a/b/c/d/e.ts")), "a/b/c/d/e.ts");
75    }
76
77    #[test]
78    fn path_with_spaces() {
79        assert_eq!(
80            normalize(Path::new("my project/src/file.ts")),
81            "my project/src/file.ts"
82        );
83    }
84
85    #[test]
86    fn dot_relative_path() {
87        assert_eq!(normalize(Path::new("./src/file.ts")), "./src/file.ts");
88    }
89
90    #[test]
91    fn parent_relative_path() {
92        assert_eq!(normalize(Path::new("../other/file.ts")), "../other/file.ts");
93    }
94
95    // Test the actual backslash replacement — the core purpose of this module.
96    // On Unix, Path::new doesn't split on backslash, so to_string_lossy() preserves
97    // literal backslashes, and .replace('\\', "/") converts them.
98
99    #[test]
100    fn backslash_replacement_in_string() {
101        // Directly test the replace logic that runs on Windows paths
102        let windows_path = "src\\utils\\index.ts";
103        assert_eq!(windows_path.replace('\\', "/"), "src/utils/index.ts");
104    }
105
106    #[test]
107    fn mixed_separators_normalized() {
108        let mixed = "src/utils\\helpers\\index.ts";
109        assert_eq!(mixed.replace('\\', "/"), "src/utils/helpers/index.ts");
110    }
111
112    #[test]
113    fn backslash_only_path() {
114        let path = "src\\deep\\nested\\file.ts";
115        assert_eq!(path.replace('\\', "/"), "src/deep/nested/file.ts");
116    }
117
118    /// Property tests that drive the real `serialize` / `serialize_option` /
119    /// `serialize_vec`
120    /// functions through `serde_json`, rather than the `normalize` proxy the
121    /// example tests above use. The forward-slash output is a load-bearing
122    /// cross-platform invariant for JSON/SARIF, and the input space (arbitrary
123    /// separators) is unbounded, so it is encoded as properties.
124    mod proptests {
125        use proptest::prelude::*;
126        use serde::Serialize;
127        use std::path::PathBuf;
128
129        /// Wrapper that routes its field through the real scalar serializer.
130        #[derive(Serialize)]
131        struct ScalarPath {
132            #[serde(serialize_with = "crate::serde_path::serialize")]
133            path: PathBuf,
134        }
135
136        /// Wrapper that routes its field through the real option serializer.
137        #[derive(Serialize)]
138        struct OptionalPath {
139            #[serde(serialize_with = "crate::serde_path::serialize_option")]
140            path: Option<PathBuf>,
141        }
142
143        /// Wrapper that routes its field through the real vec serializer.
144        #[derive(Serialize)]
145        struct PathList {
146            #[serde(serialize_with = "crate::serde_path::serialize_vec")]
147            paths: Vec<PathBuf>,
148        }
149
150        /// Path-like strings over an alphabet that mixes both separators, so the
151        /// backslash-to-forward-slash rewrite is actually exercised (arbitrary
152        /// unicode would almost never hit the `\` branch).
153        fn path_like() -> impl Strategy<Value = String> {
154            prop::collection::vec(
155                prop::sample::select(vec!['a', 'b', '1', '/', '\\', '.', '-', '_', ' ']),
156                0..40,
157            )
158            .prop_map(|chars| chars.into_iter().collect())
159        }
160
161        /// Serialize one path through `ScalarPath` and return the emitted string.
162        fn scalar_json(path: &str) -> String {
163            let value = serde_json::to_value(ScalarPath {
164                path: PathBuf::from(path),
165            })
166            .expect("scalar wrapper serializes");
167            value["path"].as_str().expect("path is a string").to_owned()
168        }
169
170        /// Serialize one optional path through `OptionalPath`.
171        fn option_json(path: Option<&str>) -> serde_json::Value {
172            serde_json::to_value(OptionalPath {
173                path: path.map(PathBuf::from),
174            })
175            .expect("option wrapper serializes")
176        }
177
178        proptest! {
179            /// The serializer never emits a backslash and equals the input with
180            /// every `\` rewritten to `/`. Exercises the real `serialize` fn.
181            #[test]
182            fn serialize_emits_only_forward_slashes(path in path_like()) {
183                let out = scalar_json(&path);
184                prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
185                prop_assert_eq!(out, path.replace('\\', "/"));
186            }
187
188            /// Round-trip: a serialized path read back out of the JSON is its
189            /// forward-slashed form. `PathBuf` has no custom deserializer, so the
190            /// normalized string is the fixed point a second pass cannot change.
191            #[test]
192            fn serialize_then_read_back_is_normalized(path in path_like()) {
193                let json = serde_json::to_string(&ScalarPath { path: PathBuf::from(&path) })
194                    .expect("scalar wrapper serializes");
195                let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
196                let restored = parsed["path"].as_str().expect("path is a string");
197                prop_assert_eq!(restored, path.replace('\\', "/"));
198            }
199
200            /// Idempotence: serializing the already-normalized output again is a
201            /// no-op, so repeated passes never corrupt a path.
202            #[test]
203            fn serialize_is_idempotent(path in path_like()) {
204                let once = scalar_json(&path);
205                let twice = scalar_json(&once);
206                prop_assert_eq!(once, twice);
207            }
208
209            /// The option serializer keeps `None` as null and normalizes `Some`.
210            #[test]
211            fn serialize_option_normalizes_some(path in path_like()) {
212                let value = option_json(Some(&path));
213                let out = value["path"].as_str().expect("path is a string");
214                prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
215                prop_assert_eq!(out, path.replace('\\', "/"));
216            }
217
218            /// None remains a JSON null rather than a string sentinel.
219            #[test]
220            fn serialize_option_none_is_null(_path in path_like()) {
221                let value = option_json(None);
222                prop_assert!(value["path"].is_null());
223            }
224
225            /// The vec serializer agrees element-for-element with the scalar
226            /// serializer, so the two independent functions cannot drift apart.
227            #[test]
228            fn serialize_vec_matches_scalar(paths in prop::collection::vec(path_like(), 0..8)) {
229                let value = serde_json::to_value(PathList {
230                    paths: paths.iter().map(PathBuf::from).collect(),
231                })
232                .expect("vec wrapper serializes");
233                let array = value["paths"].as_array().expect("paths is an array");
234                prop_assert_eq!(array.len(), paths.len());
235                for (element, original) in array.iter().zip(&paths) {
236                    let serialized = element.as_str().expect("element is a string");
237                    prop_assert_eq!(serialized.to_owned(), scalar_json(original));
238                }
239            }
240        }
241    }
242}