fallow_types/
serde_path.rs1use std::path::{Path, PathBuf};
6
7use serde::Serializer;
8
9pub fn serialize<S: Serializer>(path: &Path, s: S) -> Result<S::Ok, S::Error> {
15 s.serialize_str(&path.to_string_lossy().replace('\\', "/"))
16}
17
18pub 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
30pub 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 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]
96 fn backslash_replacement_in_string() {
97 let windows_path = "src\\utils\\index.ts";
98 assert_eq!(windows_path.replace('\\', "/"), "src/utils/index.ts");
99 }
100
101 #[test]
102 fn mixed_separators_normalized() {
103 let mixed = "src/utils\\helpers\\index.ts";
104 assert_eq!(mixed.replace('\\', "/"), "src/utils/helpers/index.ts");
105 }
106
107 #[test]
108 fn backslash_only_path() {
109 let path = "src\\deep\\nested\\file.ts";
110 assert_eq!(path.replace('\\', "/"), "src/deep/nested/file.ts");
111 }
112
113 mod proptests {
120 use proptest::prelude::*;
121 use serde::Serialize;
122 use std::path::PathBuf;
123
124 #[derive(Serialize)]
126 struct ScalarPath {
127 #[serde(serialize_with = "crate::serde_path::serialize")]
128 path: PathBuf,
129 }
130
131 #[derive(Serialize)]
133 struct OptionalPath {
134 #[serde(serialize_with = "crate::serde_path::serialize_option")]
135 path: Option<PathBuf>,
136 }
137
138 #[derive(Serialize)]
140 struct PathList {
141 #[serde(serialize_with = "crate::serde_path::serialize_vec")]
142 paths: Vec<PathBuf>,
143 }
144
145 fn path_like() -> impl Strategy<Value = String> {
149 prop::collection::vec(
150 prop::sample::select(vec!['a', 'b', '1', '/', '\\', '.', '-', '_', ' ']),
151 0..40,
152 )
153 .prop_map(|chars| chars.into_iter().collect())
154 }
155
156 fn scalar_json(path: &str) -> String {
158 let value = serde_json::to_value(ScalarPath {
159 path: PathBuf::from(path),
160 })
161 .expect("scalar wrapper serializes");
162 value["path"].as_str().expect("path is a string").to_owned()
163 }
164
165 fn option_json(path: Option<&str>) -> serde_json::Value {
167 serde_json::to_value(OptionalPath {
168 path: path.map(PathBuf::from),
169 })
170 .expect("option wrapper serializes")
171 }
172
173 proptest! {
174 #[test]
177 fn serialize_emits_only_forward_slashes(path in path_like()) {
178 let out = scalar_json(&path);
179 prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
180 prop_assert_eq!(out, path.replace('\\', "/"));
181 }
182
183 #[test]
187 fn serialize_then_read_back_is_normalized(path in path_like()) {
188 let json = serde_json::to_string(&ScalarPath { path: PathBuf::from(&path) })
189 .expect("scalar wrapper serializes");
190 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
191 let restored = parsed["path"].as_str().expect("path is a string");
192 prop_assert_eq!(restored, path.replace('\\', "/"));
193 }
194
195 #[test]
198 fn serialize_is_idempotent(path in path_like()) {
199 let once = scalar_json(&path);
200 let twice = scalar_json(&once);
201 prop_assert_eq!(once, twice);
202 }
203
204 #[test]
206 fn serialize_option_normalizes_some(path in path_like()) {
207 let value = option_json(Some(&path));
208 let out = value["path"].as_str().expect("path is a string");
209 prop_assert!(!out.contains('\\'), "output {out:?} still contains a backslash");
210 prop_assert_eq!(out, path.replace('\\', "/"));
211 }
212
213 #[test]
215 fn serialize_option_none_is_null(_path in path_like()) {
216 let value = option_json(None);
217 prop_assert!(value["path"].is_null());
218 }
219
220 #[test]
223 fn serialize_vec_matches_scalar(paths in prop::collection::vec(path_like(), 0..8)) {
224 let value = serde_json::to_value(PathList {
225 paths: paths.iter().map(PathBuf::from).collect(),
226 })
227 .expect("vec wrapper serializes");
228 let array = value["paths"].as_array().expect("paths is an array");
229 prop_assert_eq!(array.len(), paths.len());
230 for (element, original) in array.iter().zip(&paths) {
231 let serialized = element.as_str().expect("element is a string");
232 prop_assert_eq!(serialized.to_owned(), scalar_json(original));
233 }
234 }
235 }
236 }
237}