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}