s3_path/
lib.rs

1pub mod error;
2mod validation;
3
4use crate::error::InvalidS3PathComponent;
5use std::borrow::Cow;
6use std::fmt::Formatter;
7use std::marker::PhantomData;
8use std::path::PathBuf;
9
10pub type S3PathComp<'i> = Cow<'i, str>;
11
12pub trait S3PathIter<'i, C>: IntoIterator<Item = C> + Clone {}
13
14impl<'i, C: Into<S3PathComp<'i>>, II: IntoIterator<Item = C> + Clone> S3PathIter<'i, C> for II {}
15
16/// A borrowed S3 storage path.
17#[derive(Clone)]
18pub struct S3Path<'i, C: Into<S3PathComp<'i>>, I: S3PathIter<'i, C>> {
19    components: I,
20    phantom_data: PhantomData<&'i ()>,
21    phantom_data2: PhantomData<C>,
22}
23
24impl<'i, C: Into<S3PathComp<'i>>, I: S3PathIter<'i, C>> S3Path<'i, C, I> {
25    pub fn try_from(components: I) -> Result<Self, InvalidS3PathComponent> {
26        for component in components.clone().into_iter() {
27            validation::validate_component(&component.into())?;
28        }
29        Ok(Self {
30            components,
31            phantom_data: PhantomData,
32            phantom_data2: PhantomData,
33        })
34    }
35
36    fn new_unchecked(components: I) -> Self {
37        Self {
38            components,
39            phantom_data: PhantomData,
40            phantom_data2: PhantomData,
41        }
42    }
43}
44
45impl<'i, C: Into<S3PathComp<'i>>, I: S3PathIter<'i, C>> std::fmt::Display for S3Path<'i, C, I> {
46    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
47        write_components(self.components.clone().into_iter().map(|it| it.into()), f)?;
48        Ok(())
49    }
50}
51
52impl<'i, C: Into<S3PathComp<'i>>, I: S3PathIter<'i, C>> std::fmt::Debug for S3Path<'i, C, I> {
53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54        write_components(self.components.clone().into_iter().map(|it| it.into()), f)?;
55        Ok(())
56    }
57}
58
59/// An owned S3 storage path.
60#[derive(Clone, PartialEq, Eq, Default)]
61pub struct S3PathBuf {
62    components: Vec<Cow<'static, str>>,
63}
64
65impl S3PathBuf {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    pub fn join(
71        &mut self,
72        component: impl Into<Cow<'static, str>>,
73    ) -> Result<&mut Self, InvalidS3PathComponent> {
74        let component_str = component.into();
75        validation::validate_component(component_str.as_ref())?;
76        self.components.push(component_str);
77        Ok(self)
78    }
79
80    pub fn as_path(&self) -> S3Path<Cow<str>, impl S3PathIter<Cow<str>>> {
81        // SAFETY: We can use `new_unchecked` here,
82        // because components were already validated when added!
83        S3Path::new_unchecked(self.components.iter().map(|it| Cow::Borrowed(it.as_ref())))
84    }
85
86    pub fn to_std_path_buf(&self) -> PathBuf {
87        let mut path = PathBuf::new();
88        for c in &self.components {
89            path = path.join(c.as_ref());
90        }
91        path
92    }
93}
94
95impl TryFrom<&str> for S3PathBuf {
96    type Error = InvalidS3PathComponent;
97
98    fn try_from(value: &str) -> Result<Self, Self::Error> {
99        let mut path = S3PathBuf::new();
100        for c in value.split('/') {
101            // Skip empty components from consecutive slashes
102            if !c.is_empty() {
103                path.join(Cow::Owned(c.to_string()))?;
104            }
105        }
106        Ok(path)
107    }
108}
109
110impl TryFrom<String> for S3PathBuf {
111    type Error = InvalidS3PathComponent;
112
113    fn try_from(value: String) -> Result<Self, Self::Error> {
114        S3PathBuf::try_from(value.as_str())
115    }
116}
117
118impl std::fmt::Display for S3PathBuf {
119    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
120        write_components(self.components.iter(), f)?;
121        Ok(())
122    }
123}
124
125impl std::fmt::Debug for S3PathBuf {
126    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
127        write_components(self.components.iter(), f)?;
128        Ok(())
129    }
130}
131
132fn write_components<C: AsRef<str>>(
133    components: impl Iterator<Item = C>,
134    f: &mut Formatter,
135) -> Result<(), std::fmt::Error> {
136    for (i, c) in components.enumerate() {
137        if i > 0 {
138            f.write_str("/")?;
139        }
140        f.write_str(c.as_ref())?;
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146mod test {
147    mod s3_path_buf {
148        use crate::S3PathBuf;
149        use assertr::prelude::*;
150
151        #[test]
152        fn new_is_initially_empty() {
153            let path = S3PathBuf::new();
154            assert_that(path).has_display_value("");
155        }
156
157        #[test]
158        fn construct_using_new_and_join_components() {
159            let mut path = S3PathBuf::new();
160            path.join("foo").unwrap();
161            path.join("bar").unwrap();
162            assert_that(path).has_display_value("foo/bar");
163        }
164
165        #[test]
166        fn construct_using_try_from_given_str() {
167            let path = S3PathBuf::try_from("foo/bar").unwrap();
168            assert_that(path).has_display_value("foo/bar");
169        }
170
171        #[test]
172        fn construct_using_try_from_given_string() {
173            let path = S3PathBuf::try_from("foo/bar".to_string()).unwrap();
174            assert_that(path).has_display_value("foo/bar");
175        }
176
177        #[test]
178        fn reject_invalid_characters() {
179            let mut path = S3PathBuf::new();
180            let result = path.join("invalid/path");
181            assert_that(result.is_err()).is_true();
182
183            let result = S3PathBuf::try_from("foo/bar$baz");
184            assert_that(result.is_err()).is_true();
185        }
186    }
187
188    mod s3_path {
189
190        mod new {
191            use crate::S3Path;
192            use assertr::prelude::*;
193
194            #[test]
195            fn from_static_str_array() {
196                let path = S3Path::try_from(["foo", "bar"]).unwrap();
197                assert_that(path).has_display_value("foo/bar");
198            }
199
200            #[test]
201            fn from_borrowed_str_array() {
202                let components = ["foo".to_string(), "bar".to_string()];
203                let components_ref = [components[0].as_str(), components[1].as_str()];
204                let path = S3Path::try_from(components_ref).unwrap();
205                assert_that(path).has_display_value("foo/bar");
206            }
207
208            #[test]
209            fn from_string_array() {
210                let path = S3Path::try_from(["foo".to_string(), "bar".to_string()]).unwrap();
211                assert_that(path).has_display_value("foo/bar");
212            }
213
214            #[test]
215            fn reject_invalid_characters() {
216                assert_that(S3Path::try_from(["foo-bar"]))
217                    .is_ok()
218                    .has_display_value("foo-bar");
219                assert_that(S3Path::try_from(["foo_bar"]))
220                    .is_ok()
221                    .has_display_value("foo_bar");
222                assert_that(S3Path::try_from(["foo.bar"]))
223                    .is_ok()
224                    .has_display_value("foo.bar");
225                assert_that(S3Path::try_from([".test"]))
226                    .is_ok()
227                    .has_display_value(".test");
228
229                assert_that(S3Path::try_from(["foo$bar"])).is_err();
230                assert_that(S3Path::try_from(["foo&bar"])).is_err();
231                assert_that(S3Path::try_from(["foo#bar"])).is_err();
232                assert_that(S3Path::try_from(["foo/bar"])).is_err();
233                assert_that(S3Path::try_from(["foo|bar"])).is_err();
234                assert_that(S3Path::try_from(["foo\\bar"])).is_err();
235                assert_that(S3Path::try_from(["."])).is_err();
236                assert_that(S3Path::try_from([".."])).is_err();
237            }
238        }
239    }
240}