Skip to main content

fallow_types/
serde_path.rs

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