neotron_api/path.rs
1//! Path related types.
2//!
3//! These aren't used in the API itself, but will be useful to code on both
4//! sides of the API, so they live here.
5
6// ============================================================================
7// Imports
8// ============================================================================
9
10// None
11
12// ============================================================================
13// Constants
14// ============================================================================
15
16// None
17
18// ============================================================================
19// Types
20// ============================================================================
21
22/// Represents a (borrowed) path to file.
23///
24/// Neotron OS uses the following format for file paths:
25///
26/// `<drive>:/[<directory>/]...<filename>.<extension>`
27///
28/// Unlike on MS-DOS, the `drive` specifier portion is not limited to a single
29/// ASCII letter and can be any UTF-8 string that does not contain `:` or `/`.
30///
31/// Typically drives will look like `DEV:` or `HD0:`, but that's not enforced
32/// here.
33///
34/// Paths are a sub-set of UTF-8 strings in this API, but be aware that not all
35/// filesystems support all Unicode characters. In particular FAT16 and FAT32
36/// volumes are likely to be limited to only `A-Z`, `a-z`, `0-9` and
37/// `$%-_@~\`!(){}^#&`. This API will expressly disallow UTF-8 codepoints below
38/// 32 (i.e. C0 control characters) to avoid confusion, but non-ASCII
39/// code-points are accepted.
40///
41/// Paths are case-preserving but file operations may not be case-sensitive
42/// (depending on the filesystem you are accessing). Paths may contain spaces
43/// (but your filesystem may not support that).
44///
45/// Here are some examples of valid paths:
46///
47/// ```text
48/// # relative to the Current Directory
49/// Documents/2023/June/Sales in €.xls
50/// # a file on drive HD0
51/// HD0:/MYDOCU~1/SALES.TXT
52/// # a directory on drive SD0
53/// SD0:/MYDOCU~1/
54/// # a file on drive SD0, with no file extension
55/// SD0:/BOOTLDR
56/// ```
57///
58/// Files and Directories generally have distinct APIs, so a directory without a
59/// trailing `/` is likely to be accepted. A file path with a trailing `/` won't
60/// be accepted.
61pub struct Path<'a>(&'a str);
62
63impl<'a> Path<'a> {
64 /// The character that separates one directory name from another directory name.
65 pub const PATH_SEP: char = '/';
66
67 /// The character that separates drive specifiers from directories.
68 pub const DRIVE_SEP: char = ':';
69
70 /// Create a path from a string.
71 ///
72 /// If the given string is not a valid path, an `Err` is returned.
73 pub fn new(path_str: &'a str) -> Result<Path<'a>, crate::Error> {
74 // No empty paths in drive specifier
75 if path_str.is_empty() {
76 return Err(crate::Error::InvalidPath);
77 }
78
79 if let Some((drive_specifier, directory_path)) = path_str.split_once(Self::DRIVE_SEP) {
80 if drive_specifier.contains(Self::PATH_SEP) {
81 // No slashes in drive specifier
82 return Err(crate::Error::InvalidPath);
83 }
84 if directory_path.contains(Self::DRIVE_SEP) {
85 // No colons in directory path
86 return Err(crate::Error::InvalidPath);
87 }
88 if !directory_path.is_empty() && !directory_path.starts_with(Self::PATH_SEP) {
89 // No relative paths if drive is specified. An empty path is OK (it means "/")
90 return Err(crate::Error::InvalidPath);
91 }
92 } else if path_str.starts_with(Self::PATH_SEP) {
93 // No absolute paths if drive is not specified
94 return Err(crate::Error::InvalidPath);
95 }
96 for ch in path_str.chars() {
97 if ch.is_control() {
98 // No control characters allowed
99 return Err(crate::Error::InvalidPath);
100 }
101 }
102 Ok(Path(path_str))
103 }
104
105 /// Is this an absolute path?
106 ///
107 /// Absolute paths have drive specifiers. Relative paths do not.
108 pub fn is_absolute_path(&self) -> bool {
109 self.drive_specifier().is_some()
110 }
111
112 /// Get the drive specifier for this path.
113 ///
114 /// * A path like `DS0:/FOO/BAR.TXT` has a drive specifier of `DS0`.
115 /// * A path like `BAR.TXT` has no drive specifier.
116 pub fn drive_specifier(&self) -> Option<&str> {
117 if let Some((drive_specifier, _directory_path)) = self.0.split_once(Self::DRIVE_SEP) {
118 Some(drive_specifier)
119 } else {
120 None
121 }
122 }
123
124 /// Get the drive path portion.
125 ///
126 /// That is, everything after the directory specifier.
127 pub fn drive_path(&self) -> Option<&str> {
128 if let Some((_drive_specifier, drive_path)) = self.0.split_once(Self::DRIVE_SEP) {
129 if drive_path.is_empty() {
130 // Bare drives are assumed to be at the root
131 Some("/")
132 } else {
133 Some(drive_path)
134 }
135 } else {
136 Some(self.0)
137 }
138 }
139
140 /// Get the directory portion of this path.
141 ///
142 /// * A path like `DS0:/FOO/BAR.TXT` has a directory portion of `/FOO`.
143 /// * A path like `DS0:/FOO/BAR/` has a directory portion of `/FOO/BAR`.
144 /// * A path like `BAR.TXT` has no directory portion.
145 pub fn directory(&self) -> Option<&str> {
146 let Some(drive_path) = self.drive_path() else {
147 return None;
148 };
149 if let Some((directory, _filename)) = drive_path.rsplit_once(Self::PATH_SEP) {
150 if directory.is_empty() {
151 // Bare drives are assumed to be at the root
152 Some("/")
153 } else {
154 Some(directory)
155 }
156 } else {
157 Some(drive_path)
158 }
159 }
160
161 /// Get the filename portion of this path. This filename will include the file extension, if any.
162 ///
163 /// * A path like `DS0:/FOO/BAR.TXT` has a filename portion of `/BAR.TXT`.
164 /// * A path like `DS0:/FOO` has a filename portion of `/FOO`.
165 /// * A path like `DS0:/FOO/` has no filename portion (so it's important directories have a trailing `/`)
166 pub fn filename(&self) -> Option<&str> {
167 let Some(drive_path) = self.drive_path() else {
168 return None;
169 };
170 if let Some((_directory, filename)) = drive_path.rsplit_once(Self::PATH_SEP) {
171 if filename.is_empty() {
172 None
173 } else {
174 Some(filename)
175 }
176 } else {
177 Some(drive_path)
178 }
179 }
180
181 /// Get the filename extension portion of this path.
182 ///
183 /// A path like `DS0:/FOO/BAR.TXT` has a filename extension portion of `TXT`.
184 /// A path like `DS0:/FOO/BAR` has no filename extension portion.
185 pub fn extension(&self) -> Option<&str> {
186 let Some(filename) = self.filename() else {
187 return None;
188 };
189 if let Some((_basename, extension)) = filename.rsplit_once('.') {
190 Some(extension)
191 } else {
192 None
193 }
194 }
195
196 /// View this [`Path`] as a string-slice.
197 pub fn as_str(&self) -> &str {
198 self.0
199 }
200}
201
202// ============================================================================
203// Functions
204// ============================================================================
205
206// None
207
208// ============================================================================
209// Tests
210// ============================================================================
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn full_path() {
218 let path_str = "HD0:/DOCUMENTS/JUNE/SALES.TXT";
219 let path = Path::new(path_str).unwrap();
220 assert!(path.is_absolute_path());
221 assert_eq!(path.drive_specifier(), Some("HD0"));
222 assert_eq!(path.drive_path(), Some("/DOCUMENTS/JUNE/SALES.TXT"));
223 assert_eq!(path.directory(), Some("/DOCUMENTS/JUNE"));
224 assert_eq!(path.filename(), Some("SALES.TXT"));
225 assert_eq!(path.extension(), Some("TXT"));
226 }
227
228 #[test]
229 fn bare_drive() {
230 let path_str = "HD0:";
231 let path = Path::new(path_str).unwrap();
232 assert!(path.is_absolute_path());
233 assert_eq!(path.drive_specifier(), Some("HD0"));
234 assert_eq!(path.drive_path(), Some("/"));
235 assert_eq!(path.directory(), Some("/"));
236 assert_eq!(path.filename(), None);
237 assert_eq!(path.extension(), None);
238 }
239
240 #[test]
241 fn relative_path() {
242 let path_str = "DOCUMENTS/JUNE/SALES.TXT";
243 let path = Path::new(path_str).unwrap();
244 assert!(!path.is_absolute_path());
245 assert_eq!(path.drive_specifier(), None);
246 assert_eq!(path.drive_path(), Some("DOCUMENTS/JUNE/SALES.TXT"));
247 assert_eq!(path.directory(), Some("DOCUMENTS/JUNE"));
248 assert_eq!(path.filename(), Some("SALES.TXT"));
249 assert_eq!(path.extension(), Some("TXT"));
250 }
251
252 #[test]
253 fn full_dir() {
254 let path_str = "HD0:/DOCUMENTS/JUNE/";
255 let path = Path::new(path_str).unwrap();
256 assert!(path.is_absolute_path());
257 assert_eq!(path.drive_specifier(), Some("HD0"));
258 assert_eq!(path.drive_path(), Some("/DOCUMENTS/JUNE/"));
259 assert_eq!(path.directory(), Some("/DOCUMENTS/JUNE"));
260 assert_eq!(path.filename(), None);
261 assert_eq!(path.extension(), None);
262 }
263
264 #[test]
265 fn relative_dir() {
266 let path_str = "DOCUMENTS/";
267 let path = Path::new(path_str).unwrap();
268 assert!(!path.is_absolute_path());
269 assert_eq!(path.drive_specifier(), None);
270 assert_eq!(path.drive_path(), Some("DOCUMENTS/"));
271 assert_eq!(path.directory(), Some("DOCUMENTS"));
272 assert_eq!(path.filename(), None);
273 assert_eq!(path.extension(), None);
274 }
275}
276
277// ============================================================================
278// End of File
279// ============================================================================