pineapple_core/ut/
path.rs

1// Copyright (c) 2025, Tom Ouellette
2// Licensed under the BSD 3-Clause License
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use rayon::prelude::*;
8
9use crate::error::PineappleError;
10
11/// Ensures a new directory is created with an incrementing suffix if necessary.
12///
13/// # Arguments
14///
15/// * `directory` - Path to new directory - no overwrites allowed
16///
17/// # Examples
18///
19/// ```
20/// use std::path::Path;
21/// use pineapple_core::ut::path::create_directory;
22///
23/// let base = Path::new("TEST_CREATE_DIRECTORY");
24///
25/// std::fs::create_dir(base).unwrap();
26/// assert!(base.exists());
27///
28/// let increment_0 = Path::new("TEST_CREATE_DIRECTORY_0/");
29/// let increment_1 = Path::new("TEST_CREATE_DIRECTORY_1/");
30/// let increment_2 = Path::new("TEST_CREATE_DIRECTORY_2/");
31///
32/// create_directory(base);
33/// create_directory(base);
34/// create_directory(base);
35///
36/// assert!(increment_0.exists());
37/// assert!(increment_1.exists());
38/// assert!(increment_2.exists());
39///
40/// std::fs::remove_dir(base);
41/// std::fs::remove_dir(increment_0);
42/// std::fs::remove_dir(increment_1);
43/// std::fs::remove_dir(increment_2);
44/// ```
45pub fn create_directory<P: AsRef<Path>>(directory: P) -> Result<PathBuf, PineappleError> {
46    let directory = directory.as_ref();
47
48    if !directory.exists() {
49        std::fs::create_dir(directory).map_err(|err| PineappleError::DirError(err.to_string()))?;
50        return Ok(directory.to_path_buf());
51    }
52
53    let parent = directory.parent().unwrap_or_else(|| Path::new("."));
54    let base_name = directory
55        .file_name()
56        .and_then(|name| name.to_str())
57        .ok_or_else(|| PineappleError::DirError("Invalid directory name".to_string()))?;
58
59    for index in 0..30 {
60        let new_dir = parent.join(format!("{}_{}", base_name, index));
61
62        if !new_dir.exists() {
63            std::fs::create_dir(&new_dir).map_err(|err| PineappleError::DirError(err.to_string()))?;
64            return Ok(new_dir);
65        }
66    }
67
68    Err(PineappleError::DirError(format!(
69        "Could not create a directory in alotted increments. Check the directory path: {}",
70        directory.display()
71    )))
72}
73
74/// Collect file paths from a directory with an optional substring filter
75///
76/// # Arguments
77///
78/// * `directory` - Path to directory containing files
79/// * `substring` - Only include files containing this substring
80///
81/// # Examples
82///
83/// ```no_run
84/// use pineapple_core::ut::path::collect_file_paths;
85/// use pineapple_core::constant::SUPPORTED_IMAGE_FORMATS;
86/// let files = collect_file_paths("directory/", SUPPORTED_IMAGE_FORMATS.as_slice(), None);
87/// ```
88pub fn collect_file_paths<P>(
89    directory: P,
90    valid_ext: &[&str],
91    substring: Option<String>,
92) -> Result<Vec<PathBuf>, PineappleError>
93where
94    P: AsRef<Path> + ToString,
95{
96    let message = directory.to_string();
97
98    let mut files: Vec<PathBuf> = std::fs::read_dir(directory)
99        .map_err(|_| PineappleError::DirError(message))?
100        .filter_map(Result::ok)
101        .map(|entry| entry.path())
102        .filter(|path| {
103            path.is_file()
104                && path
105                    .extension()
106                    .and_then(|ext| ext.to_str())
107                    .is_some_and(|ext| valid_ext.contains(&ext))
108        })
109        .collect();
110
111    if let Some(substring) = substring {
112        files.retain(|f| {
113            f.file_name()
114                .unwrap()
115                .to_str()
116                .unwrap()
117                .contains(&substring)
118        });
119    }
120
121    Ok(files)
122}
123
124/// Collect file pairs that share matching prefix
125///
126/// # Arguments
127///
128/// * `files_a` - List of file paths
129/// * `files_b` - List of file paths
130/// * `suffix` - Optionally remove a suffix from the first set of file paths
131///
132/// # Examples
133///
134/// ```
135/// use std::path::{Path, PathBuf};
136/// use pineapple_core::ut::path::collect_file_pairs;
137///
138/// let files_a: [PathBuf; 4] = [
139///     PathBuf::from("directory/id_1.png"),
140///     PathBuf::from("directory/id_2.png"),
141///     PathBuf::from("directory/id_3.png"),
142///     PathBuf::from("directory/id_4_image.png"),
143/// ];
144///
145/// let files_b: [PathBuf; 4] = [
146///     PathBuf::from("directory/id_1.png"),
147///     PathBuf::from("directory/id_2.png"),
148///     PathBuf::from("directory/id_3.png"),
149///     PathBuf::from("directory/id_4.png"),
150/// ];
151///
152/// let pairs = collect_file_pairs(&files_a, &files_b, None, None);
153/// assert_eq!(pairs.len(), 3);
154///
155/// let pairs = collect_file_pairs(&files_a, &files_b, Some("_image".to_string()), None);
156/// assert_eq!(pairs.len(), 4);
157/// ```
158pub fn collect_file_pairs(
159    files_a: &[PathBuf],
160    files_b: &[PathBuf],
161    substring_a: Option<String>,
162    substring_b: Option<String>,
163) -> Vec<(String, PathBuf, PathBuf)> {
164    let substring_a = substring_a.unwrap_or_default();
165    let substring_b = substring_b.unwrap_or_default();
166
167    let file_map: HashMap<String, &PathBuf> = files_a
168        .iter()
169        .filter_map(|file| {
170            file.file_stem().map(|stem| {
171                let name = stem.to_string_lossy().replace(&substring_a, "");
172                (name, file)
173            })
174        })
175        .collect();
176
177    files_b
178        .par_iter()
179        .filter_map(|file_b| {
180            file_b.file_stem().and_then(|stem| {
181                let name = stem.to_string_lossy().replace(&substring_b, "");
182                file_map
183                    .get(&name)
184                    .map(|file_a| (name, (*file_a).clone(), file_b.clone()))
185            })
186        })
187        .collect()
188}