utils_box_pathfinder/
paths.rs

1//! # Path utilities
2//! A toolbox of small utilities to handle paths for files.
3//! Useful for searching for files in a pre-determined list of directories or git repositories.
4
5use anyhow::{Result, anyhow, bail};
6use directories::BaseDirs;
7use glob::{MatchOptions, glob_with};
8use names::{Generator, Name};
9use std::{env, path::PathBuf, str::FromStr};
10use walkdir::{DirEntry, WalkDir};
11
12use utils_box_logger::log_debug;
13
14#[derive(Debug, Clone)]
15pub struct IncludePaths {
16    known_paths: Vec<PathBuf>,
17    unknown_paths: Vec<PathBuf>,
18}
19
20pub struct IncludePathsBuilder {
21    paths: IncludePaths,
22}
23
24impl Default for IncludePathsBuilder {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl IncludePathsBuilder {
31    /// Create new builder
32    pub fn new() -> Self {
33        Self {
34            paths: IncludePaths {
35                known_paths: vec![],
36                unknown_paths: vec![],
37            },
38        }
39    }
40
41    /// Add another include directory that we know the path to seek into
42    /// The directories are considered to be **absolute** if starting with `/` or relative to current folder otherwise
43    /// You can chain multiple calls
44    pub fn include_known(&mut self, path: &str) -> &mut Self {
45        let new = self;
46        new.paths.known_paths.push(PathBuf::from_str(path).unwrap());
47        new
48    }
49
50    /// Add current executable directory to seek into
51    /// You can chain multiple calls
52    pub fn include_exe_dir(&mut self) -> &mut Self {
53        let new = self;
54        new.paths
55            .known_paths
56            .push(std::env::current_exe().unwrap().parent().unwrap().into());
57        new
58    }
59
60    /// Add another include directory that we do not know the path to, to seek into
61    /// We will start seeking the directory first by backtracing from the  the current folder until we reach the $HOME directory.
62    /// Once we find the directory, then we will check inside for the files.
63    /// Useful for testing inside project data.
64    /// For example `cargo test` will execute from the `./target` folder and you have your test data inside a `config` folder somewhere in your project.
65    /// The same fodler will be deployed in a subfolder next to the binary in deployment.
66    /// You can use this function to include the `config` folder as unknown and the code will discover the directory by itself.
67    /// This feature is useful to avoid having multiple includes with `../..` etc to cover all scenarios.
68    /// You can chain multiple calls
69    pub fn include_unknown(&mut self, path: &str) -> &mut Self {
70        let new = self;
71        new.paths
72            .unknown_paths
73            .push(PathBuf::from_str(path).unwrap());
74        new
75    }
76
77    pub fn build(&mut self) -> IncludePaths {
78        self.paths.clone()
79    }
80}
81
82impl IncludePaths {
83    /// Seek File in include directories first
84    /// If not found, fall-back in unknown path search (if applicable)
85    pub fn seek(&self, file: &str) -> Result<PathBuf> {
86        let seek_dir = self.seek_in_known(file);
87
88        if seek_dir.is_ok() {
89            return seek_dir;
90        }
91
92        self.seek_in_unknown(file).map_err(|_| {
93            anyhow!("[include_paths][seek] Failed to find file in directories in included known & unknown paths")
94        })
95    }
96
97    /// Seek only in included known paths (ingoring included unknown paths)
98    pub fn seek_in_known(&self, file: &str) -> Result<PathBuf> {
99        let file = PathBuf::from_str(file)?;
100        self.known_paths
101            .iter()
102            .find_map(|f| {
103                if f.join(&file).exists() {
104                    Some(f.join(&file))
105                } else {
106                    None
107                }
108            })
109            .ok_or(anyhow!(
110                "[include_paths][seek_in_known] Failed to find file in directories in included known paths"
111            ))
112    }
113
114    /// Seek only using the directory names stored with their paths as unknown
115    /// Tries to locate the repositories in the current working directory and then going backwards until reaching `$HOME` or `%HOME%`
116    pub fn seek_in_unknown(&self, file: &str) -> Result<PathBuf> {
117        // Get HOME directory
118        let _home_dir: PathBuf = if let Some(base_dirs) = BaseDirs::new() {
119            base_dirs.home_dir().to_path_buf()
120        } else {
121            bail!(
122                "[include_paths][seek_in_unknown] Failed to retrieve system's home directory path"
123            )
124        };
125
126        // Get current working dir
127        let current_dir: PathBuf = if let Ok(curr) = env::current_dir() {
128            curr.to_path_buf().canonicalize()?
129        } else {
130            bail!("[include_paths][seek_in_unknown] Failed to retrieve current directory path")
131        };
132
133        for repo in self.unknown_paths.iter().rev() {
134            for parent_dir in current_dir.ancestors() {
135                for entry in WalkDir::new(parent_dir)
136                    .follow_links(true)
137                    .into_iter()
138                    .filter_entry(|e| {
139                        (e.file_type().is_dir() || e.path_is_symlink()) && is_not_hidden(e)
140                    })
141                {
142                    let e_path = match entry {
143                        Err(_) => continue,
144                        Ok(ref e) => e.path(),
145                    };
146
147                    match e_path.file_name() {
148                        Some(f_name) => {
149                            if f_name == repo && e_path.join(file).exists() {
150                                return Ok(e_path.join(file));
151                            } else {
152                                continue;
153                            }
154                        }
155                        None => continue,
156                    }
157                }
158            }
159        }
160
161        bail!(
162            "[include_paths][seek_in_unknown] Failed to find file in directories in included unknown paths"
163        );
164    }
165
166    /// Search in directories in known paths first for files matching the glob pattern requested
167    /// If not found, fall-back in unknown paths search (if applicable)
168    pub fn search_glob(&self, pattern: &str) -> Vec<PathBuf> {
169        let seek_dir = self.search_glob_known(pattern);
170
171        if !seek_dir.is_empty() {
172            return seek_dir;
173        }
174
175        self.search_glob_unknown(pattern)
176    }
177
178    /// Search in included directories for files matching the glob pattern requested
179    pub fn search_glob_known(&self, pattern: &str) -> Vec<PathBuf> {
180        let options = MatchOptions {
181            case_sensitive: true,
182            // Allow relative paths
183            require_literal_separator: false,
184            require_literal_leading_dot: false,
185        };
186
187        let detected_files: Vec<PathBuf> = self
188            .known_paths
189            .iter()
190            .flat_map(|dir| {
191                let new_pattern = dir.join("**/").join(pattern);
192                let new_pattern = new_pattern.to_str().unwrap();
193                glob_with(new_pattern, options)
194                    .unwrap()
195                    .filter_map(|path| path.ok())
196                    .collect::<Vec<PathBuf>>()
197            })
198            .collect();
199
200        log_debug!(
201            "[include_paths][search_glob_known] Detected files for [{}]: \n{:?}",
202            pattern,
203            detected_files
204        );
205
206        detected_files
207    }
208
209    /// Search in included repsotories for files matching the glob pattern requested
210    pub fn search_glob_unknown(&self, pattern: &str) -> Vec<PathBuf> {
211        let options = MatchOptions {
212            case_sensitive: true,
213            // Allow relative paths
214            require_literal_separator: false,
215            require_literal_leading_dot: false,
216        };
217
218        // Get HOME directory
219        let home_dir: PathBuf = if let Some(base_dirs) = BaseDirs::new() {
220            base_dirs.home_dir().to_path_buf()
221        } else {
222            log_debug!(
223                "[include_paths][search_glob_unknown] Failed to retrieve system's home directory path"
224            );
225            PathBuf::new()
226        };
227
228        let detected_files: Vec<PathBuf> = {
229            let mut tmp: Vec<PathBuf> = vec![];
230            for repo in self.unknown_paths.iter() {
231                let mut dir: Vec<PathBuf> = WalkDir::new(&home_dir)
232                    .follow_links(true)
233                    .into_iter()
234                    .filter_entry(|e| e.file_type().is_dir() && is_not_hidden(e))
235                    .filter_map(|entry| {
236                        let dir = match entry {
237                            Err(_) => return None,
238                            Ok(ref e) => e.path(),
239                        };
240
241                        match dir.file_name() {
242                            Some(f_name) => {
243                                if f_name != repo {
244                                    return None;
245                                }
246                            }
247                            None => return None,
248                        }
249
250                        let new_pattern = dir.join("**/").join(pattern);
251                        let new_pattern = new_pattern.to_str().unwrap();
252
253                        Some(
254                            glob_with(new_pattern, options)
255                                .unwrap()
256                                .filter_map(|path| path.ok())
257                                .collect::<Vec<PathBuf>>(),
258                        )
259                    })
260                    .flatten()
261                    .collect::<Vec<PathBuf>>();
262
263                tmp.append(&mut dir);
264            }
265
266            tmp
267        };
268
269        log_debug!(
270            "[include_paths][search_glob_unknown] Detected files for [{}]: \n{:?}",
271            pattern,
272            detected_files
273        );
274
275        detected_files
276    }
277}
278
279fn is_not_hidden(entry: &DirEntry) -> bool {
280    entry
281        .file_name()
282        .to_str()
283        .map(|s| !s.starts_with('.'))
284        .unwrap_or(false)
285}
286
287/// Generate random filename
288pub fn random_filename() -> String {
289    let mut generator = Generator::with_naming(Name::Numbered);
290    generator.next().unwrap()
291}
292
293/// Generate timestamp for filenames
294pub fn timestamp_filename() -> String {
295    format!("{}", chrono::offset::Utc::now().format("%Y%m%d_%H%M%S"))
296}
297
298#[cfg(test)]
299mod tests {
300    use crate::paths::*;
301
302    #[test]
303    fn include_path_test() {
304        let paths = IncludePathsBuilder::new()
305            .include_known("/test/")
306            .include_known("/2/")
307            .include_exe_dir()
308            .include_known("test_data/")
309            .build();
310
311        println!("Paths: {:?}", paths);
312
313        let file = "test_archives.tar";
314        assert_eq!(
315            std::fs::canonicalize(PathBuf::from_str("test_data/test_archives.tar").unwrap())
316                .unwrap(),
317            std::fs::canonicalize(paths.seek(file).unwrap()).unwrap()
318        );
319    }
320
321    #[test]
322    fn include_repos_test() {
323        let paths = IncludePathsBuilder::new()
324            .include_unknown("utils-box")
325            .build();
326
327        println!("Paths: {:?}", paths);
328
329        let file = "src/paths.rs";
330        assert_eq!(
331            std::fs::canonicalize(
332                std::env::current_exe()
333                    .unwrap()
334                    .parent()
335                    .unwrap()
336                    .join("../../../src/paths.rs")
337            )
338            .unwrap(),
339            std::fs::canonicalize(paths.seek_in_unknown(file).unwrap()).unwrap()
340        );
341    }
342
343    #[test]
344    fn include_seek_all_test() {
345        let paths = IncludePathsBuilder::new()
346            .include_known("/test/")
347            .include_known("/2/")
348            .include_exe_dir()
349            .include_known("tests_data/")
350            .include_unknown("utils-box")
351            .build();
352
353        println!("Paths: {:?}", paths);
354
355        let file = "src/paths.rs";
356        assert_eq!(
357            std::fs::canonicalize(
358                std::env::current_exe()
359                    .unwrap()
360                    .parent()
361                    .unwrap()
362                    .join("../../../src/paths.rs")
363            )
364            .unwrap(),
365            std::fs::canonicalize(paths.seek(file).unwrap()).unwrap()
366        );
367    }
368
369    #[test]
370    fn search_glob_dir_test() {
371        let paths = IncludePathsBuilder::new()
372            .include_known("test_data/")
373            .build();
374
375        println!("Paths: {:?}", paths);
376
377        let pattern = "test_*.tar";
378
379        let results = paths.search_glob_known(pattern);
380
381        assert!(!results.is_empty());
382    }
383
384    #[test]
385    fn search_glob_repos_test() {
386        let paths = IncludePathsBuilder::new()
387            .include_unknown("utils-box")
388            .build();
389
390        println!("Paths: {:?}", paths);
391
392        let pattern = "test_*.tar";
393
394        let results = paths.search_glob_unknown(pattern);
395
396        assert!(!results.is_empty());
397    }
398
399    #[test]
400    fn search_glob_all_test() {
401        let paths = IncludePathsBuilder::new()
402            .include_exe_dir()
403            .include_unknown("utils-box")
404            .build();
405
406        println!("Paths: {:?}", paths);
407
408        let pattern = "test_*.tar";
409
410        let results = paths.search_glob(pattern);
411
412        assert!(!results.is_empty());
413    }
414}