subx_core/core/input/mod.rs
1//! Core-owned input path collection.
2//!
3//! Provides [`InputPathHandler`] (universal file/directory input processing:
4//! path merging, recursive scanning, extension filtering, archive extraction)
5//! and [`CollectedFiles`] (the collected-path handle with RAII archive temp
6//! dirs). The module deliberately depends on nothing from the CLI layer or on
7//! any argument-parsing type — CLI structs are thin adapters over it.
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use log::warn;
14use tempfile::TempDir;
15
16use crate::core::archive;
17use crate::error::SubXError;
18
19/// Universal input path processing structure for CLI commands.
20///
21/// `InputPathHandler` provides a unified interface for processing file and directory
22/// inputs across different SubX CLI commands. It supports multiple input sources,
23/// recursive directory scanning, and file extension filtering.
24///
25/// This handler is used by commands like `match`, `convert`, `sync`, and `detect-encoding`
26/// to provide consistent `-i` parameter functionality and directory processing behavior.
27///
28/// # Features
29///
30/// - **Multiple Input Sources**: Supports multiple files and directories via `-i` parameter
31/// - **Recursive Processing**: Optional recursive directory scanning with `--recursive` flag
32/// - **File Filtering**: Filter files by extension for command-specific processing
33/// - **Path Validation**: Validates all input paths exist before processing
34/// - **Cross-Platform**: Handles both absolute and relative paths correctly
35/// - **Archive Extraction**: Transparently extracts `.zip` (and `.rar` when built
36/// with the `archive-rar` feature) archives passed directly as inputs into a
37/// temporary directory and processes the extracted files as if they had been
38/// supplied directly. Archives discovered during recursive directory traversal
39/// are NOT extracted. The behaviour can be disabled per command via
40/// `--no-extract` (see [`with_no_extract`](Self::with_no_extract)), in which
41/// case archive files are treated as regular files and filtered by the
42/// command's extension list.
43///
44/// # Return Value
45///
46/// [`collect_files`](Self::collect_files) returns a [`CollectedFiles`] handle
47/// that dereferences to `&[PathBuf]`. When archive extraction is performed,
48/// `CollectedFiles` owns the underlying [`tempfile::TempDir`] handles; the
49/// extracted directories are removed automatically (RAII) when the
50/// `CollectedFiles` value is dropped. Callers must therefore keep the
51/// `CollectedFiles` value alive for as long as the extracted file paths are
52/// in use. `CollectedFiles` also exposes
53/// [`archive_origin`](CollectedFiles::archive_origin) so callers can map an
54/// extracted file back to the original archive that produced it.
55///
56/// # Examples
57///
58/// ## Basic Usage
59///
60/// ```rust
61/// use subx_core::core::input::InputPathHandler;
62/// use std::path::PathBuf;
63/// # use tempfile::TempDir;
64/// # use std::fs;
65///
66/// # let tmp = TempDir::new().unwrap();
67/// # let test_dir = tmp.path();
68/// # let file1 = test_dir.join("test1.srt");
69/// # let file2 = test_dir.join("test2.ass");
70/// # fs::write(&file1, "test content").unwrap();
71/// # fs::write(&file2, "test content").unwrap();
72///
73/// // Create handler from multiple paths
74/// let paths = vec![file1, file2];
75/// let handler = InputPathHandler::from_args(&paths, false)?
76/// .with_extensions(&["srt", "ass"]);
77///
78/// // Collect all matching files
79/// let files = handler.collect_files()?;
80/// assert_eq!(files.len(), 2);
81/// # Ok::<(), subx_core::error::SubXError>(())
82/// ```
83///
84/// ## Directory Processing
85///
86/// ```rust
87/// use subx_core::core::input::InputPathHandler;
88/// use std::path::PathBuf;
89/// # use tempfile::TempDir;
90/// # use std::fs;
91///
92/// # let tmp = TempDir::new().unwrap();
93/// # let test_dir = tmp.path();
94/// # let nested_dir = test_dir.join("nested");
95/// # fs::create_dir(&nested_dir).unwrap();
96/// # let file1 = test_dir.join("test1.srt");
97/// # let file2 = nested_dir.join("test2.srt");
98/// # fs::write(&file1, "test content").unwrap();
99/// # fs::write(&file2, "test content").unwrap();
100///
101/// // Flat directory scanning (non-recursive)
102/// let handler_flat = InputPathHandler::from_args(&[test_dir.to_path_buf()], false)?
103/// .with_extensions(&["srt"]);
104/// let files_flat = handler_flat.collect_files()?;
105/// assert_eq!(files_flat.len(), 1); // Only finds file1
106///
107/// // Recursive directory scanning
108/// let handler_recursive = InputPathHandler::from_args(&[test_dir.to_path_buf()], true)?
109/// .with_extensions(&["srt"]);
110/// let files_recursive = handler_recursive.collect_files()?;
111/// assert_eq!(files_recursive.len(), 2); // Finds both file1 and file2
112/// # Ok::<(), subx_core::error::SubXError>(())
113/// ```
114#[derive(Debug, Clone)]
115pub struct InputPathHandler {
116 /// List of input paths (files and directories) to process
117 pub paths: Vec<PathBuf>,
118 /// Whether to recursively scan subdirectories
119 pub recursive: bool,
120 /// File extension filters (lowercase, without dot)
121 pub file_extensions: Vec<String>,
122 /// Whether to skip archive extraction for archive file inputs
123 pub no_extract: bool,
124}
125
126impl InputPathHandler {
127 /// Merge paths from multiple sources to create a unified path list
128 ///
129 /// This method provides a unified interface for CLI commands to merge
130 /// different types of path parameters into a single PathBuf vector.
131 ///
132 /// # Arguments
133 ///
134 /// * `optional_paths` - Optional path list (e.g., `path`, `input`, `video`, `subtitle`, etc.)
135 /// * `multiple_paths` - Multiple path list (e.g., `input_paths`)
136 /// * `string_paths` - String format path list (e.g., `file_paths`)
137 ///
138 /// # Returns
139 ///
140 /// Returns the merged PathBuf vector, or an error if all inputs are empty
141 ///
142 /// # Examples
143 ///
144 /// ```rust
145 /// use subx_core::core::input::InputPathHandler;
146 /// use std::path::PathBuf;
147 ///
148 /// // Merge paths from different sources
149 /// let optional = vec![Some(PathBuf::from("single.srt"))];
150 /// let multiple = vec![PathBuf::from("dir1"), PathBuf::from("dir2")];
151 /// let strings = vec!["file1.srt".to_string(), "file2.ass".to_string()];
152 ///
153 /// let merged = InputPathHandler::merge_paths_from_multiple_sources(
154 /// &optional,
155 /// &multiple,
156 /// &strings
157 /// )?;
158 ///
159 /// // merged now contains all paths
160 /// assert_eq!(merged.len(), 5);
161 /// # Ok::<(), subx_core::error::SubXError>(())
162 /// ```
163 pub fn merge_paths_from_multiple_sources(
164 optional_paths: &[Option<PathBuf>],
165 multiple_paths: &[PathBuf],
166 string_paths: &[String],
167 ) -> Result<Vec<PathBuf>, SubXError> {
168 let mut all_paths = Vec::new();
169
170 // Add optional paths (filter out None values)
171 for p in optional_paths.iter().flatten() {
172 all_paths.push(p.clone());
173 }
174
175 // Add multiple paths
176 all_paths.extend(multiple_paths.iter().cloned());
177
178 // Add string paths (convert to PathBuf)
179 for path_str in string_paths {
180 all_paths.push(PathBuf::from(path_str));
181 }
182
183 // Check if any paths were specified
184 if all_paths.is_empty() {
185 return Err(SubXError::NoInputSpecified);
186 }
187
188 Ok(all_paths)
189 }
190
191 /// Create InputPathHandler from command line arguments
192 pub fn from_args(input_args: &[PathBuf], recursive: bool) -> Result<Self, SubXError> {
193 let handler = Self {
194 paths: input_args.to_vec(),
195 recursive,
196 file_extensions: Vec::new(),
197 no_extract: false,
198 };
199 handler.validate()?;
200 Ok(handler)
201 }
202
203 /// Set supported file extensions (without dot)
204 pub fn with_extensions(mut self, extensions: &[&str]) -> Self {
205 self.file_extensions = extensions.iter().map(|s| s.to_lowercase()).collect();
206 self
207 }
208
209 /// Set whether to skip archive extraction.
210 ///
211 /// When `true`, archive files (`.zip`, `.rar`) are treated as regular
212 /// files and subject to the normal extension filter instead of being
213 /// extracted.
214 pub fn with_no_extract(mut self, no_extract: bool) -> Self {
215 self.no_extract = no_extract;
216 self
217 }
218
219 /// Validate that all paths exist
220 pub fn validate(&self) -> Result<(), SubXError> {
221 for path in &self.paths {
222 if !path.exists() {
223 return Err(SubXError::PathNotFound(path.clone()));
224 }
225 }
226 Ok(())
227 }
228
229 /// Get all specified directory paths
230 ///
231 /// This method returns all specified directory paths for commands
232 /// that need to process directories one by one. If the specified path
233 /// contains files, it will return the directory containing that file.
234 ///
235 /// # Returns
236 ///
237 /// Deduplicated list of directory paths
238 ///
239 /// # Examples
240 ///
241 /// ```rust
242 /// use subx_core::core::input::InputPathHandler;
243 /// use std::path::PathBuf;
244 /// # use tempfile::TempDir;
245 /// # use std::fs;
246 ///
247 /// # let tmp = TempDir::new().unwrap();
248 /// # let test_dir = tmp.path();
249 /// # let file1 = test_dir.join("test1.srt");
250 /// # fs::write(&file1, "test content").unwrap();
251 ///
252 /// let paths = vec![file1.clone(), test_dir.to_path_buf()];
253 /// let handler = InputPathHandler::from_args(&paths, false)?;
254 /// let directories = handler.get_directories();
255 ///
256 /// // Should contain test_dir (after deduplication)
257 /// assert_eq!(directories.len(), 1);
258 /// assert_eq!(directories[0], test_dir);
259 /// # Ok::<(), subx_core::error::SubXError>(())
260 /// ```
261 pub fn get_directories(&self) -> Vec<PathBuf> {
262 let mut directories = std::collections::HashSet::new();
263
264 for path in &self.paths {
265 if path.is_dir() {
266 directories.insert(path.clone());
267 } else if path.is_file() {
268 if let Some(parent) = path.parent() {
269 directories.insert(parent.to_path_buf());
270 }
271 }
272 }
273
274 directories.into_iter().collect()
275 }
276
277 /// Expand files and directories, collecting all files that match the filter conditions.
278 ///
279 /// When archive extraction is enabled (the default), directly-specified
280 /// archive files (`.zip`, `.rar`) are transparently extracted to temporary
281 /// directories and their contents are included in the result instead of
282 /// the archive path itself. Archives found during directory traversal
283 /// are **not** extracted.
284 pub fn collect_files(&self) -> Result<CollectedFiles, SubXError> {
285 let mut files = Vec::new();
286 let mut temp_dirs = Vec::new();
287 let mut archive_origins: HashMap<PathBuf, PathBuf> = HashMap::new();
288
289 for base in &self.paths {
290 if base.is_file() {
291 // Check if this is an archive that should be extracted
292 if !self.no_extract {
293 if let Some(_format) = archive::detect_format(base) {
294 match self.extract_and_collect(base) {
295 Ok((extracted, temp_dir)) => {
296 let temp_root = temp_dir.path().to_path_buf();
297 archive_origins.insert(temp_root, base.clone());
298 files.extend(extracted);
299 temp_dirs.push(temp_dir);
300 continue;
301 }
302 Err(e) => {
303 warn!(
304 "Failed to extract archive {}, skipping: {e}",
305 base.display()
306 );
307 continue;
308 }
309 }
310 }
311 }
312 if self.matches_extension(base) {
313 files.push(base.clone());
314 }
315 } else if base.is_dir() {
316 if self.recursive {
317 files.extend(self.scan_directory_recursive(base)?);
318 } else {
319 files.extend(self.scan_directory_flat(base)?);
320 }
321 } else {
322 return Err(SubXError::InvalidPath(base.clone()));
323 }
324 }
325
326 if temp_dirs.is_empty() {
327 Ok(CollectedFiles::new(files))
328 } else {
329 Ok(CollectedFiles::with_archives(
330 files,
331 temp_dirs,
332 archive_origins,
333 ))
334 }
335 }
336
337 /// Extracts an archive to a temp directory and returns paths matching
338 /// the configured extension filter.
339 fn extract_and_collect(
340 &self,
341 archive_path: &Path,
342 ) -> Result<(Vec<PathBuf>, TempDir), SubXError> {
343 let temp_dir = TempDir::new().map_err(|e| {
344 SubXError::CommandExecution(format!("Failed to create temp directory: {e}"))
345 })?;
346 let extracted = archive::extract_archive(archive_path, temp_dir.path()).map_err(|e| {
347 SubXError::CommandExecution(format!(
348 "Failed to extract {}: {e}",
349 archive_path.display()
350 ))
351 })?;
352
353 let filtered: Vec<PathBuf> = extracted
354 .into_iter()
355 .filter(|p| self.matches_extension(p))
356 .collect();
357
358 Ok((filtered, temp_dir))
359 }
360
361 fn matches_extension(&self, path: &Path) -> bool {
362 if self.file_extensions.is_empty() {
363 return true;
364 }
365 path.extension()
366 .and_then(|e| e.to_str())
367 .map(|s| {
368 self.file_extensions
369 .iter()
370 .any(|ext| ext.eq_ignore_ascii_case(s))
371 })
372 .unwrap_or(false)
373 }
374
375 fn scan_directory_flat(&self, dir: &Path) -> Result<Vec<PathBuf>, SubXError> {
376 let mut result = Vec::new();
377 let rd = fs::read_dir(dir).map_err(|e| SubXError::DirectoryReadError {
378 path: dir.to_path_buf(),
379 source: e,
380 })?;
381 for entry in rd {
382 let entry = entry.map_err(|e| SubXError::DirectoryReadError {
383 path: dir.to_path_buf(),
384 source: e,
385 })?;
386 let ft = entry
387 .file_type()
388 .map_err(|e| SubXError::DirectoryReadError {
389 path: dir.to_path_buf(),
390 source: e,
391 })?;
392 if ft.is_symlink() {
393 log::debug!("Skipping symlink: {}", entry.path().display());
394 continue;
395 }
396 let p = entry.path();
397 if ft.is_file() && self.matches_extension(&p) {
398 result.push(p);
399 }
400 }
401 Ok(result)
402 }
403
404 fn scan_directory_recursive(&self, dir: &Path) -> Result<Vec<PathBuf>, SubXError> {
405 let mut result = Vec::new();
406 let rd = fs::read_dir(dir).map_err(|e| SubXError::DirectoryReadError {
407 path: dir.to_path_buf(),
408 source: e,
409 })?;
410 for entry in rd {
411 let entry = entry.map_err(|e| SubXError::DirectoryReadError {
412 path: dir.to_path_buf(),
413 source: e,
414 })?;
415 let ft = entry
416 .file_type()
417 .map_err(|e| SubXError::DirectoryReadError {
418 path: dir.to_path_buf(),
419 source: e,
420 })?;
421 if ft.is_symlink() {
422 log::debug!("Skipping symlink: {}", entry.path().display());
423 continue;
424 }
425 let p = entry.path();
426 if ft.is_file() {
427 if self.matches_extension(&p) {
428 result.push(p.clone());
429 }
430 } else if ft.is_dir() {
431 result.extend(self.scan_directory_recursive(&p)?);
432 }
433 }
434 Ok(result)
435 }
436}
437
438/// Result of collecting files from input paths, including any temporary
439/// directories created during archive extraction.
440///
441/// This struct owns any `TempDir` handles created during archive extraction.
442/// The temporary directories are automatically cleaned up when this value
443/// is dropped.
444#[derive(Debug)]
445pub struct CollectedFiles {
446 /// Collected file paths
447 paths: Vec<PathBuf>,
448 /// Temporary directories from archive extraction (kept alive by ownership)
449 _temp_dirs: Vec<TempDir>,
450 /// Mapping from temp-directory root to original archive file path
451 archive_origins: HashMap<PathBuf, PathBuf>,
452}
453
454impl CollectedFiles {
455 /// Creates a new `CollectedFiles` with no archive origins.
456 pub fn new(paths: Vec<PathBuf>) -> Self {
457 Self {
458 paths,
459 _temp_dirs: Vec::new(),
460 archive_origins: HashMap::new(),
461 }
462 }
463
464 /// Creates a new `CollectedFiles` with archive context.
465 pub fn with_archives(
466 paths: Vec<PathBuf>,
467 temp_dirs: Vec<TempDir>,
468 archive_origins: HashMap<PathBuf, PathBuf>,
469 ) -> Self {
470 Self {
471 paths,
472 _temp_dirs: temp_dirs,
473 archive_origins,
474 }
475 }
476
477 /// Returns the archive origin path for a file extracted from an archive.
478 ///
479 /// If the given path starts with a known temp-directory root, returns
480 /// the original archive file path. Returns `None` for non-archive paths.
481 pub fn archive_origin(&self, path: &Path) -> Option<&Path> {
482 for (temp_root, archive_path) in &self.archive_origins {
483 if path.starts_with(temp_root) {
484 return Some(archive_path.as_path());
485 }
486 }
487 None
488 }
489
490 /// Resolve the directory a derived output file belongs in for `input`.
491 ///
492 /// Preference order:
493 /// 1. the parent directory of the archive `input` was extracted from,
494 /// when [`CollectedFiles::archive_origin`] is `Some` and that archive
495 /// has a parent;
496 /// 2. `input`'s own parent — for a single-component path such as
497 /// `movie.srt` that parent is `Path::new("")`, deliberately NOT
498 /// normalised to `Path::new(".")`: callers join onto the returned
499 /// directory, `""` joins to the bare name, and normalising would
500 /// render `./movie.zh.srt` where the CLI has always printed
501 /// `movie.zh.srt`;
502 /// 3. `Path::new(".")` only for an input with no parent component at all
503 /// (`/`, or the empty path).
504 ///
505 /// The archive rule exists so output is never written into a temporary
506 /// extraction directory, which is deleted when this [`CollectedFiles`]
507 /// is dropped. Neither this method nor [`CollectedFiles::default_output_path`]
508 /// touches the filesystem or creates a directory.
509 ///
510 /// This query is deliberately **not** the directory half of
511 /// [`CollectedFiles::default_output_path`], and `default_output_path`
512 /// SHALL NOT be rewritten as `default_output_dir(input).join(..)`:
513 /// `default_output_path`'s non-archive half is `input.with_extension(..)`,
514 /// which preserves today's rendered paths byte-for-byte, and its archive
515 /// half resolves beside the archive — a property this directory query
516 /// shares but whose join form the command loops never used. The
517 /// rendered forms reach CLI output, so both stay verbatim copies of the
518 /// loops they came from rather than being derived from each other.
519 ///
520 /// # Arguments
521 ///
522 /// * `input` - The input path whose default output directory is wanted.
523 ///
524 /// # Returns
525 ///
526 /// The directory to place derived output in, as described above — the
527 /// empty path for a bare relative filename, joining onto which keeps the
528 /// name bare.
529 ///
530 /// # Examples
531 ///
532 /// ```
533 /// use std::collections::HashMap;
534 /// # use std::path::PathBuf;
535 /// use std::path::Path;
536 /// use subx_core::core::input::CollectedFiles;
537 ///
538 /// // Direct input: the directory beside the input itself.
539 /// let collected = CollectedFiles::new(vec![PathBuf::from("/data/movie.srt")]);
540 /// assert_eq!(
541 /// collected.default_output_dir(Path::new("/data/movie.srt")),
542 /// Path::new("/data")
543 /// );
544 ///
545 /// // Archive-extracted input: the directory beside the archive.
546 /// let mut origins = HashMap::new();
547 /// origins.insert(
548 /// PathBuf::from("/tmp/subx-XXXX"),
549 /// PathBuf::from("/data/subs.zip"),
550 /// );
551 /// let collected = CollectedFiles::with_archives(Vec::new(), Vec::new(), origins);
552 /// assert_eq!(
553 /// collected.default_output_dir(Path::new("/tmp/subx-XXXX/movie.srt")),
554 /// Path::new("/data")
555 /// );
556 ///
557 /// // Bare relative input: the empty path (Path::parent of a
558 /// // single-component path is Some("")) — joining keeps the name bare,
559 /// // where normalising to "." would render "./movie.zh.srt".
560 /// let collected = CollectedFiles::new(vec![PathBuf::from("movie.srt")]);
561 /// assert_eq!(collected.default_output_dir(Path::new("movie.srt")), Path::new(""));
562 /// assert_eq!(
563 /// collected.default_output_dir(Path::new("movie.srt")).join("movie.zh.srt"),
564 /// PathBuf::from("movie.zh.srt")
565 /// );
566 ///
567 /// // A path with no parent component at all falls back to ".".
568 /// assert_eq!(collected.default_output_dir(Path::new("/")), Path::new("."));
569 /// ```
570 pub fn default_output_dir<'a>(&'a self, input: &'a Path) -> &'a Path {
571 self.archive_origin(input)
572 .and_then(Path::parent)
573 .or_else(|| input.parent())
574 .unwrap_or(Path::new("."))
575 }
576
577 /// Resolve the default output path for converting `input` to `extension`.
578 ///
579 /// - When `input` was extracted from an archive: the archive's parent
580 /// directory (or `Path::new(".")` when the archive has no parent)
581 /// joined with `<stem>.<extension>`, where `<stem>` is `input`'s file
582 /// stem or the literal `output` when it has none.
583 /// - Otherwise: `input.with_extension(extension)`.
584 ///
585 /// The archive rule exists so converted output is never written into a
586 /// temporary extraction directory, which is deleted when this
587 /// [`CollectedFiles`] is dropped. Neither this method nor
588 /// [`CollectedFiles::default_output_dir`] touches the filesystem or
589 /// creates a directory.
590 ///
591 /// This resolver is deliberately **not**
592 /// `default_output_dir(input).join(name)`, and SHALL NOT be rewritten
593 /// that way: its two arms are verbatim copies of the loop
594 /// `subx convert` has always run, and `convert` prints the resolved
595 /// path — byte-compatibility with that printed text is the contract.
596 /// (The hazard that makes the distinction load-bearing in the other
597 /// direction lives in [`CollectedFiles::default_output_dir`]:
598 /// normalising its single-component-path result from `Path::new("")` to
599 /// `Path::new(".")` would render `./movie.vtt` where the CLI prints
600 /// `movie.vtt`.)
601 ///
602 /// # Arguments
603 ///
604 /// * `input` - The input path being converted.
605 /// * `extension` - Target format extension (e.g. `"vtt"`).
606 ///
607 /// # Returns
608 ///
609 /// The default output path, as described above — byte-compatible with
610 /// the path `subx convert` prints when no `--output` is given.
611 ///
612 /// # Examples
613 ///
614 /// ```
615 /// use std::collections::HashMap;
616 /// # use std::path::PathBuf;
617 /// use std::path::Path;
618 /// use subx_core::core::input::CollectedFiles;
619 ///
620 /// // Archive-extracted input resolves beside the archive, not inside
621 /// // the extraction directory:
622 /// let mut origins = HashMap::new();
623 /// origins.insert(
624 /// PathBuf::from("/tmp/subx-XXXX"),
625 /// PathBuf::from("/data/subs.zip"),
626 /// );
627 /// let collected = CollectedFiles::with_archives(Vec::new(), Vec::new(), origins);
628 /// assert_eq!(
629 /// collected.default_output_path(Path::new("/tmp/subx-XXXX/movie.srt"), "vtt"),
630 /// Path::new("/data/movie.vtt")
631 /// );
632 ///
633 /// // Direct input keeps `with_extension` semantics — note the bare
634 /// // relative form renders `movie.vtt`, deliberately not `./movie.vtt`
635 /// // (which is what the forbidden join-over-"." rewrite would print):
636 /// let collected = CollectedFiles::new(Vec::new());
637 /// assert_eq!(
638 /// collected.default_output_path(Path::new("movie.srt"), "vtt"),
639 /// Path::new("movie.vtt")
640 /// );
641 /// assert_eq!(
642 /// collected.default_output_path(Path::new("/data/movie.srt"), "vtt"),
643 /// Path::new("/data/movie.vtt")
644 /// );
645 /// ```
646 pub fn default_output_path(&self, input: &Path, extension: &str) -> PathBuf {
647 match self.archive_origin(input) {
648 Some(archive) => {
649 // File came from an archive: write output beside the archive
650 let archive_dir = archive.parent().unwrap_or(Path::new("."));
651 let stem = input
652 .file_stem()
653 .and_then(|s| s.to_str())
654 .unwrap_or("output");
655 archive_dir.join(format!("{stem}.{extension}"))
656 }
657 None => input.with_extension(extension),
658 }
659 }
660
661 /// Consumes self and returns the collected paths.
662 ///
663 /// **Warning:** This drops the `TempDir` handles, so any paths pointing
664 /// to temporary extraction directories will become invalid.
665 pub fn into_paths(self) -> Vec<PathBuf> {
666 self.paths
667 }
668}
669
670impl std::ops::Deref for CollectedFiles {
671 type Target = Vec<PathBuf>;
672
673 fn deref(&self) -> &Self::Target {
674 &self.paths
675 }
676}
677
678impl AsRef<[PathBuf]> for CollectedFiles {
679 fn as_ref(&self) -> &[PathBuf] {
680 &self.paths
681 }
682}
683
684#[cfg(test)]
685mod output_location_tests {
686 use super::*;
687
688 fn archive_collected(temp_root: &str, archive: &str) -> CollectedFiles {
689 let mut origins = HashMap::new();
690 origins.insert(PathBuf::from(temp_root), PathBuf::from(archive));
691 CollectedFiles::with_archives(Vec::new(), Vec::new(), origins)
692 }
693
694 #[test]
695 fn output_path_resolves_archive_extracted_input_beside_the_archive() {
696 let collected = archive_collected("/tmp/subx-XXXX", "/data/subs.zip");
697 assert_eq!(
698 collected.default_output_path(Path::new("/tmp/subx-XXXX/movie.srt"), "vtt"),
699 PathBuf::from("/data/movie.vtt")
700 );
701 }
702
703 #[test]
704 fn output_path_resolves_direct_input_beside_itself() {
705 let collected = CollectedFiles::new(Vec::new());
706 assert_eq!(
707 collected.default_output_path(Path::new("/data/movie.srt"), "vtt"),
708 PathBuf::from("/data/movie.vtt")
709 );
710 }
711
712 #[test]
713 fn output_path_bare_relative_input_is_bare_not_dot_prefixed() {
714 // The exact rendering `subx convert movie.srt` prints — the
715 // forbidden join-over-"." rewrite would produce "./movie.vtt".
716 let collected = CollectedFiles::new(Vec::new());
717 let resolved = collected.default_output_path(Path::new("movie.srt"), "vtt");
718 assert_eq!(resolved, PathBuf::from("movie.vtt"));
719 assert!(!resolved.display().to_string().starts_with("./"));
720 }
721
722 #[test]
723 fn output_path_archive_without_parent_falls_back_to_dot() {
724 // `Path::new("subs.zip").parent()` is Some(""), so a relative
725 // archive resolves through the EMPTY path and joins to the bare
726 // name — the `unwrap_or(".")` fallback is only reachable for a
727 // literally-empty archive path, which no collection produces.
728 // Both forms are pinned here, verbatim-loop behaviour.
729 let collected = archive_collected("/tmp/subx-XXXX", "subs.zip");
730 assert_eq!(
731 collected.default_output_path(Path::new("/tmp/subx-XXXX/movie.srt"), "vtt"),
732 PathBuf::from("movie.vtt")
733 );
734 let collected = archive_collected("/tmp/subx-XXXX", "");
735 assert_eq!(
736 collected.default_output_path(Path::new("/tmp/subx-XXXX/movie.srt"), "vtt"),
737 PathBuf::from("./movie.vtt")
738 );
739 }
740
741 #[test]
742 fn output_path_extensionless_extracted_input_falls_back_to_output_stem() {
743 // Defensive fallback pinned verbatim from convert's loop: an entry
744 // with no file stem (only reachable for degenerate paths such as
745 // the root) uses the literal `output`.
746 let mut origins = HashMap::new();
747 origins.insert(PathBuf::from("/"), PathBuf::from("/data/subs.zip"));
748 let collected = CollectedFiles::with_archives(Vec::new(), Vec::new(), origins);
749 assert_eq!(
750 collected.default_output_path(Path::new("/"), "vtt"),
751 PathBuf::from("/data/output.vtt")
752 );
753 }
754
755 #[test]
756 fn output_dir_prefers_archive_parent_then_input_parent_then_empty() {
757 // Archive wins over the extraction directory.
758 let collected = archive_collected("/tmp/subx-XXXX", "/data/subs.zip");
759 assert_eq!(
760 collected.default_output_dir(Path::new("/tmp/subx-XXXX/movie.srt")),
761 Path::new("/data")
762 );
763 // No archive: the input's own directory.
764 let collected = CollectedFiles::new(Vec::new());
765 assert_eq!(
766 collected.default_output_dir(Path::new("/data/movie.srt")),
767 Path::new("/data")
768 );
769 // Single-component input: Path::parent is Some(""), NOT normalised
770 // to "." — joining onto it keeps the name bare (Decision 3's
771 // byte-compatibility contract).
772 assert_eq!(
773 collected.default_output_dir(Path::new("movie.srt")),
774 Path::new("")
775 );
776 assert_eq!(
777 collected
778 .default_output_dir(Path::new("movie.srt"))
779 .join("movie.zh.srt"),
780 PathBuf::from("movie.zh.srt")
781 );
782 // No parent component at all: the "." fallback.
783 assert_eq!(collected.default_output_dir(Path::new("/")), Path::new("."));
784 }
785
786 #[test]
787 fn output_dir_relative_archive_yields_empty_path_not_fallthrough() {
788 // A relative archive yields Some("") through the chain — NOT a
789 // fall-through to the extraction directory: Some("") is Some, and
790 // joining onto it keeps the derived name bare, which is what
791 // translate's own base-directory chain always produced.
792 let collected = archive_collected("/tmp/subx-XXXX", "subs.zip");
793 assert_eq!(
794 collected.default_output_dir(Path::new("/tmp/subx-XXXX/movie.srt")),
795 Path::new("")
796 );
797 }
798}
799
800#[cfg(test)]
801mod symlink_tests {
802 use super::*;
803 use std::fs;
804 use tempfile::TempDir;
805
806 #[cfg(unix)]
807 #[test]
808 fn test_scan_directory_recursive_skips_symlinks() {
809 let tmp = TempDir::new().unwrap();
810 let real = tmp.path().join("real.txt");
811 fs::write(&real, b"x").unwrap();
812 let link = tmp.path().join("link.txt");
813 std::os::unix::fs::symlink(&real, &link).unwrap();
814
815 let handler = InputPathHandler::from_args(&[tmp.path().to_path_buf()], true).unwrap();
816 let results = handler.scan_directory_recursive(tmp.path()).unwrap();
817
818 assert!(results.iter().any(|p| p == &real));
819 assert!(
820 !results.iter().any(|p| p == &link),
821 "symlinked file should have been skipped"
822 );
823 }
824
825 #[cfg(unix)]
826 #[test]
827 fn test_scan_directory_flat_skips_symlinks() {
828 let tmp = TempDir::new().unwrap();
829 let real = tmp.path().join("real.txt");
830 fs::write(&real, b"x").unwrap();
831 let link = tmp.path().join("link.txt");
832 std::os::unix::fs::symlink(&real, &link).unwrap();
833
834 let handler = InputPathHandler::from_args(&[tmp.path().to_path_buf()], false).unwrap();
835 let results = handler.scan_directory_flat(tmp.path()).unwrap();
836
837 assert!(results.iter().any(|p| p == &real));
838 assert!(!results.iter().any(|p| p == &link));
839 }
840}