Skip to main content

libsurfer/
util.rs

1//! Utility functions.
2use crate::{displayed_item_tree::VisibleItemIndex, wave_data::WaveData};
3use camino::Utf8PathBuf;
4use egui::RichText;
5#[cfg(not(target_arch = "wasm32"))]
6use std::path::Path;
7
8/// This function takes a number and converts it's digits into the range
9/// a-p. This is nice because it makes for some easily typed ids.
10/// The function first formats the number as a hex digit and then performs
11/// the mapping.
12#[must_use]
13pub(crate) fn uint_idx_to_alpha_idx(idx: VisibleItemIndex, nvariables: usize) -> String {
14    // this calculates how many hex digits we need to represent nvariables
15    // unwrap because the result should always fit into usize and because
16    // we are not going to display millions of character ids.
17    let width = usize::try_from(nvariables.ilog(16)).unwrap() + 1;
18    format!("{:0width$x}", idx.0)
19        .chars()
20        .map(|c| match c {
21            '0' => 'a',
22            '1' => 'b',
23            '2' => 'c',
24            '3' => 'd',
25            '4' => 'e',
26            '5' => 'f',
27            '6' => 'g',
28            '7' => 'h',
29            '8' => 'i',
30            '9' => 'j',
31            'a' => 'k',
32            'b' => 'l',
33            'c' => 'm',
34            'd' => 'n',
35            'e' => 'o',
36            'f' => 'p',
37            _ => '?',
38        })
39        .collect()
40}
41
42/// This is the reverse function to `uint_idx_to_alpha_idx`.
43pub(crate) fn alpha_idx_to_uint_idx(idx: &str) -> Option<VisibleItemIndex> {
44    let mapped = idx
45        .chars()
46        .map(|c| match c {
47            'a' => '0',
48            'b' => '1',
49            'c' => '2',
50            'd' => '3',
51            'e' => '4',
52            'f' => '5',
53            'g' => '6',
54            'h' => '7',
55            'i' => '8',
56            'j' => '9',
57            'k' => 'a',
58            'l' => 'b',
59            'm' => 'c',
60            'n' => 'd',
61            'o' => 'e',
62            'p' => 'f',
63            _ => '?',
64        })
65        .collect::<String>();
66    usize::from_str_radix(&mapped, 16)
67        .ok()
68        .map(VisibleItemIndex)
69}
70
71#[must_use]
72pub(crate) fn get_alpha_focus_id(vidx: VisibleItemIndex, waves: &WaveData) -> RichText {
73    let alpha_id = uint_idx_to_alpha_idx(vidx, waves.displayed_items.len());
74
75    RichText::new(alpha_id).monospace()
76}
77
78/// This function searches upward from `start` for directories or files matching `item`.
79///
80/// It returns a `Vec<Utf8PathBuf>` to all found instances in order of closest to furthest away.
81/// The function only searches up within subdirectories of `end`. Paths that are not valid UTF-8
82/// are silently skipped.
83#[cfg(not(target_arch = "wasm32"))]
84pub(crate) fn search_upward(
85    start: impl AsRef<Path>,
86    end: impl AsRef<Path>,
87    item: impl AsRef<Path>,
88) -> Vec<Utf8PathBuf> {
89    start
90        .as_ref()
91        .ancestors()
92        .take_while(|p| p.starts_with(end.as_ref()))
93        .map(|p| p.join(&item))
94        .filter(|p| p.try_exists().is_ok_and(std::convert::identity))
95        .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok())
96        .collect()
97}
98
99fn get_multi_extension_from_filename(filename: &str) -> Option<String> {
100    filename
101        .find('.')
102        .map(|pos| filename[pos + 1..].to_string())
103}
104
105/// Get the full extension of a path, including all extensions.
106/// For example, for "foo.tar.gz", this function returns "tar.gz", and not just "gz",
107/// like `path.extension()` would.
108#[must_use]
109pub(crate) fn get_multi_extension(path: &Utf8PathBuf) -> Option<String> {
110    // Find the first . in the path, if any. Return the rest of the path.
111    if let Some(filename) = path.file_name() {
112        return get_multi_extension_from_filename(filename);
113    }
114    None
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_uint_idx_to_alpha_idx_basic_width_1() {
123        // nvariables determines hex width: width = ilog16(nvariables) + 1
124        // For nvariables = 1 => width = 1
125        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0), 1), "a");
126        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(9), 1), "j");
127        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(15), 1), "p");
128    }
129
130    #[test]
131    fn test_uint_idx_to_alpha_idx_zero_padded_width_2() {
132        // nvariables = 16 => width = 2 (since ilog16(16) == 1)
133        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0x0), 16), "aa");
134        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0x1), 16), "ab");
135        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0xf), 16), "ap");
136        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0x10), 16), "ba");
137        assert_eq!(uint_idx_to_alpha_idx(VisibleItemIndex(0x1f), 16), "bp");
138    }
139
140    #[test]
141    fn test_alpha_idx_to_uint_idx_roundtrip() {
142        // Try a selection across multiple widths
143        let cases = [
144            (VisibleItemIndex(0x0), 1),
145            (VisibleItemIndex(0x9), 1),
146            (VisibleItemIndex(0xf), 1),
147            (VisibleItemIndex(0x10), 16),
148            (VisibleItemIndex(0x2a), 256),
149            (VisibleItemIndex(0xabc), 4096),
150        ];
151
152        for (vidx, nvars) in cases {
153            let s = uint_idx_to_alpha_idx(vidx, nvars);
154            let back = alpha_idx_to_uint_idx(&s).expect("should parse back");
155            assert_eq!(back, vidx);
156        }
157    }
158
159    #[test]
160    fn test_alpha_idx_to_uint_idx_invalid_input() {
161        // Contains invalid character 'r' which is outside a-p
162        assert!(alpha_idx_to_uint_idx("ar").is_none());
163        // Empty string should fail to parse as hex
164        assert!(alpha_idx_to_uint_idx("").is_none());
165        // Mixed case / unexpected chars
166        assert!(alpha_idx_to_uint_idx("A").is_none());
167        assert!(alpha_idx_to_uint_idx("-").is_none());
168    }
169
170    #[test]
171    fn test_get_multi_extension_from_filename() {
172        assert_eq!(
173            get_multi_extension_from_filename("foo.tar.gz"),
174            Some("tar.gz".to_string())
175        );
176        assert_eq!(
177            get_multi_extension_from_filename("foo.txt"),
178            Some("txt".to_string())
179        );
180        assert_eq!(get_multi_extension_from_filename("foo"), None);
181        // Leading dot files: first dot at 0, extension is the remainder
182        assert_eq!(
183            get_multi_extension_from_filename(".bashrc"),
184            Some("bashrc".to_string())
185        );
186        // Trailing dot: extension becomes empty string
187        assert_eq!(
188            get_multi_extension_from_filename("foo."),
189            Some(String::new())
190        );
191    }
192
193    #[test]
194    fn test_get_multi_extension_from_path() {
195        let p = Utf8PathBuf::from("/tmp/foo/bar.tar.gz");
196        assert_eq!(get_multi_extension(&p), Some("tar.gz".to_string()));
197        let p = Utf8PathBuf::from("/tmp/foo/bar");
198        assert_eq!(get_multi_extension(&p), None);
199    }
200
201    #[test]
202    fn test_get_multi_extension_with_unicode() {
203        // Ensure Unicode before the first dot does not break slicing
204        // (previous implementation mixed byte and char indexing)
205        let name = "åäö.archive.tar.gz"; // multibyte chars before '.'
206        assert_eq!(
207            get_multi_extension_from_filename(name),
208            Some("archive.tar.gz".to_string())
209        );
210
211        // Only Unicode and then dot
212        let name2 = "ß.";
213        assert_eq!(
214            get_multi_extension_from_filename(name2),
215            Some(String::new())
216        );
217    }
218
219    #[cfg(not(target_arch = "wasm32"))]
220    #[test]
221    fn test_search_upward_finds_closest_first() {
222        use std::fs;
223        use std::io::Write;
224        use std::path::Path;
225
226        // Create a temporary directory structure: root/a/b/c
227        let tmp = tempfile::tempdir().expect("tempdir");
228        let root = tmp.path();
229        let a = root.join("a");
230        let b = a.join("b");
231        let c = b.join("c");
232        fs::create_dir_all(&c).expect("dirs");
233
234        // Place target file at c and at a
235        let item_name = Path::new("target.txt");
236        let item_c = c.join(item_name);
237        let item_a = a.join(item_name);
238        {
239            let mut f = fs::File::create(&item_c).expect("create c");
240            writeln!(f, "hello").unwrap();
241        }
242        {
243            let mut f = fs::File::create(&item_a).expect("create a");
244            writeln!(f, "world").unwrap();
245        }
246
247        // Start searching from c upwards, but only within root
248        let found = search_upward(&c, root, item_name);
249        // Expect closest-first order: c/target.txt, then a/target.txt
250        let expected =
251            [item_c, item_a].map(|p| Utf8PathBuf::from_path_buf(p).expect("valid UTF-8 temp path"));
252        assert_eq!(found, expected);
253    }
254}