koan_core/helpers/
text.rs1use std::path::Path;
4
5pub fn shuffle<T>(items: &mut [T]) {
10 let Some(mut rng) = Rng::seeded() else {
11 return; };
13 for i in (1..items.len()).rev() {
14 items.swap(i, rng.below(i + 1));
15 }
16}
17
18pub 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 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
38pub 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
50pub 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
72pub 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
85pub 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
94pub 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 assert_eq!(year_of("1997"), None);
110 }
111}