Skip to main content

exarch_core/creation/
creator.rs

1//! Builder for creating archives with fluent API.
2
3use std::path::Path;
4use std::path::PathBuf;
5
6use crate::creation::config::CreationConfig;
7use crate::creation::report::CreationReport;
8use crate::error::ArchiveError;
9use crate::error::Result;
10use crate::formats::detect::ArchiveType;
11
12/// Builder for creating archives with fluent API.
13///
14/// Provides a convenient, type-safe interface for configuring and creating
15/// archives with various compression formats and security options.
16///
17/// # Examples
18///
19/// ```no_run
20/// use exarch_core::creation::ArchiveCreator;
21///
22/// let report = ArchiveCreator::new()
23///     .output("backup.tar.gz")
24///     .add_source("src/")
25///     .add_source("Cargo.toml")
26///     .compression_level(9)?
27///     .create()?;
28///
29/// println!("Created archive with {} files", report.files_added);
30/// # Ok::<(), exarch_core::ArchiveError>(())
31/// ```
32#[derive(Debug, Default)]
33pub struct ArchiveCreator {
34    output_path: Option<PathBuf>,
35    sources: Vec<PathBuf>,
36    config: CreationConfig,
37}
38
39impl ArchiveCreator {
40    /// Creates a new `ArchiveCreator` with default settings.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use exarch_core::creation::ArchiveCreator;
46    ///
47    /// let creator = ArchiveCreator::new();
48    /// ```
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Sets the output archive path.
55    ///
56    /// The archive format is auto-detected from the file extension
57    /// unless explicitly set via `format()`.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use exarch_core::creation::ArchiveCreator;
63    ///
64    /// let creator = ArchiveCreator::new().output("backup.tar.gz");
65    /// ```
66    #[must_use]
67    pub fn output<P: AsRef<Path>>(mut self, path: P) -> Self {
68        self.output_path = Some(path.as_ref().to_path_buf());
69        self
70    }
71
72    /// Adds a source file or directory.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use exarch_core::creation::ArchiveCreator;
78    ///
79    /// let creator = ArchiveCreator::new()
80    ///     .add_source("src/")
81    ///     .add_source("Cargo.toml");
82    /// ```
83    #[must_use]
84    pub fn add_source<P: AsRef<Path>>(mut self, path: P) -> Self {
85        self.sources.push(path.as_ref().to_path_buf());
86        self
87    }
88
89    /// Adds multiple source files or directories.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use exarch_core::creation::ArchiveCreator;
95    ///
96    /// let creator = ArchiveCreator::new().sources(&["src/", "Cargo.toml", "README.md"]);
97    /// ```
98    #[must_use]
99    pub fn sources<P: AsRef<Path>>(mut self, paths: &[P]) -> Self {
100        self.sources
101            .extend(paths.iter().map(|p| p.as_ref().to_path_buf()));
102        self
103    }
104
105    /// Sets the full configuration.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use exarch_core::creation::ArchiveCreator;
111    /// use exarch_core::creation::CreationConfig;
112    ///
113    /// let config = CreationConfig::default().with_follow_symlinks(true);
114    ///
115    /// let creator = ArchiveCreator::new().config(config);
116    /// ```
117    #[must_use]
118    pub fn config(mut self, config: CreationConfig) -> Self {
119        self.config = config;
120        self
121    }
122
123    /// Sets the compression level (1-9).
124    ///
125    /// Higher values provide better compression but slower speed.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ArchiveError::InvalidCompressionLevel`] if `level` is not in
130    /// the range 1–9.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use exarch_core::creation::ArchiveCreator;
136    ///
137    /// let creator = ArchiveCreator::new().compression_level(9)?; // Maximum compression
138    ///
139    /// # Ok::<(), exarch_core::ArchiveError>(())
140    /// ```
141    pub fn compression_level(mut self, level: u8) -> Result<Self> {
142        if !(1..=9).contains(&level) {
143            return Err(ArchiveError::InvalidCompressionLevel { level });
144        }
145        self.config.compression_level = Some(level);
146        Ok(self)
147    }
148
149    /// Sets whether to follow symlinks.
150    ///
151    /// Default: `false` (symlinks stored as symlinks).
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use exarch_core::creation::ArchiveCreator;
157    ///
158    /// let creator = ArchiveCreator::new().follow_symlinks(true);
159    /// ```
160    #[must_use]
161    pub fn follow_symlinks(mut self, follow: bool) -> Self {
162        self.config.follow_symlinks = follow;
163        self
164    }
165
166    /// Sets whether to include hidden files.
167    ///
168    /// Default: `false` (skip hidden files).
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use exarch_core::creation::ArchiveCreator;
174    ///
175    /// let creator = ArchiveCreator::new().include_hidden(true);
176    /// ```
177    #[must_use]
178    pub fn include_hidden(mut self, include: bool) -> Self {
179        self.config.include_hidden = include;
180        self
181    }
182
183    /// Adds an exclude pattern.
184    ///
185    /// Files matching this pattern will be skipped.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use exarch_core::creation::ArchiveCreator;
191    ///
192    /// let creator = ArchiveCreator::new().exclude("*.log").exclude("target/");
193    /// ```
194    #[must_use]
195    pub fn exclude<S: Into<String>>(mut self, pattern: S) -> Self {
196        self.config.exclude_patterns.push(pattern.into());
197        self
198    }
199
200    /// Sets the strip prefix for archive paths.
201    ///
202    /// If set, this prefix will be removed from all entry paths in the archive.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use exarch_core::creation::ArchiveCreator;
208    ///
209    /// let creator = ArchiveCreator::new().strip_prefix("/base/path");
210    /// ```
211    #[must_use]
212    pub fn strip_prefix<P: AsRef<Path>>(mut self, prefix: P) -> Self {
213        self.config.strip_prefix = Some(prefix.as_ref().to_path_buf());
214        self
215    }
216
217    /// Sets explicit archive format.
218    ///
219    /// If not set, format is auto-detected from output file extension.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use exarch_core::creation::ArchiveCreator;
225    /// use exarch_core::formats::detect::ArchiveType;
226    ///
227    /// let creator = ArchiveCreator::new().format(ArchiveType::TarGz);
228    /// ```
229    #[must_use]
230    pub fn format(mut self, format: ArchiveType) -> Self {
231        self.config.format = Some(format);
232        self
233    }
234
235    /// Creates the archive.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if:
240    /// - Output path not set
241    /// - No sources provided
242    /// - Source files don't exist
243    /// - I/O errors during creation
244    /// - Invalid configuration (e.g., invalid compression level)
245    ///
246    /// # Examples
247    ///
248    /// ```no_run
249    /// use exarch_core::creation::ArchiveCreator;
250    ///
251    /// let report = ArchiveCreator::new()
252    ///     .output("backup.tar.gz")
253    ///     .add_source("src/")
254    ///     .create()?;
255    /// # Ok::<(), exarch_core::ArchiveError>(())
256    /// ```
257    pub fn create(self) -> Result<CreationReport> {
258        let output_path = self
259            .output_path
260            .ok_or_else(|| ArchiveError::InvalidConfiguration {
261                reason: "output path not set".to_string(),
262            })?;
263
264        if self.sources.is_empty() {
265            return Err(ArchiveError::InvalidConfiguration {
266                reason: "no source paths provided".to_string(),
267            });
268        }
269
270        // Configuration is validated inside `create_archive`.
271        crate::api::create_archive(&output_path, &self.sources, &self.config)
272    }
273}
274
275#[cfg(test)]
276#[allow(clippy::unwrap_used)]
277mod tests {
278    use super::*;
279    use crate::formats::detect::ArchiveType;
280    use std::assert_matches;
281    use std::path::PathBuf;
282
283    #[test]
284    fn test_builder_basic() {
285        let creator = ArchiveCreator::new()
286            .output("test.tar.gz")
287            .add_source("src/");
288
289        assert_eq!(creator.output_path, Some(PathBuf::from("test.tar.gz")));
290        assert_eq!(creator.sources, vec![PathBuf::from("src/")]);
291    }
292
293    #[test]
294    fn test_builder_multiple_sources() {
295        let creator = ArchiveCreator::new()
296            .add_source("src/")
297            .add_source("Cargo.toml")
298            .add_source("README.md");
299
300        assert_eq!(creator.sources.len(), 3);
301        assert_eq!(creator.sources[0], PathBuf::from("src/"));
302        assert_eq!(creator.sources[1], PathBuf::from("Cargo.toml"));
303        assert_eq!(creator.sources[2], PathBuf::from("README.md"));
304    }
305
306    #[test]
307    fn test_builder_sources_array() {
308        let creator = ArchiveCreator::new().sources(&["src/", "Cargo.toml", "README.md"]);
309
310        assert_eq!(creator.sources.len(), 3);
311    }
312
313    #[test]
314    fn test_builder_config_methods() {
315        let creator = ArchiveCreator::new()
316            .compression_level(9)
317            .unwrap()
318            .follow_symlinks(true)
319            .include_hidden(true)
320            .exclude("*.log")
321            .exclude("target/")
322            .strip_prefix("/base")
323            .format(ArchiveType::TarGz);
324
325        assert_eq!(creator.config.compression_level, Some(9));
326        assert!(creator.config.follow_symlinks);
327        assert!(creator.config.include_hidden);
328        assert!(
329            creator
330                .config
331                .exclude_patterns
332                .contains(&"*.log".to_string())
333        );
334        assert!(
335            creator
336                .config
337                .exclude_patterns
338                .contains(&"target/".to_string())
339        );
340        assert_eq!(creator.config.strip_prefix, Some(PathBuf::from("/base")));
341        assert_eq!(creator.config.format, Some(ArchiveType::TarGz));
342    }
343
344    #[test]
345    fn test_builder_no_output_error() {
346        let creator = ArchiveCreator::new().add_source("src/");
347
348        let result = creator.create();
349        assert!(result.is_err());
350        assert_matches!(
351            result.unwrap_err(),
352            ArchiveError::InvalidConfiguration { .. }
353        );
354    }
355
356    #[test]
357    fn test_builder_no_sources_error() {
358        let creator = ArchiveCreator::new().output("test.tar.gz");
359
360        let result = creator.create();
361        assert!(result.is_err());
362        assert_matches!(
363            result.unwrap_err(),
364            ArchiveError::InvalidConfiguration { .. }
365        );
366    }
367
368    #[test]
369    fn test_builder_compression_level() {
370        let creator = ArchiveCreator::new().compression_level(9).unwrap();
371        assert_eq!(creator.config.compression_level, Some(9));
372    }
373
374    #[test]
375    fn test_builder_compression_level_mid() {
376        let creator = ArchiveCreator::new().compression_level(5).unwrap();
377        assert_eq!(creator.config.compression_level, Some(5));
378    }
379
380    #[test]
381    fn test_builder_compression_level_invalid() {
382        assert_matches!(
383            ArchiveCreator::new().compression_level(0),
384            Err(ArchiveError::InvalidCompressionLevel { level: 0 })
385        );
386        assert_matches!(
387            ArchiveCreator::new().compression_level(10),
388            Err(ArchiveError::InvalidCompressionLevel { level: 10 })
389        );
390    }
391
392    #[test]
393    fn test_builder_exclude_patterns() {
394        let creator = ArchiveCreator::new()
395            .exclude("*.log")
396            .exclude("*.tmp")
397            .exclude(".git");
398
399        assert!(
400            creator
401                .config
402                .exclude_patterns
403                .contains(&"*.log".to_string())
404        );
405        assert!(
406            creator
407                .config
408                .exclude_patterns
409                .contains(&"*.tmp".to_string())
410        );
411        assert!(
412            creator
413                .config
414                .exclude_patterns
415                .contains(&".git".to_string())
416        );
417
418        // Default exclude patterns should still be there
419        assert!(
420            creator
421                .config
422                .exclude_patterns
423                .contains(&".DS_Store".to_string())
424        );
425    }
426
427    #[test]
428    fn test_builder_full_config() {
429        let config = CreationConfig::default()
430            .with_follow_symlinks(true)
431            .with_compression_level(9)
432            .unwrap();
433
434        let creator = ArchiveCreator::new()
435            .output("test.tar.gz")
436            .add_source("src/")
437            .config(config);
438
439        assert!(creator.config.follow_symlinks);
440        assert_eq!(creator.config.compression_level, Some(9));
441    }
442
443    #[test]
444    fn test_builder_default() {
445        let creator = ArchiveCreator::default();
446        assert_eq!(creator.output_path, None);
447        assert_eq!(creator.sources.len(), 0);
448    }
449
450    #[test]
451    fn test_builder_new() {
452        let creator = ArchiveCreator::new();
453        assert_eq!(creator.output_path, None);
454        assert_eq!(creator.sources.len(), 0);
455    }
456}