Skip to main content

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}