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        // Validate configuration
271        self.config.validate()?;
272
273        crate::api::create_archive(&output_path, &self.sources, &self.config)
274    }
275}
276
277#[cfg(test)]
278#[allow(clippy::unwrap_used)]
279mod tests {
280    use super::*;
281    use crate::formats::detect::ArchiveType;
282    use std::path::PathBuf;
283
284    #[test]
285    fn test_builder_basic() {
286        let creator = ArchiveCreator::new()
287            .output("test.tar.gz")
288            .add_source("src/");
289
290        assert_eq!(creator.output_path, Some(PathBuf::from("test.tar.gz")));
291        assert_eq!(creator.sources, vec![PathBuf::from("src/")]);
292    }
293
294    #[test]
295    fn test_builder_multiple_sources() {
296        let creator = ArchiveCreator::new()
297            .add_source("src/")
298            .add_source("Cargo.toml")
299            .add_source("README.md");
300
301        assert_eq!(creator.sources.len(), 3);
302        assert_eq!(creator.sources[0], PathBuf::from("src/"));
303        assert_eq!(creator.sources[1], PathBuf::from("Cargo.toml"));
304        assert_eq!(creator.sources[2], PathBuf::from("README.md"));
305    }
306
307    #[test]
308    fn test_builder_sources_array() {
309        let creator = ArchiveCreator::new().sources(&["src/", "Cargo.toml", "README.md"]);
310
311        assert_eq!(creator.sources.len(), 3);
312    }
313
314    #[test]
315    fn test_builder_config_methods() {
316        let creator = ArchiveCreator::new()
317            .compression_level(9)
318            .unwrap()
319            .follow_symlinks(true)
320            .include_hidden(true)
321            .exclude("*.log")
322            .exclude("target/")
323            .strip_prefix("/base")
324            .format(ArchiveType::TarGz);
325
326        assert_eq!(creator.config.compression_level, Some(9));
327        assert!(creator.config.follow_symlinks);
328        assert!(creator.config.include_hidden);
329        assert!(
330            creator
331                .config
332                .exclude_patterns
333                .contains(&"*.log".to_string())
334        );
335        assert!(
336            creator
337                .config
338                .exclude_patterns
339                .contains(&"target/".to_string())
340        );
341        assert_eq!(creator.config.strip_prefix, Some(PathBuf::from("/base")));
342        assert_eq!(creator.config.format, Some(ArchiveType::TarGz));
343    }
344
345    #[test]
346    fn test_builder_no_output_error() {
347        let creator = ArchiveCreator::new().add_source("src/");
348
349        let result = creator.create();
350        assert!(result.is_err());
351        assert!(matches!(
352            result.unwrap_err(),
353            ArchiveError::InvalidConfiguration { .. }
354        ));
355    }
356
357    #[test]
358    fn test_builder_no_sources_error() {
359        let creator = ArchiveCreator::new().output("test.tar.gz");
360
361        let result = creator.create();
362        assert!(result.is_err());
363        assert!(matches!(
364            result.unwrap_err(),
365            ArchiveError::InvalidConfiguration { .. }
366        ));
367    }
368
369    #[test]
370    fn test_builder_compression_level() {
371        let creator = ArchiveCreator::new().compression_level(9).unwrap();
372        assert_eq!(creator.config.compression_level, Some(9));
373    }
374
375    #[test]
376    fn test_builder_compression_level_mid() {
377        let creator = ArchiveCreator::new().compression_level(5).unwrap();
378        assert_eq!(creator.config.compression_level, Some(5));
379    }
380
381    #[test]
382    fn test_builder_compression_level_invalid() {
383        assert!(matches!(
384            ArchiveCreator::new().compression_level(0),
385            Err(ArchiveError::InvalidCompressionLevel { level: 0 })
386        ));
387        assert!(matches!(
388            ArchiveCreator::new().compression_level(10),
389            Err(ArchiveError::InvalidCompressionLevel { level: 10 })
390        ));
391    }
392
393    #[test]
394    fn test_builder_exclude_patterns() {
395        let creator = ArchiveCreator::new()
396            .exclude("*.log")
397            .exclude("*.tmp")
398            .exclude(".git");
399
400        assert!(
401            creator
402                .config
403                .exclude_patterns
404                .contains(&"*.log".to_string())
405        );
406        assert!(
407            creator
408                .config
409                .exclude_patterns
410                .contains(&"*.tmp".to_string())
411        );
412        assert!(
413            creator
414                .config
415                .exclude_patterns
416                .contains(&".git".to_string())
417        );
418
419        // Default exclude patterns should still be there
420        assert!(
421            creator
422                .config
423                .exclude_patterns
424                .contains(&".DS_Store".to_string())
425        );
426    }
427
428    #[test]
429    fn test_builder_full_config() {
430        let config = CreationConfig::default()
431            .with_follow_symlinks(true)
432            .with_compression_level(9)
433            .unwrap();
434
435        let creator = ArchiveCreator::new()
436            .output("test.tar.gz")
437            .add_source("src/")
438            .config(config);
439
440        assert!(creator.config.follow_symlinks);
441        assert_eq!(creator.config.compression_level, Some(9));
442    }
443
444    #[test]
445    fn test_builder_default() {
446        let creator = ArchiveCreator::default();
447        assert_eq!(creator.output_path, None);
448        assert_eq!(creator.sources.len(), 0);
449    }
450
451    #[test]
452    fn test_builder_new() {
453        let creator = ArchiveCreator::new();
454        assert_eq!(creator.output_path, None);
455        assert_eq!(creator.sources.len(), 0);
456    }
457}