Skip to main content

apif_utils/
file_utils.rs

1use anyhow::Result;
2use std::path::{Path, PathBuf};
3
4/// File utilities for cross-platform operations
5pub struct FileUtils;
6
7impl FileUtils {
8    /// Collect all .gctf files from a directory, optionally excluding patterns.
9    /// Uses `ignore` crate which respects `.gitignore` and `.ignore` files.
10    pub fn collect_test_files(path: &Path, exclude_patterns: &[String]) -> Vec<PathBuf> {
11        let mut files = Vec::new();
12        let walker = ignore::WalkBuilder::new(path)
13            .git_global(true)
14            .git_ignore(true)
15            .git_exclude(true)
16            .build();
17        for entry in walker.flatten() {
18            let p = entry.path();
19            if p.extension()
20                .is_some_and(|ext| ext == "gctf" || ext == "apif")
21                && !is_excluded(p, exclude_patterns)
22            {
23                files.push(p.to_path_buf());
24            }
25        }
26        files
27    }
28
29    /// Sort files by name or modification time
30    pub fn sort_files(files: &mut [PathBuf], sort_by: &str) {
31        match sort_by {
32            "name" => files.sort_by(|a, b| a.file_name().cmp(&b.file_name())),
33            "mtime" | "time" => files.sort_by(|a, b| {
34                Self::get_mtime(b)
35                    .unwrap_or(0)
36                    .cmp(&Self::get_mtime(a).unwrap_or(0))
37            }),
38            "random" => {
39                use rand::SeedableRng;
40                use rand::rngs::StdRng;
41                use rand::seq::SliceRandom;
42                let mut rng = StdRng::from_rng(&mut rand::rng());
43                files.shuffle(&mut rng);
44            }
45            _ => {}
46        }
47    }
48
49    /// Get file modification time as Unix timestamp
50    pub fn get_mtime(path: &Path) -> Result<i64> {
51        let metadata = std::fs::metadata(path)?;
52        let mtime = metadata
53            .modified()?
54            .duration_since(std::time::UNIX_EPOCH)
55            .unwrap_or_default()
56            .as_secs() as i64;
57        Ok(mtime)
58    }
59
60    /// Get file size in bytes
61    pub fn get_file_size(path: &Path) -> Result<u64> {
62        let metadata = std::fs::metadata(path)?;
63        Ok(metadata.len())
64    }
65
66    /// Resolve a relative path against a base file's directory
67    pub fn resolve_relative_path(base_file_path: &Path, relative_path: &str) -> PathBuf {
68        let base_dir = base_file_path.parent().unwrap_or(Path::new("."));
69        base_dir.join(relative_path)
70    }
71}
72
73fn is_excluded(path: &Path, exclude_patterns: &[String]) -> bool {
74    if exclude_patterns.is_empty() {
75        return false;
76    }
77    let path_str = path.to_string_lossy();
78    exclude_patterns.iter().any(|pattern| {
79        if let Ok(glob) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
80            // A glob like `smoke*` is whole-path anchored, so it would only
81            // match a bare filename, never `dir/smoke1.gctf`. Match it against
82            // the full path AND every path component (including the basename)
83            // so patterns such as `smoke*` exclude matching files anywhere.
84            glob.is_match(path_str.as_ref())
85                || path
86                    .components()
87                    .any(|c| glob.is_match(c.as_os_str().to_string_lossy().as_ref()))
88        } else {
89            path_str.contains(pattern.as_str())
90        }
91    })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::fs;
98
99    #[cfg_attr(miri, ignore)]
100    #[test]
101    #[cfg(not(miri))]
102    fn test_collect_test_files_empty_dir() {
103        let dir = std::env::temp_dir().join("gctf_test_empty");
104        let _ = fs::remove_dir_all(&dir);
105        fs::create_dir_all(&dir).unwrap();
106        let files = FileUtils::collect_test_files(&dir, &[]);
107        assert!(files.is_empty());
108        let _ = fs::remove_dir_all(&dir);
109    }
110
111    #[cfg_attr(miri, ignore)]
112    #[test]
113    #[cfg(not(miri))]
114    fn test_collect_test_files_with_gctf() {
115        let dir = std::env::temp_dir().join("gctf_test_files");
116        let _ = fs::remove_dir_all(&dir);
117        fs::create_dir_all(&dir).unwrap();
118        fs::write(dir.join("test.gctf"), "content").unwrap();
119        fs::write(dir.join("other.txt"), "content").unwrap();
120        let files = FileUtils::collect_test_files(&dir, &[]);
121        assert_eq!(files.len(), 1);
122        let _ = fs::remove_dir_all(&dir);
123    }
124
125    #[test]
126    fn test_resolve_relative_path() {
127        let base = Path::new("/home/user/tests/test.gctf");
128        let resolved = FileUtils::resolve_relative_path(base, "data/file.csv");
129        assert_eq!(resolved, Path::new("/home/user/tests/data/file.csv"));
130    }
131
132    #[cfg_attr(miri, ignore)]
133    #[test]
134    #[cfg(not(miri))]
135    fn test_get_file_size() {
136        let dir = std::env::temp_dir().join("gctf_test_size");
137        let _ = fs::remove_dir_all(&dir);
138        fs::create_dir_all(&dir).unwrap();
139        let file = dir.join("test.gctf");
140        fs::write(&file, "hello").unwrap();
141        assert_eq!(FileUtils::get_file_size(&file).unwrap(), 5);
142        let _ = fs::remove_dir_all(&dir);
143    }
144
145    #[test]
146    fn test_is_excluded() {
147        assert!(!is_excluded(Path::new("test.gctf"), &[]));
148        assert!(is_excluded(Path::new("test.gctf"), &["test*".into()]));
149        assert!(!is_excluded(Path::new("other.gctf"), &["test*".into()]));
150        // Glob pattern matching
151        assert!(is_excluded(Path::new("test.gctf"), &["*.gctf".into()]));
152        assert!(!is_excluded(Path::new("test.gctf"), &["*.txt".into()]));
153    }
154
155    // Bug 7: `smoke*` (not `**/smoke*`) must exclude matching files anywhere,
156    // matching against each path component / basename, not just the full path.
157    #[test]
158    fn test_is_excluded_matches_basename_glob() {
159        assert!(is_excluded(
160            Path::new("tests/smoke_login.gctf"),
161            &["smoke*".into()]
162        ));
163        assert!(is_excluded(
164            Path::new("a/b/c/smoke1.gctf"),
165            &["smoke*".into()]
166        ));
167        assert!(!is_excluded(
168            Path::new("tests/regular.gctf"),
169            &["smoke*".into()]
170        ));
171        // Full-path anchored globs still work.
172        assert!(is_excluded(Path::new("tests/x.gctf"), &["tests/*".into()]));
173        // Excluding by directory component.
174        assert!(is_excluded(
175            Path::new("fixtures/skip/x.gctf"),
176            &["skip".into()]
177        ));
178    }
179
180    #[test]
181    fn test_sort_files_by_name() {
182        let mut files = vec![
183            PathBuf::from("b.gctf"),
184            PathBuf::from("a.gctf"),
185            PathBuf::from("c.gctf"),
186        ];
187        FileUtils::sort_files(&mut files, "name");
188        assert_eq!(files[0].file_name().unwrap(), "a.gctf");
189        assert_eq!(files[1].file_name().unwrap(), "b.gctf");
190        assert_eq!(files[2].file_name().unwrap(), "c.gctf");
191    }
192
193    #[test]
194    fn test_sort_files_unsupported() {
195        let mut files = vec![PathBuf::from("b.gctf"), PathBuf::from("a.gctf")];
196        FileUtils::sort_files(&mut files, "unsupported");
197        // Should stay in original order
198        assert_eq!(files[0].file_name().unwrap(), "b.gctf");
199    }
200
201    #[test]
202    fn test_get_mtime_nonexistent() {
203        let result = FileUtils::get_mtime(Path::new("/nonexistent/path.gctf"));
204        assert!(result.is_err());
205    }
206}