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// ============================================================================