coven_foundation/files/relative_path.rs
1//! A path an app names below a folder the person picked, as names, and the
2//! name coven writes for each: one rule, the same on every platform, that
3//! makes a name valid on any volume a copy can land on (§17.3).
4
5use std::fmt;
6use std::path::PathBuf;
7
8/// A path below a folder, as the names the app gives, outermost first: an
9/// album's name, then a track's. Each name is one name on every platform,
10/// whatever it holds, `/` and `\` included. Coven writes each through one
11/// rule that makes it valid on any volume (§17.3), so joined to the folder
12/// the path stays inside it.
13#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
14pub struct RelativePath(Vec<String>);
15
16/// Why names can't be a `RelativePath`.
17#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
18pub enum RelativePathError {
19 /// The path has no names, or a name is empty.
20 #[error("the path or one of its names is empty")]
21 Empty,
22}
23
24impl RelativePath {
25 /// The path of `names`, outermost first. Each must be non-empty.
26 pub fn new<S: Into<String>>(
27 names: impl IntoIterator<Item = S>,
28 ) -> Result<Self, RelativePathError> {
29 let names: Vec<String> = names.into_iter().map(Into::into).collect();
30 if names.is_empty() || names.iter().any(String::is_empty) {
31 return Err(RelativePathError::Empty);
32 }
33 Ok(Self(names))
34 }
35
36 /// Its names as the app gave them, outermost first.
37 pub fn names(&self) -> impl Iterator<Item = &str> {
38 self.0.iter().map(String::as_str)
39 }
40
41 /// The path coven writes for it below a folder: each name as the one
42 /// rule for every platform makes it (§17.3), joined by this platform's
43 /// separator. An app writing a file of its own under a name it stores
44 /// writes it here, so its names land as coven's copies do.
45 pub fn local_path(&self) -> PathBuf {
46 self.names().map(local_name).collect()
47 }
48
49 /// The path of its first `count` names.
50 pub(super) fn first(&self, count: usize) -> Self {
51 Self(self.0[..count].to_vec())
52 }
53}
54
55/// The names joined by `/`, for showing only: a name may hold `/` itself.
56impl fmt::Display for RelativePath {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(&self.0.join("/"))
59 }
60}
61
62/// The longest name coven writes: 255 bytes of UTF-8, the limit of ext4,
63/// f2fs and Android's shared storage, which also covers the 255 UTF-16
64/// units of NTFS, exFAT, FAT and APFS, since no character takes more UTF-16
65/// units than UTF-8 bytes. HFS+ counts 255 UTF-16 units of the name
66/// decomposed, where `Ǖ`, two bytes of UTF-8, takes three, so that counts
67/// too.
68pub(super) const NAME_LIMIT: usize = 255;
69
70/// The name coven writes for `name`, the same on every platform, since what
71/// a name may hold is the volume's rule, not the platform's (§17.3):
72///
73/// 1. `" * : < > ? \ | /`, which Windows, Android's shared storage and FAT
74/// volumes refuse, become their full-width lookalikes; control characters
75/// and DEL become their pictures, `␀`–`␟` and `␡`.
76/// 2. A last `.` becomes `.` and a last space `␠`, since Windows and FAT
77/// drop them. So `.` and `..` are ordinary names, `.` and `..`.
78/// 3. A Windows device name, `CON.flac` among them, has its letters in full
79/// width: `CON.flac`.
80/// 4. A name past `NAME_LIMIT` keeps its extension and loses whole
81/// characters, as a person reads them, from the end of the part before
82/// it, with `…` where it was cut.
83///
84/// The result is never empty, `.` or `..`, and holds no separator, so it is
85/// one name inside the folder on every platform.
86pub(super) fn local_name(name: &str) -> String {
87 let mut local: String = name.chars().map(lookalike).collect();
88 if let Some(last) = local.pop() {
89 local.push(match last {
90 '.' => '.',
91 ' ' => '␠',
92 last => last,
93 });
94 }
95 let device = local[..local.find('.').unwrap_or(local.len())].trim_end_matches(' ');
96 if windows_device(device) {
97 let wide: String = device.chars().map(full_width).collect();
98 let end = device.len();
99 local.replace_range(..end, &wide);
100 }
101 fit(local)
102}
103
104/// Whether `base`, a name's part before its first `.` without trailing
105/// spaces, names a Windows device: Windows opens the device for it, with any
106/// extension on some versions, and in any ASCII case. `¹`, `²` and `³` count
107/// as digits.
108pub fn windows_device(base: &str) -> bool {
109 let base = base.to_ascii_uppercase();
110 if matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") {
111 return true;
112 }
113 let Some(number) = base.strip_prefix("COM").or_else(|| base.strip_prefix("LPT")) else {
114 return false;
115 };
116 let mut number = number.chars();
117 matches!((number.next(), number.next()), (Some('1'..='9' | '¹' | '²' | '³'), None))
118}
119
120/// A character no volume of the four platforms refuses in a name.
121fn lookalike(character: char) -> char {
122 match character {
123 '"' | '*' | ':' | '<' | '>' | '?' | '\\' | '|' | '/' => full_width(character),
124 '\0'..='\x1f' => char::from_u32(0x2400 + u32::from(character))
125 .expect("Control Pictures has a picture for each control character"),
126 '\x7f' => '␡',
127 _ => character,
128 }
129}
130
131/// An ASCII character's full-width form; anything else as it is.
132fn full_width(character: char) -> char {
133 match character {
134 '!'..='~' => char::from_u32(u32::from(character) + 0xFEE0)
135 .expect("Halfwidth and Fullwidth Forms has each printable ASCII character"),
136 _ => character,
137 }
138}
139
140/// `name`, cut to `NAME_LIMIT` if it is longer. The extension, from the
141/// last `.`, stays when at least one character before it fits too;
142/// otherwise the name is cut from its own end. A cut falls between
143/// characters as a person reads them (grapheme clusters), so an accent is
144/// never cut from its letter, except within one such character too long
145/// for a name by itself. The ending `…` is neither dot nor space, so a cut
146/// name stays valid.
147fn fit(name: String) -> String {
148 if fits(&name) {
149 return name;
150 }
151 let (stem, extension) = match name.rfind('.') {
152 Some(dot) if dot > 0 => name.split_at(dot),
153 _ => (name.as_str(), ""),
154 };
155 let graphemes = |text: &str| -> Vec<usize> {
156 icu_segmenter::GraphemeClusterSegmenter::new().segment_str(text).collect()
157 };
158 let characters = |text: &str| -> Vec<usize> {
159 text.char_indices().map(|(index, _)| index).chain([text.len()]).collect()
160 };
161 cut(stem, extension, &graphemes(stem))
162 .or_else(|| cut(&name, "", &graphemes(&name)))
163 .or_else(|| cut(&name, "", &characters(&name)))
164 .expect("one character, `…` and nothing else fit in a name")
165}
166
167/// The longest of `text` up to one of `ends`, then `…` and `extension`,
168/// that fits; none when no end past the start does.
169fn cut(text: &str, extension: &str, ends: &[usize]) -> Option<String> {
170 let room = NAME_LIMIT.checked_sub('…'.len_utf8() + extension.len())?;
171 ends.iter()
172 .rev()
173 .filter(|&&end| end > 0 && end <= room)
174 .map(|&end| format!("{}…{extension}", &text[..end]))
175 .find(|candidate| fits(candidate))
176}
177
178/// Whether `name` is within `NAME_LIMIT` in UTF-8 bytes and, decomposed,
179/// in UTF-16 units.
180fn fits(name: &str) -> bool {
181 name.len() <= NAME_LIMIT
182 && icu_normalizer::DecomposingNormalizerBorrowed::new_nfd()
183 .normalize(name)
184 .encode_utf16()
185 .count()
186 <= NAME_LIMIT
187}
188
189#[cfg(test)]
190#[path = "relative_path_tests.rs"]
191mod tests;