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    /// Property tests that drive the real `serialize` / `serialize_option` /
47    /// `serialize_vec` functions through `serde_json`. The forward-slash output
48    /// is a load-bearing cross-platform invariant for JSON/SARIF, and the input
49    /// space (arbitrary separators) is unbounded, so it is encoded as
50    /// properties.
51    mod proptests {
52        use proptest::prelude::*;
53        use serde::Serialize;
54        use std::path::PathBuf;
55
56        /// Wrapper that routes its field through the real scalar serializer.
57        #[derive(Serialize)]
58        struct ScalarPath {
59            #[serde(serialize_with = "crate::serde_path::serialize")]
60            path: PathBuf,
61        }
62
63        /// Wrapper that routes its field through the real option serializer.
64        #[derive(Serialize)]
65        struct OptionalPath {
66            #[serde(serialize_with = "crate::serde_path::serialize_option")]
67            path: Option<PathBuf>,
68        }
69
70        /// Wrapper that routes its field through the real vec serializer.
71        #[derive(Serialize)]
72        struct PathList {
73            #[serde(serialize_with = "crate::serde_path::serialize_vec")]
74            paths: Vec<PathBuf>,
75        }
76
77        /// Path-like strings over an alphabet that mixes both separators, so the
78        /// backslash-to-forward-slash rewrite is actually exercised (arbitrary
79        /// unicode would almost never hit the `\` branch).
80        fn path_like() -> impl Strategy<Value = String> {
81            prop::collection::vec(
82                prop::sample::select(vec!['a', 'b', '1', '/', '\\', '.', '-', '_', ' ']),
83                0..40,
84            )
85            .prop_map(|chars| chars.into_iter().collect())
86        }
87
88        /// Serialize one path through `ScalarPath` and return the emitted string.
89        fn scalar_json(path: &str) -> String {
90            let value = serde_json::to_value(ScalarPath {
91                path: PathBuf::from(path),
92            })
93            .expect("scalar wrapper serializes");
94            value["path"].as_str().expect("path is a string").to_owned()
95        }
96
97        /// Serialize one optional path through `OptionalPath`.
98        fn option_json(path: Option<&str>) -> serde_json::Value {
99            serde_json::to_value(OptionalPath {
100                path: path.map(PathBuf::from),
101            })
102            .expect("option wrapper serializes")
103        }
104
105        proptest! {
106            /// The serializer never emits a backslash and equals the input with
107            /// every `\` rewritten to `/`. Exercises the real `serialize` fn.
108            #[test]
109            fn serialize_emits_only_forward_slashes(path in path_like()) {
110                let out = scalar_json(&path);
111                prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
112                prop_assert_eq!(out, path.replace('\\', "/"));
113            }
114
115            /// Round-trip: a serialized path read back out of the JSON is its
116            /// forward-slashed form. `PathBuf` has no custom deserializer, so the
117            /// normalized string is the fixed point a second pass cannot change.
118            #[test]
119            fn serialize_then_read_back_is_normalized(path in path_like()) {
120                let json = serde_json::to_string(&ScalarPath { path: PathBuf::from(&path) })
121                    .expect("scalar wrapper serializes");
122                let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
123                let restored = parsed["path"].as_str().expect("path is a string");
124                prop_assert_eq!(restored, path.replace('\\', "/"));
125            }
126
127            /// Idempotence: serializing the already-normalized output again is a
128            /// no-op, so repeated passes never corrupt a path.
129            #[test]
130            fn serialize_is_idempotent(path in path_like()) {
131                let once = scalar_json(&path);
132                let twice = scalar_json(&once);
133                prop_assert_eq!(once, twice);
134            }
135
136            /// The option serializer keeps `None` as null and normalizes `Some`.
137            #[test]
138            fn serialize_option_normalizes_some(path in path_like()) {
139                let value = option_json(Some(&path));
140                let out = value["path"].as_str().expect("path is a string");
141                prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
142                prop_assert_eq!(out, path.replace('\\', "/"));
143            }
144
145            /// None remains a JSON null rather than a string sentinel.
146            #[test]
147            fn serialize_option_none_is_null(_path in path_like()) {
148                let value = option_json(None);
149                prop_assert!(value["path"].is_null());
150            }
151
152            /// The vec serializer agrees element-for-element with the scalar
153            /// serializer, so the two independent functions cannot drift apart.
154            #[test]
155            fn serialize_vec_matches_scalar(paths in prop::collection::vec(path_like(), 0..8)) {
156                let value = serde_json::to_value(PathList {
157                    paths: paths.iter().map(PathBuf::from).collect(),
158                })
159                .expect("vec wrapper serializes");
160                let array = value["paths"].as_array().expect("paths is an array");
161                prop_assert_eq!(array.len(), paths.len());
162                for (element, original) in array.iter().zip(&paths) {
163                    let serialized = element.as_str().expect("element is a string");
164                    prop_assert_eq!(serialized.to_owned(), scalar_json(original));
165                }
166            }
167        }
168    }
169}