Skip to main content

autopulse_utils/
runtime_path.rs

1use std::fmt;
2use typed_path::Utf8TypedPath;
3
4#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5pub enum RuntimePathFlavor {
6    Unix,
7    Windows,
8}
9
10#[derive(Clone, Copy, Debug)]
11pub struct RuntimePath<'a> {
12    source: &'a str,
13    inner: Utf8TypedPath<'a>,
14}
15
16#[derive(Clone, Copy)]
17enum CaseSensitivity {
18    Sensitive,
19    AsciiInsensitive,
20}
21
22impl<'a> RuntimePath<'a> {
23    pub fn new(path: &'a str) -> Self {
24        Self {
25            source: path,
26            inner: Utf8TypedPath::derive(path),
27        }
28    }
29
30    pub fn flavor(&self) -> RuntimePathFlavor {
31        if self.inner.is_unix() {
32            RuntimePathFlavor::Unix
33        } else {
34            RuntimePathFlavor::Windows
35        }
36    }
37
38    pub fn as_str(&self) -> &'a str {
39        self.source
40    }
41
42    pub fn starts_with(&self, base: RuntimePath<'_>) -> bool {
43        self.starts_with_mode(base, self.flavor().default_sensitivity())
44    }
45
46    pub fn starts_with_case_sensitive(&self, base: RuntimePath<'_>) -> bool {
47        self.starts_with_mode(base, CaseSensitivity::Sensitive)
48    }
49
50    pub fn starts_with_ascii_case_insensitive(&self, base: RuntimePath<'_>) -> bool {
51        self.starts_with_mode(base, CaseSensitivity::AsciiInsensitive)
52    }
53
54    /// Not `PartialEq`: applies flavor-dependent case folding (ASCII-insensitive
55    /// on Windows), so callers opt in by name instead of via `==`.
56    pub fn equals(&self, other: RuntimePath<'_>) -> bool {
57        if self.flavor() != other.flavor() {
58            return false;
59        }
60
61        let sensitivity = self.flavor().default_sensitivity();
62
63        let mut self_components = self.inner.components();
64        let mut other_components = other.inner.components();
65
66        loop {
67            match (self_components.next(), other_components.next()) {
68                (Some(a), Some(b)) => {
69                    if !sensitivity.components_match(a.as_str(), b.as_str()) {
70                        return false;
71                    }
72                }
73                (None, None) => return true,
74                _ => return false,
75            }
76        }
77    }
78
79    pub fn component_count(&self) -> usize {
80        self.inner.components().count()
81    }
82
83    pub fn normal_components(&self) -> impl DoubleEndedIterator<Item = &str> + '_ {
84        self.inner
85            .components()
86            .filter(|component| component.is_normal())
87            .map(|component| component.as_str())
88    }
89
90    pub fn file_name(&self) -> Option<&str> {
91        self.inner.file_name()
92    }
93
94    /// Syntactic check on the path string, not a filesystem stat; can't
95    /// distinguish an extension-less file from a dir like `Season.1`.
96    pub fn is_file(&self) -> bool {
97        self.inner.extension().is_some()
98    }
99
100    pub fn is_directory(&self) -> bool {
101        !self.is_file()
102    }
103
104    /// Slices `source` instead of using `Utf8TypedPath::parent()` directly,
105    /// to keep the `'a` borrow and the original (non-re-serialized) rendering.
106    pub fn parent_or_self(&self) -> RuntimePath<'a> {
107        let source = if self.is_file() {
108            let length = self
109                .inner
110                .parent()
111                .map_or(self.source.len(), |parent| parent.as_str().len());
112            &self.source[..length]
113        } else {
114            self.source
115        };
116        RuntimePath::new(source)
117    }
118
119    fn starts_with_mode(&self, base: RuntimePath<'_>, sensitivity: CaseSensitivity) -> bool {
120        if self.flavor() != base.flavor() {
121            return false;
122        }
123
124        let mut components = self.inner.components();
125        base.inner.components().all(|base_component| {
126            components.next().is_some_and(|component| {
127                sensitivity.components_match(component.as_str(), base_component.as_str())
128            })
129        })
130    }
131}
132
133impl RuntimePathFlavor {
134    /// Unix compares byte-for-byte; Windows folds ASCII case, matching
135    /// each platform's filesystem semantics.
136    fn default_sensitivity(self) -> CaseSensitivity {
137        match self {
138            RuntimePathFlavor::Unix => CaseSensitivity::Sensitive,
139            RuntimePathFlavor::Windows => CaseSensitivity::AsciiInsensitive,
140        }
141    }
142}
143
144impl CaseSensitivity {
145    fn components_match(self, a: &str, b: &str) -> bool {
146        match self {
147            CaseSensitivity::Sensitive => a == b,
148            CaseSensitivity::AsciiInsensitive => a.eq_ignore_ascii_case(b),
149        }
150    }
151}
152
153impl fmt::Display for RuntimePath<'_> {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        formatter.write_str(self.as_str())
156    }
157}