humble_cli/
util.rs

1use byte_unit::{Byte, UnitType};
2use std::{collections::HashSet, future::Future};
3
4pub fn run_future<F, T>(input: F) -> T
5where
6    F: Future<Output = T>,
7{
8    let runtime = tokio::runtime::Runtime::new().unwrap();
9    runtime.block_on(input)
10}
11
12pub fn humanize_bytes(bytes: u64) -> String {
13    let b = Byte::from_u64(bytes).get_appropriate_unit(UnitType::Binary);
14    format!("{b:.2}")
15}
16
17// Convert a string representing a byte size (e.g. 12MB) to a number.
18// It supports the IEC (KiB MiB ...) and KB MB ... formats.
19pub fn byte_string_to_number(byte_string: &str) -> Option<u64> {
20    Byte::parse_str(byte_string, true).map(|b| b.into()).ok()
21}
22
23pub fn replace_invalid_chars_in_filename(input: &str) -> String {
24    let replacement: char = ' ';
25    let invalid_chars: Vec<char> = vec![
26        '/', '\\', '?', '%', '*', ':', '|', '"', '<', '>', ';', '=', '\n',
27    ];
28
29    input
30        .chars()
31        .map(|c| {
32            if invalid_chars.contains(&c) {
33                replacement
34            } else {
35                c
36            }
37        })
38        .collect::<String>()
39        .trim()
40        .to_string()
41}
42
43pub fn extract_filename_from_url(url: &str) -> Option<String> {
44    let url = reqwest::Url::parse(url);
45    if url.is_err() {
46        return None;
47    }
48
49    let url = url.unwrap();
50    let path_segments: Vec<&str> = url.path_segments()?.collect();
51    match path_segments[path_segments.len() - 1] {
52        "" => None,
53        segment => Some(segment.to_string()),
54    }
55}
56
57pub fn str_vectors_intersect<T1, T2>(first: &[T1], second: &[T2]) -> bool
58where
59    T1: AsRef<str>,
60    T2: AsRef<str>,
61{
62    if first.is_empty() || second.is_empty() {
63        return false;
64    }
65
66    let mut first_set = HashSet::new();
67
68    for first_item in first {
69        first_set.insert(first_item.as_ref().to_lowercase());
70    }
71
72    for second_item in second {
73        if first_set.contains(&second_item.as_ref().to_lowercase()) {
74            return true;
75        }
76    }
77
78    false
79}
80
81/// Parse the given `usize` range and return the values in that range as a `Vector`.
82///
83/// Value formats are:
84/// - A single value: 42
85/// - A range with beginning and end (1-5): Returns all valus between those two numbers (inclusive).
86/// - A range with no end (10-): In this case, `max_value` specifies the end of the range.
87/// - A range with no beginning (-5): In this case, the range begins at `1`.
88///
89/// Note: the range starts at `1`, **not** `0`.
90pub fn parse_usize_range(value: &str, max_value: usize) -> Option<Vec<usize>> {
91    let dash_idx = value.find('-');
92
93    if dash_idx.is_none() {
94        return value.parse::<usize>().map(|v| vec![v]).ok();
95    }
96
97    let dash_idx = dash_idx.unwrap();
98
99    let left = &value[0..dash_idx];
100    let right = &value[dash_idx + 1..];
101
102    let range_left = if !left.is_empty() {
103        match left.parse::<usize>() {
104            Ok(v) => v,
105            Err(_) => return None,
106        }
107    } else {
108        1
109    };
110
111    let range_right = if !right.is_empty() {
112        match right.parse::<usize>() {
113            Ok(v) => v,
114            Err(_) => return None,
115        }
116    } else {
117        max_value
118    };
119
120    // These min and max values are intentional:
121    // min value is `1` and max value is `max_value + 1`
122    Some((range_left..range_right + 1).collect())
123}
124
125pub fn union_usize_ranges(values: &[&str], max_value: usize) -> Result<Vec<usize>, anyhow::Error> {
126    let mut invalid_values = vec![];
127    let mut parsed = HashSet::new();
128
129    for &v in values {
130        match parse_usize_range(v, max_value) {
131            Some(usize_values) => parsed.extend(usize_values),
132            None => invalid_values.push(v),
133        }
134    }
135
136    if !invalid_values.is_empty() {
137        let msg = invalid_values
138            .into_iter()
139            .map(|v| format!("'{}'", v))
140            .collect::<Vec<_>>()
141            .join(", ");
142
143        return Err(anyhow::anyhow!("{}", msg));
144    }
145
146    let mut output = Vec::from_iter(parsed);
147    output.sort();
148    Ok(output)
149}
150
151#[test]
152fn test_remove_invalid_chars() {
153    let test_data = vec![
154        ("Humble Bundle: Nice book", "Humble Bundle  Nice book"),
155        ("::Make::", "Make"),
156        ("Test\nFile", "Test File"),
157    ];
158
159    for (input, expected) in test_data {
160        let got = replace_invalid_chars_in_filename(input);
161        assert_eq!(expected, got);
162    }
163}
164
165#[test]
166fn test_extract_filename_from_url() {
167    let test_data = vec![(
168        "with filename",
169        "https://dl.humble.com/grokkingalgorithms.mobi?gamekey=xxxxxx&ttl=1655031034&t=yyyyyyyyyy",
170        Some("grokkingalgorithms.mobi".to_string()),
171    ), (
172        "no filename",
173        "https://www.google.com/",
174        None
175    )];
176
177    for (name, url, expected) in test_data {
178        assert_eq!(
179            extract_filename_from_url(url),
180            expected,
181            "test case '{}'",
182            name
183        );
184    }
185}
186
187#[test]
188/// A test to make sure `humanize_bytes` function works as expected.
189///
190/// We rely on an external library to do this for us, but still a good
191/// idea to have a small test to make sure the library is not broken :-)
192fn test_humanize_bytes() {
193    let test_data = vec![(1, "1 B"), (3 * 1024, "3.00 KiB")];
194
195    for (input, want) in test_data {
196        assert_eq!(humanize_bytes(input), want.to_string());
197    }
198}
199#[test]
200fn test_vectors_intersect() {
201    let test_data = vec![
202        (vec!["FOO", "bar"], vec!["foo"], true),
203        (vec!["foo", "bar"], vec!["baz"], false),
204        (vec!["foo"], vec![], false),
205        (vec![], vec!["baz"], false),
206    ];
207
208    for (first, second, result) in test_data {
209        let msg = format!(
210            "intersect of {:?} and {:?}, expected: {}",
211            first, second, result
212        );
213        assert_eq!(str_vectors_intersect(&first, &second), result, "{}", msg);
214    }
215}
216
217#[test]
218fn test_parse_usize_range() {
219    const MAX_VAL: usize = 50;
220
221    let test_data = vec![
222        ("empty string", "", None),
223        ("invalid string", "abcd", None),
224        ("single value", "42", Some(vec![42])),
225        (
226            "range with start and end",
227            "5-10",
228            Some(vec![5, 6, 7, 8, 9, 10]),
229        ),
230        ("range with no start", "-5", Some(vec![1, 2, 3, 4, 5])),
231        (
232            "range with no end",
233            "45-",
234            Some(vec![45, 46, 47, 48, 49, 50]),
235        ), // 50 is MAX_VAL
236        ("invalid start", "abc-", None),
237        ("invalid end", "-abc", None),
238        ("invalid start and end", "abc-def", None),
239    ];
240
241    for (name, input, expected) in test_data {
242        let msg = format!(
243            "'{}' failed: input = {}, expected = {:?}",
244            name, input, &expected
245        );
246        assert_eq!(parse_usize_range(input, MAX_VAL), expected, "{}", msg);
247    }
248}
249
250#[test]
251fn test_union_valid_usize_ranges() {
252    const MAX_VAL: usize = 10;
253    let test_data = vec![
254        ("simple values", vec!["5", "10"], vec![5, 10]),
255        ("simple value and range", vec!["8", "7-"], vec![7, 8, 9, 10]),
256        ("two ranges", vec!["-3", "7-"], vec![1, 2, 3, 7, 8, 9, 10]),
257    ];
258
259    for (name, input, expected) in test_data {
260        let output = union_usize_ranges(&input, MAX_VAL);
261
262        let msg = format!(
263            "'{}' failed: input = {:?}, expected = {:?}",
264            name, &input, &expected
265        );
266
267        assert!(output.is_ok(), "{}", msg);
268        assert_eq!(output.unwrap(), expected, "{}", msg);
269    }
270}
271
272#[test]
273fn test_union_invalid_usize_ranges() {
274    const MAX_VAL: usize = 10;
275    let test_data = vec![
276        ("invalid simple values", vec!["a", "b"]),
277        ("invalid ranges", vec!["a-", "-b"]),
278    ];
279
280    for (name, input) in test_data {
281        // expected error message
282        let expected_err_msg = input
283            .iter()
284            .map(|v| format!("'{}'", v))
285            .collect::<Vec<_>>()
286            .join(", ");
287
288        let output = union_usize_ranges(&input, MAX_VAL);
289
290        let assert_msg = format!(
291            "'{}' failed: input = {:?}, expected = {:?}",
292            name, &input, &expected_err_msg
293        );
294
295        assert!(output.is_err(), "{}", assert_msg);
296        let output_err_msg: String = output.unwrap_err().downcast().unwrap();
297        assert_eq!(output_err_msg, expected_err_msg, "{}", assert_msg);
298    }
299}
300
301#[cfg(target_os = "windows")]
302#[test]
303fn test_windows_filename_validation() {
304    use std::fs::File;
305
306    // These are actual forbidden characters on Windows; they are a subset of what
307    // `replace_invalid_chars_in_filename` replaces.
308    let invalid_chars = vec!['/', '\\', '?', '*', '|', '"', '<', '>', '\n'];
309
310    for &c in &invalid_chars {
311        let filename = format!("test_{}_file.txt", c);
312        // Attempt to create file with invalid character
313        let result = File::create(&filename);
314        assert!(
315            result.is_err(),
316            "Expected error creating file with invalid character '{}'",
317            c
318        );
319
320        // Replace invalid characters
321        let cleaned = replace_invalid_chars_in_filename(&filename);
322        assert_ne!(
323            cleaned, filename,
324            "Filename not cleaned for character '{}'",
325            c
326        );
327
328        // Attempt to create file with cleaned name
329        File::create(&cleaned).expect(&format!(
330            "Failed to create cleaned file for character '{}'",
331            c
332        ));
333        // Clean up the file after test
334        std::fs::remove_file(&cleaned).expect("Failed to remove test file");
335    }
336}