msg_tool\utils/
files.rs

1//! Utilities for File Operations
2use crate::scripts::{ALL_EXTS, ARCHIVE_EXTS};
3#[cfg(not(windows))]
4use std::ffi::OsString;
5use std::fs;
6use std::io;
7use std::io::{Read, Write};
8#[cfg(not(windows))]
9use std::path::Component;
10use std::path::{Path, PathBuf};
11
12/// Returns the relative path from `root` to `target`.
13pub fn relative_path<P: AsRef<Path>, T: AsRef<Path>>(root: P, target: T) -> PathBuf {
14    let root = root
15        .as_ref()
16        .canonicalize()
17        .unwrap_or_else(|_| root.as_ref().to_path_buf());
18    let target = target
19        .as_ref()
20        .canonicalize()
21        .unwrap_or_else(|_| target.as_ref().to_path_buf());
22
23    let mut root_components: Vec<_> = root.components().collect();
24    let mut target_components: Vec<_> = target.components().collect();
25
26    // Remove common prefix
27    while !root_components.is_empty()
28        && !target_components.is_empty()
29        && root_components[0] == target_components[0]
30    {
31        root_components.remove(0);
32        target_components.remove(0);
33    }
34
35    // Add ".." for each remaining root component
36    let mut result = PathBuf::new();
37    for _ in root_components {
38        result.push("..");
39    }
40
41    // Add remaining target components
42    for component in target_components {
43        result.push(component);
44    }
45
46    result
47}
48
49/// Finds all files in the specified directory and its subdirectories.
50pub fn find_files(path: &str, recursive: bool, no_ext_filter: bool) -> io::Result<Vec<String>> {
51    let mut result = Vec::new();
52    let dir_path = Path::new(&path);
53
54    if dir_path.is_dir() {
55        for entry in fs::read_dir(dir_path)? {
56            let entry = entry?;
57            let path = entry.path();
58
59            if path.is_file()
60                && (no_ext_filter
61                    || path.file_name().map_or(false, |file| {
62                        path.extension().map_or(true, |_| {
63                            let file = file.to_string_lossy().to_lowercase();
64                            for ext in ALL_EXTS.iter() {
65                                if file.ends_with(&format!(".{}", ext)) {
66                                    return true;
67                                }
68                            }
69                            false
70                        })
71                    }))
72            {
73                if let Some(path_str) = path.to_str() {
74                    result.push(path_str.to_string());
75                }
76            } else if recursive && path.is_dir() {
77                if let Some(path_str) = path.to_str() {
78                    let mut sub_files =
79                        find_files(&path_str.to_string(), recursive, no_ext_filter)?;
80                    result.append(&mut sub_files);
81                }
82            }
83        }
84    }
85
86    Ok(result)
87}
88
89/// Finds all archive files in the specified directory and its subdirectories.
90pub fn find_arc_files(path: &str, recursive: bool) -> io::Result<Vec<String>> {
91    let mut result = Vec::new();
92    let dir_path = Path::new(&path);
93
94    if dir_path.is_dir() {
95        for entry in fs::read_dir(dir_path)? {
96            let entry = entry?;
97            let path = entry.path();
98
99            if path.is_file()
100                && path.file_name().map_or(false, |file| {
101                    path.extension().map_or(true, |_| {
102                        let file = file.to_string_lossy().to_lowercase();
103                        for ext in ARCHIVE_EXTS.iter() {
104                            if file.ends_with(&format!(".{}", ext)) {
105                                return true;
106                            }
107                        }
108                        false
109                    })
110                })
111            {
112                if let Some(path_str) = path.to_str() {
113                    result.push(path_str.to_string());
114                }
115            } else if recursive && path.is_dir() {
116                if let Some(path_str) = path.to_str() {
117                    let mut sub_files = find_arc_files(&path_str.to_string(), recursive)?;
118                    result.append(&mut sub_files);
119                }
120            }
121        }
122    }
123
124    Ok(result)
125}
126
127/// Collects files from the specified path, either as a directory or a single file.
128pub fn collect_files(
129    path: &str,
130    recursive: bool,
131    no_ext_filter: bool,
132) -> io::Result<(Vec<String>, bool)> {
133    let pa = Path::new(path);
134    if pa.is_dir() {
135        return Ok((find_files(path, recursive, no_ext_filter)?, true));
136    }
137    if pa.is_file() {
138        return Ok((vec![path.to_string()], false));
139    }
140    Err(io::Error::new(
141        io::ErrorKind::NotFound,
142        format!("Path {} is neither a file nor a directory", pa.display()),
143    ))
144}
145
146/// Finds all files with specific extensions in the specified directory and its subdirectories.
147pub fn find_ext_files(path: &str, recursive: bool, exts: &[&str]) -> io::Result<Vec<String>> {
148    let mut result = Vec::new();
149    let dir_path = Path::new(&path);
150
151    if dir_path.is_dir() {
152        for entry in fs::read_dir(dir_path)? {
153            let entry = entry?;
154            let path = entry.path();
155
156            if path.is_file()
157                && path.file_name().map_or(false, |file| {
158                    path.extension().map_or(true, |_| {
159                        let file = file.to_string_lossy().to_lowercase();
160                        for ext in exts {
161                            if file.ends_with(&format!(".{}", ext)) {
162                                return true;
163                            }
164                        }
165                        false
166                    })
167                })
168            {
169                if let Some(path_str) = path.to_str() {
170                    result.push(path_str.to_string());
171                }
172            } else if recursive && path.is_dir() {
173                if let Some(path_str) = path.to_str() {
174                    let mut sub_files = find_ext_files(path_str, recursive, exts)?;
175                    result.append(&mut sub_files);
176                }
177            }
178        }
179    }
180
181    Ok(result)
182}
183
184/// Collects files with specific extensions from the specified path, either as a directory or a single file.
185pub fn collect_ext_files(
186    path: &str,
187    recursive: bool,
188    exts: &[&str],
189) -> io::Result<(Vec<String>, bool)> {
190    let pa = Path::new(path);
191    if pa.is_dir() {
192        return Ok((find_ext_files(path, recursive, exts)?, true));
193    }
194    if pa.is_file() {
195        return Ok((vec![path.to_string()], false));
196    }
197    Err(io::Error::new(
198        io::ErrorKind::NotFound,
199        format!("Path {} is neither a file nor a directory", pa.display()),
200    ))
201}
202
203/// Collects archive files from the specified path, either as a directory or a single file.
204pub fn collect_arc_files(path: &str, recursive: bool) -> io::Result<(Vec<String>, bool)> {
205    let pa = Path::new(path);
206    if pa.is_dir() {
207        return Ok((find_arc_files(path, recursive)?, true));
208    }
209    if pa.is_file() {
210        return Ok((vec![path.to_string()], false));
211    }
212    Err(io::Error::new(
213        io::ErrorKind::NotFound,
214        format!("Path {} is neither a file nor a directory", pa.display()),
215    ))
216}
217
218/// Reads the content of a file or standard input if the path is "-".
219pub fn read_file<F: AsRef<Path> + ?Sized>(f: &F) -> io::Result<Vec<u8>> {
220    let mut content = Vec::new();
221    if f.as_ref() == Path::new("-") {
222        io::stdin().read_to_end(&mut content)?;
223    } else {
224        content = fs::read(f)?;
225    }
226    Ok(content)
227}
228
229/// Writes content to a file or standard output if the path is "-".
230pub fn write_file<F: AsRef<Path> + ?Sized>(f: &F) -> io::Result<Box<dyn Write>> {
231    Ok(if f.as_ref() == Path::new("-") {
232        Box::new(io::stdout())
233    } else {
234        Box::new(fs::File::create(f)?)
235    })
236}
237
238/// Ensures that the parent directory for the specified path exists, creating it if necessary.
239pub fn make_sure_dir_exists<F: AsRef<Path> + ?Sized>(f: &F) -> io::Result<()> {
240    let path = f.as_ref();
241    if let Some(parent) = path.parent() {
242        if !parent.exists() {
243            fs::create_dir_all(parent)?;
244        }
245    }
246    Ok(())
247}
248
249/// Replace symbols not allowed in Windows path with underscores.
250pub fn sanitize_path(path: &str) -> String {
251    // Split path into components, preserving separators
252    if path.is_empty() {
253        return String::new();
254    }
255
256    let invalid_chars: &[char] = &['<', '>', '"', '|', '?', '*'];
257    let mut result = String::with_capacity(path.len());
258
259    let reserved_names: Vec<String> = {
260        let mut v = vec!["CON", "PRN", "AUX", "NUL"]
261            .into_iter()
262            .map(|s| s.to_string())
263            .collect::<Vec<_>>();
264        for i in 1..=9 {
265            v.push(format!("COM{}", i));
266            v.push(format!("LPT{}", i));
267        }
268        v
269    };
270
271    let bytes = path.as_bytes();
272    let len = bytes.len();
273    let mut start = 0usize;
274
275    while start < len {
276        // find next separator index
277        let mut end = start;
278        while end < len && bytes[end] != b'\\' && bytes[end] != b'/' {
279            end += 1;
280        }
281        // segment is path[start..end]
282        let seg = &path[start..end];
283
284        // sanitize segment
285        let mut s = String::with_capacity(seg.len());
286        for (i, ch) in seg.chars().enumerate() {
287            // allow drive letter colon like "C:" (i == 1, first char is ASCII letter)
288            if ch == ':' {
289                if i == 1 {
290                    // check first char is ASCII letter
291                    if seg
292                        .chars()
293                        .next()
294                        .map(|c| c.is_ascii_alphabetic())
295                        .unwrap_or(false)
296                    {
297                        s.push(':');
298                        continue;
299                    }
300                }
301                // otherwise treat as invalid
302                s.push('_');
303                continue;
304            }
305            // keep separators out of segment (shouldn't appear here)
306            // replace control chars and other invalids
307            if (ch as u32) < 32 || invalid_chars.contains(&ch) {
308                s.push('_');
309            } else {
310                s.push(ch);
311            }
312        }
313
314        // trim trailing spaces and dots (Windows disallows filenames ending with space or dot)
315        while s.ends_with(' ') || s.ends_with('.') {
316            s.pop();
317        }
318
319        if s.is_empty() {
320            s.push('_');
321        } else {
322            // check reserved names (base name before first '.')
323            let base = s.split('.').next().unwrap_or("").to_ascii_uppercase();
324            if reserved_names.iter().any(|r| r == &base) {
325                s = format!("_{}", s);
326            }
327        }
328
329        result.push_str(&s);
330
331        // append separator if present
332        if end < len {
333            // keep original separator (preserve '\' or '/')
334            result.push(path.as_bytes()[end] as char);
335            start = end + 1;
336        } else {
337            start = end;
338        }
339    }
340
341    result
342}
343
344pub fn get_ignorecase_path<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
345    #[cfg(windows)]
346    return Ok(path.as_ref().to_path_buf());
347    #[cfg(not(windows))]
348    {
349        let path = path.as_ref();
350        // If the path exists as is, return it
351        if path.exists() {
352            return Ok(path.to_path_buf());
353        }
354        {
355            // Helper: try to resolve the remaining tail components starting from base,
356            // performing case-insensitive matches for each step.
357            fn resolve_from_base(base: PathBuf, tail: &[OsString]) -> io::Result<Option<PathBuf>> {
358                let mut cur = base;
359                for comp in tail {
360                    let direct = cur.join(comp);
361                    if direct.exists() {
362                        cur = direct;
363                        continue;
364                    }
365
366                    if !cur.is_dir() {
367                        return Ok(None);
368                    }
369
370                    let mut found = None;
371                    for entry in fs::read_dir(&cur)? {
372                        let entry = entry?;
373                        let name = entry.file_name();
374                        if name
375                            .to_string_lossy()
376                            .eq_ignore_ascii_case(&comp.to_string_lossy())
377                        {
378                            found = Some(cur.join(name));
379                            break;
380                        }
381                    }
382
383                    match found {
384                        Some(p) => cur = p,
385                        None => return Ok(None),
386                    }
387                }
388                Ok(Some(cur))
389            }
390
391            let orig = path;
392            // If it exists as-is, return immediately
393            if orig.exists() {
394                return Ok(orig.to_path_buf());
395            }
396
397            // Collect components as OsString (preserve Prefix/RootDir as components)
398            let comps: Vec<OsString> = orig
399                .components()
400                .map(|c| match c {
401                    Component::Prefix(p) => p.as_os_str().to_os_string(),
402                    Component::RootDir => OsString::from(std::path::MAIN_SEPARATOR.to_string()),
403                    other => other.as_os_str().to_os_string(),
404                })
405                .collect();
406
407            // Try replacing components from the bottom (leaf) upward.
408            let len = comps.len();
409            for idx in (0..len).rev() {
410                // Build parent path from comps[0..idx]
411                let mut parent = PathBuf::new();
412                for j in 0..idx {
413                    parent.push(&comps[j]);
414                }
415                if parent.as_os_str().is_empty() {
416                    parent = PathBuf::from(".");
417                }
418
419                // If parent doesn't exist or is not a directory, skip this level
420                if !parent.exists() || !parent.is_dir() {
421                    continue;
422                }
423
424                // Look for a case-insensitive match for the component at idx inside parent
425                let target = &comps[idx];
426                let mut matched_name: Option<OsString> = None;
427                for entry in fs::read_dir(&parent)? {
428                    let entry = entry?;
429                    let name = entry.file_name();
430                    if name
431                        .to_string_lossy()
432                        .eq_ignore_ascii_case(&target.to_string_lossy())
433                    {
434                        matched_name = Some(name);
435                        break;
436                    }
437                }
438
439                if let Some(name) = matched_name {
440                    // Reconstruct candidate path: parent + matched_name + remaining original tail
441                    let candidate_base = parent.join(name);
442                    let tail: Vec<OsString> = comps.iter().skip(idx + 1).cloned().collect();
443                    if tail.is_empty() {
444                        if candidate_base.exists() {
445                            return Ok(candidate_base);
446                        } else {
447                            // Even if leaf matched, final file may not exist (e.g., different deeper casing),
448                            // attempt to resolve remaining components (none here) so treat as not found.
449                            continue;
450                        }
451                    }
452
453                    if let Some(resolved) = resolve_from_base(candidate_base, &tail)? {
454                        return Ok(resolved);
455                    }
456                }
457            }
458
459            Err(io::Error::new(
460                io::ErrorKind::NotFound,
461                format!(
462                    "Path {} not found (case-insensitive search failed)",
463                    path.display()
464                ),
465            ))
466        }
467    }
468}