1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
//! The slug that fixes a record's file or directory name (F-6, RQ-26, AC-245, AC-246): set
//! when the record is created and never changed by the formatter.
use std::collections::BTreeSet;
use std::fmt;
/// Lowercase ASCII letters and digits in runs joined by single hyphens.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Slug(String);
/// Text that is not a slug.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("`{0}` is not a slug: lowercase ASCII letters and digits in runs joined by single hyphens")]
pub struct SlugError(pub String);
/// The longest slug the derive rule writes.
const MAX_LENGTH: usize = 48;
/// Names an operating system reserves for devices, which no file or directory may take.
const RESERVED: [&str; 22] = [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
impl Slug {
/// Reads a slug, refusing text that breaks the syntax.
///
/// # Errors
///
/// The text, when it is not lowercase ASCII letters and digits in runs joined by single
/// hyphens.
pub fn parse(text: &str) -> Result<Self, SlugError> {
let valid = !text.is_empty()
&& text.split('-').all(|run| {
!run.is_empty()
&& run
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
});
if valid {
Ok(Self(text.to_owned()))
} else {
Err(SlugError(text.to_owned()))
}
}
/// The slug the Format's rule derives from a name, before collisions:
/// - `A`..`Z` become `a`..`z`, and nothing else is lowercased;
/// - each run of other characters becomes one hyphen, and the ends are trimmed;
/// - a slug longer than 48 characters is cut to its longest prefix of at most 48 that a
/// hyphen follows, or to 48 when there is none;
/// - an empty result takes `fallback`, the kind's name;
/// - a reserved device name takes the suffix `-1`.
#[must_use]
pub fn derive(name: &str, fallback: &str) -> Self {
let mut slug = String::with_capacity(name.len());
for character in name.chars() {
match character {
'a'..='z' | '0'..='9' => slug.push(character),
'A'..='Z' => slug.push(character.to_ascii_lowercase()),
_ if !slug.is_empty() && !slug.ends_with('-') => slug.push('-'),
_ => {}
}
}
let mut slug = slug.trim_end_matches('-').to_owned();
if slug.len() > MAX_LENGTH {
let cut = slug[..=MAX_LENGTH].rfind('-').unwrap_or(MAX_LENGTH);
slug.truncate(cut);
}
if slug.is_empty() {
fallback.clone_into(&mut slug);
}
if RESERVED.contains(&slug.as_str()) {
slug.push_str("-1");
}
Self(slug)
}
/// This slug, or the first of `-2`, `-3`, … appended to it that `taken` does not hold; the
/// result is added to `taken`.
#[must_use]
pub fn unique_in(self, taken: &mut BTreeSet<Self>) -> Self {
let mut candidate = self.clone();
let mut suffix = 2;
while taken.contains(&candidate) {
candidate = Self(format!("{}-{suffix}", self.0));
suffix += 1;
}
taken.insert(candidate.clone());
candidate
}
/// Makes the slugs of one directory unique, in order: each slug a later one repeats takes
/// the first free suffix (AC-246).
pub fn make_unique<'a>(slugs: impl IntoIterator<Item = &'a mut Self>) {
let mut taken = BTreeSet::new();
for slug in slugs {
*slug = std::mem::replace(slug, Self(String::new())).unique_in(&mut taken);
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Slug {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}