Skip to main content

koan_core/helpers/
text.rs

1//! String and path utilities.
2
3use std::path::Path;
4
5/// Fisher-Yates over a fresh seed, so consecutive calls differ.
6///
7/// Deliberately not seeded from anything stable: "shuffle again" has to
8/// actually produce a new order, which a process-lifetime seed wouldn't.
9pub fn shuffle<T>(items: &mut [T]) {
10    let Some(mut rng) = Rng::seeded() else {
11        return; // Leave the order alone rather than pretending to shuffle.
12    };
13    for i in (1..items.len()).rev() {
14        items.swap(i, rng.below(i + 1));
15    }
16}
17
18/// xorshift64 over a fresh seed — plenty for shuffling a list nobody is
19/// betting on.
20pub struct Rng(u64);
21
22impl Rng {
23    pub fn seeded() -> Option<Self> {
24        let mut seed = [0u8; 8];
25        getrandom::fill(&mut seed).ok()?;
26        Some(Self(u64::from_le_bytes(seed) | 1))
27    }
28
29    /// A number in `0..n`. `n` must not be zero.
30    pub fn below(&mut self, n: usize) -> usize {
31        self.0 ^= self.0 << 13;
32        self.0 ^= self.0 >> 7;
33        self.0 ^= self.0 << 17;
34        (self.0 % n as u64) as usize
35    }
36}
37
38/// Truncate a string to at most `max` bytes, cutting on a char boundary.
39pub fn truncate_bytes(s: &str, max: usize) -> &str {
40    if s.len() <= max {
41        return s;
42    }
43    let mut end = max;
44    while end > 0 && !s.is_char_boundary(end) {
45        end -= 1;
46    }
47    &s[..end]
48}
49
50/// Sanitise and truncate a string for use as a path component.
51/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
52/// `.` and `..` become `_`: tags and server metadata are untrusted, and either
53/// would move the path out of the directory it is joined onto.
54pub fn sanitise_filename(s: &str) -> String {
55    let cleaned: String = s
56        .chars()
57        .map(|c| match c {
58            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
59            _ => c,
60        })
61        .collect::<String>()
62        .trim()
63        .to_string();
64
65    let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
66    match cleaned.as_str() {
67        "." | ".." => "_".into(),
68        _ => cleaned,
69    }
70}
71
72/// A file extension from a codec name, which for remote tracks is whatever
73/// the server sent as `suffix`. ASCII alphanumerics only, so it can never
74/// carry a separator or a `..`; `None` when nothing usable is left.
75pub fn sanitise_extension(codec: &str) -> Option<String> {
76    let ext: String = codec
77        .chars()
78        .filter(char::is_ascii_alphanumeric)
79        .take(16)
80        .collect::<String>()
81        .to_lowercase();
82    (!ext.is_empty()).then_some(ext)
83}
84
85/// Whether `path` lies inside `dir` without leaving it on the way — every
86/// component after the prefix is a plain name.
87pub fn path_within(dir: &Path, path: &Path) -> bool {
88    path.strip_prefix(dir).is_ok_and(|rest| {
89        rest.components()
90            .all(|c| matches!(c, std::path::Component::Normal(_)))
91    })
92}
93
94/// The year a tag date starts with. `get`, not a slice: a date is free text,
95/// and a multibyte character in its first four bytes would panic a slice.
96pub fn year_of(date: &str) -> Option<&str> {
97    date.get(..4)
98}
99
100#[cfg(test)]
101mod year_tests {
102    use super::year_of;
103
104    #[test]
105    fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
106        assert_eq!(year_of("1997-05-21"), Some("1997"));
107        assert_eq!(year_of("199"), None);
108        // Full-width digits: four bytes in is mid-character.
109        assert_eq!(year_of("1997"), None);
110    }
111}