1use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::path::{Component, Path, PathBuf};
7
8#[macro_export]
9macro_rules! string_newtype {
10 ($(#[$meta:meta])* $name:ident) => {
11 $(#[$meta])*
12 #[derive(
13 ::serde::Serialize, ::serde::Deserialize, Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd,
14 )]
15 #[serde(transparent)]
16 pub struct $name(String);
17
18 impl From<String> for $name {
19 fn from(value: String) -> Self {
20 Self(value)
21 }
22 }
23
24 impl From<&str> for $name {
25 fn from(value: &str) -> Self {
26 Self(value.to_owned())
27 }
28 }
29
30 impl AsRef<str> for $name {
31 fn as_ref(&self) -> &str {
32 &self.0
33 }
34 }
35
36 impl ::std::borrow::Borrow<str> for $name {
37 fn borrow(&self) -> &str {
38 &self.0
39 }
40 }
41
42 impl ::std::ops::Deref for $name {
43 type Target = str;
44
45 fn deref(&self) -> &Self::Target {
46 &self.0
47 }
48 }
49
50 impl From<$name> for String {
51 fn from(value: $name) -> Self {
52 value.0
53 }
54 }
55
56 impl ::std::fmt::Display for $name {
57 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
58 f.write_str(&self.0)
59 }
60 }
61
62 impl PartialEq<str> for $name {
63 fn eq(&self, other: &str) -> bool {
64 self.0 == other
65 }
66 }
67
68 impl PartialEq<&str> for $name {
69 fn eq(&self, other: &&str) -> bool {
70 self.0 == *other
71 }
72 }
73
74 impl PartialEq<String> for $name {
75 fn eq(&self, other: &String) -> bool {
76 self.0 == *other
77 }
78 }
79
80 impl PartialEq<$name> for String {
81 fn eq(&self, other: &$name) -> bool {
82 *self == other.0
83 }
84 }
85 };
86}
87
88string_newtype!(
89 ItemName
91);
92
93impl ItemName {
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RelativePathError {
102 Empty,
103 Absolute,
104 Escaping,
105}
106
107pub fn normalize_relative(raw: &str) -> Result<String, RelativePathError> {
111 if raw.is_empty() {
112 return Err(RelativePathError::Empty);
113 }
114 let normalized_separators = raw.replace('\\', "/");
115 if is_windows_absolute(&normalized_separators) {
116 return Err(RelativePathError::Absolute);
117 }
118
119 let mut segments = Vec::new();
120 for component in Path::new(&normalized_separators).components() {
121 match component {
122 Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
123 Component::CurDir => {}
124 Component::ParentDir => return Err(RelativePathError::Escaping),
125 Component::RootDir | Component::Prefix(_) => return Err(RelativePathError::Absolute),
126 }
127 }
128
129 if segments.is_empty() {
130 return Err(RelativePathError::Empty);
131 }
132
133 Ok(segments.join("/"))
134}
135
136fn is_windows_absolute(path: &str) -> bool {
137 let bytes = path.as_bytes();
138 path.starts_with('/')
139 || (bytes.len() >= 3
140 && bytes[0].is_ascii_alphabetic()
141 && bytes[1] == b':'
142 && bytes[2] == b'/')
143}
144
145#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
147pub struct SourceSubpath(String);
148
149impl SourceSubpath {
150 pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
151 let raw = value.as_ref();
152 let normalized = normalize_relative(raw).map_err(|err| match err {
153 RelativePathError::Empty => SourceSubpathError::Empty,
154 RelativePathError::Absolute => SourceSubpathError::Absolute {
155 input: raw.to_string(),
156 },
157 RelativePathError::Escaping => SourceSubpathError::Escaping {
158 input: raw.to_string(),
159 },
160 })?;
161
162 Ok(Self(normalized))
163 }
164
165 pub fn as_str(&self) -> &str {
166 &self.0
167 }
168
169 pub fn as_path(&self) -> &Path {
170 Path::new(&self.0)
171 }
172 pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
174 let mut joined = base.to_path_buf();
175 for component in self.as_path().components() {
176 match component {
177 Component::Normal(seg) => joined.push(seg),
178 Component::CurDir => {}
179 Component::ParentDir => {
180 return Err(SourceSubpathError::Escaping {
181 input: self.0.clone(),
182 });
183 }
184 Component::RootDir | Component::Prefix(_) => {
185 return Err(SourceSubpathError::Absolute {
186 input: self.0.clone(),
187 });
188 }
189 }
190 }
191
192 if joined.strip_prefix(base).is_err() {
193 return Err(SourceSubpathError::Escaping {
194 input: self.0.clone(),
195 });
196 }
197
198 Ok(joined)
199 }
200}
201
202impl fmt::Display for SourceSubpath {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 f.write_str(&self.0)
205 }
206}
207
208impl AsRef<str> for SourceSubpath {
209 fn as_ref(&self) -> &str {
210 self.as_str()
211 }
212}
213
214impl std::str::FromStr for SourceSubpath {
215 type Err = SourceSubpathError;
216
217 fn from_str(s: &str) -> Result<Self, Self::Err> {
218 Self::new(s)
219 }
220}
221
222impl Serialize for SourceSubpath {
223 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
224 self.0.serialize(serializer)
225 }
226}
227
228impl<'de> Deserialize<'de> for SourceSubpath {
229 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
230 let value = String::deserialize(deserializer)?;
231 SourceSubpath::new(value).map_err(serde::de::Error::custom)
232 }
233}
234
235#[derive(Debug, thiserror::Error, PartialEq, Eq)]
236pub enum SourceSubpathError {
237 #[error("subpath cannot be empty")]
238 Empty,
239 #[error("subpath must be relative, got absolute value: {input:?}")]
240 Absolute { input: String },
241 #[error("subpath cannot escape package root: {input:?}")]
242 Escaping { input: String },
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct RenameRule {
247 pub from: ItemName,
248 pub to: ItemName,
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct RenameMap(Vec<RenameRule>);
254
255impl RenameMap {
256 pub fn new() -> Self {
257 Self(Vec::new())
258 }
259
260 pub fn insert(&mut self, from: ItemName, to: ItemName) {
261 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
262 existing.to = to;
263 return;
264 }
265 self.0.push(RenameRule { from, to });
266 }
267 pub fn get(&self, from: &str) -> Option<&ItemName> {
268 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
269 }
270}
271
272impl Serialize for RenameMap {
273 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
274 use serde::ser::SerializeMap;
275 let mut map = serializer.serialize_map(Some(self.0.len()))?;
276 for rule in &self.0 {
277 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
278 }
279 map.end()
280 }
281}
282
283impl<'de> Deserialize<'de> for RenameMap {
284 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
285 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
286 Ok(Self(
287 map.into_iter()
288 .map(|(from, to)| RenameRule {
289 from: ItemName::from(from),
290 to: ItemName::from(to),
291 })
292 .collect(),
293 ))
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use std::path::PathBuf;
301
302 #[test]
303 fn source_subpath_normalizes_windows_and_unix_separators() {
304 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
305 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
306 }
307
308 #[test]
309 fn source_subpath_rejects_empty() {
310 let err = SourceSubpath::new("").unwrap_err();
311 assert_eq!(err, SourceSubpathError::Empty);
312 }
313
314 #[test]
315 fn source_subpath_rejects_absolute() {
316 let err = SourceSubpath::new("/abs/path").unwrap_err();
317 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
318 }
319
320 #[test]
321 fn source_subpath_rejects_root_only() {
322 let err = SourceSubpath::new("/").unwrap_err();
323 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
324 }
325
326 #[test]
327 fn source_subpath_rejects_windows_absolute() {
328 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
329 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
330 }
331
332 #[test]
333 fn source_subpath_rejects_escape() {
334 let err = SourceSubpath::new("../escape").unwrap_err();
335 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
336 }
337
338 #[test]
339 fn source_subpath_accepts_nested_relative_path() {
340 let subpath = SourceSubpath::new("a/b/c").unwrap();
341 assert_eq!(subpath.as_str(), "a/b/c");
342 }
343
344 #[test]
345 fn source_subpath_serializes_with_forward_slashes() {
346 #[derive(Debug, Serialize, Deserialize, PartialEq)]
347 struct SubpathWrapper {
348 subpath: SourceSubpath,
349 }
350
351 let wrapper = SubpathWrapper {
352 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
353 };
354 let toml = toml::to_string(&wrapper).unwrap();
355 assert!(toml.contains("subpath = \"plugins/foo\""));
356 }
357
358 #[test]
359 fn source_subpath_join_under_base() {
360 let base = PathBuf::from("/tmp/mars");
361 let subpath = SourceSubpath::new("plugins/foo").unwrap();
362 let joined = subpath.join_under(&base).unwrap();
363 assert_eq!(joined, base.join("plugins").join("foo"));
364 }
365
366 #[test]
367 fn source_subpath_join_under_rejects_escape_path() {
368 let escaped = SourceSubpath(String::from("../escape"));
369 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
370 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
371 }
372
373 #[test]
374 fn source_subpath_rejects_windows_drive_forward_slash() {
375 let err = SourceSubpath::new("C:/foo").unwrap_err();
376 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
377 }
378
379 #[test]
380 fn source_subpath_rejects_current_dir_dot() {
381 let err = SourceSubpath::new(".").unwrap_err();
382 assert_eq!(err, SourceSubpathError::Empty);
383 }
384
385 #[test]
386 fn source_subpath_rejects_mid_path_double_parent_escape() {
387 let err = SourceSubpath::new("a/../../escape").unwrap_err();
388 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
389 }
390
391 #[test]
392 fn source_subpath_rejects_harmless_parent_in_middle() {
393 let err = SourceSubpath::new("a/b/../c").unwrap_err();
394 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
395 }
396
397 #[test]
398 fn source_subpath_normalizes_trailing_slash() {
399 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
400 assert_eq!(subpath.as_str(), "plugins/foo");
401 }
402
403 #[test]
404 fn source_subpath_normalizes_leading_dot_slash() {
405 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
406 assert_eq!(subpath.as_str(), "plugins/foo");
407 }
408
409 #[test]
410 fn rename_map_toml_roundtrip_compat() {
411 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
412 struct RenameWrapper {
413 rename: RenameMap,
414 }
415
416 let input = r#"rename = { "coder" = "cool-coder" }"#;
417 let parsed: RenameWrapper = toml::from_str(input).unwrap();
418 assert_eq!(
419 parsed.rename.get("coder").map(|v| v.as_str()),
420 Some("cool-coder")
421 );
422
423 let serialized = toml::to_string(&parsed).unwrap();
424 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
425 assert_eq!(parsed, reparsed);
426 }
427}