Skip to main content

exarch_core/creation/
config.rs

1//! Configuration for archive creation operations.
2
3use crate::ArchiveError;
4use crate::Result;
5use crate::formats::detect::ArchiveType;
6use std::path::PathBuf;
7
8/// Configuration for archive creation operations.
9///
10/// Controls how archives are created from filesystem sources, including
11/// security options, compression settings, and file filtering.
12///
13/// # Examples
14///
15/// ```
16/// use exarch_core::creation::CreationConfig;
17///
18/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
19/// // Use secure defaults
20/// let config = CreationConfig::default();
21///
22/// // Customize for specific needs
23/// let custom = CreationConfig::default()
24///     .with_follow_symlinks(true)
25///     .with_compression_level(9)?;
26/// # Ok(())
27/// # }
28/// ```
29#[derive(Debug, Clone)]
30pub struct CreationConfig {
31    /// Follow symlinks when adding files to archive.
32    ///
33    /// Default: `false` (store symlinks as symlinks).
34    ///
35    /// Security note: Following symlinks may include unintended files
36    /// from outside the source directory.
37    pub follow_symlinks: bool,
38
39    /// Include hidden files (files starting with '.').
40    ///
41    /// Default: `false` (skip hidden files).
42    pub include_hidden: bool,
43
44    /// Maximum size for a single file in bytes.
45    ///
46    /// Files larger than this limit will be skipped.
47    /// `None` means no limit.
48    ///
49    /// Default: `None`.
50    pub max_file_size: Option<u64>,
51
52    /// Patterns to exclude from the archive.
53    ///
54    /// Files matching these patterns will be skipped.
55    ///
56    /// Default: `[".git", ".DS_Store", "*.tmp"]`.
57    pub exclude_patterns: Vec<String>,
58
59    /// Prefix to strip from entry paths in the archive.
60    ///
61    /// If set, this prefix will be removed from all entry paths.
62    /// Useful for creating archives without deep directory nesting.
63    ///
64    /// Default: `None`.
65    pub strip_prefix: Option<PathBuf>,
66
67    /// Compression level (1-9).
68    ///
69    /// Higher values provide better compression but slower speed.
70    /// `None` uses format-specific defaults.
71    ///
72    /// Default: `Some(6)` (balanced).
73    ///
74    /// Valid range: 1 (fastest) to 9 (best compression).
75    pub compression_level: Option<u8>,
76
77    /// Preserve file permissions in the archive.
78    ///
79    /// Default: `true`.
80    pub preserve_permissions: bool,
81
82    /// Archive format to create.
83    ///
84    /// `None` means auto-detect from output file extension.
85    ///
86    /// Default: `None`.
87    pub format: Option<ArchiveType>,
88}
89
90impl Default for CreationConfig {
91    /// Creates a `CreationConfig` with secure default settings.
92    ///
93    /// Default values:
94    /// - `follow_symlinks`: `false`
95    /// - `include_hidden`: `false`
96    /// - `max_file_size`: `None`
97    /// - `exclude_patterns`: `[".git", ".DS_Store", "*.tmp"]`
98    /// - `strip_prefix`: `None`
99    /// - `compression_level`: `Some(6)`
100    /// - `preserve_permissions`: `true`
101    /// - `format`: `None`
102    fn default() -> Self {
103        Self {
104            follow_symlinks: false,
105            include_hidden: false,
106            max_file_size: None,
107            exclude_patterns: vec![
108                ".git".to_string(),
109                ".DS_Store".to_string(),
110                "*.tmp".to_string(),
111            ],
112            strip_prefix: None,
113            compression_level: Some(6),
114            preserve_permissions: true,
115            format: None,
116        }
117    }
118}
119
120impl CreationConfig {
121    /// Creates a new `CreationConfig` with default settings.
122    #[must_use]
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Sets whether to follow symlinks.
128    #[must_use]
129    pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
130        self.follow_symlinks = follow;
131        self
132    }
133
134    /// Sets whether to include hidden files.
135    #[must_use]
136    pub fn with_include_hidden(mut self, include: bool) -> Self {
137        self.include_hidden = include;
138        self
139    }
140
141    /// Sets the maximum file size.
142    #[must_use]
143    pub fn with_max_file_size(mut self, max_size: Option<u64>) -> Self {
144        self.max_file_size = max_size;
145        self
146    }
147
148    /// Sets the exclude patterns.
149    #[must_use]
150    pub fn with_exclude_patterns(mut self, patterns: Vec<String>) -> Self {
151        self.exclude_patterns = patterns;
152        self
153    }
154
155    /// Sets the strip prefix.
156    #[must_use]
157    pub fn with_strip_prefix(mut self, prefix: Option<PathBuf>) -> Self {
158        self.strip_prefix = prefix;
159        self
160    }
161
162    /// Sets the compression level.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`ArchiveError::InvalidCompressionLevel`] if `level` is not
167    /// in the range 1–9.
168    pub fn with_compression_level(mut self, level: u8) -> Result<Self> {
169        if !(1..=9).contains(&level) {
170            return Err(ArchiveError::InvalidCompressionLevel { level });
171        }
172        self.compression_level = Some(level);
173        Ok(self)
174    }
175
176    /// Sets whether to preserve permissions.
177    #[must_use]
178    pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
179        self.preserve_permissions = preserve;
180        self
181    }
182
183    /// Sets the archive format.
184    #[must_use]
185    pub fn with_format(mut self, format: Option<ArchiveType>) -> Self {
186        self.format = format;
187        self
188    }
189
190    /// Validates the configuration.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if:
195    /// - Compression level is set but not in range 1-9
196    pub fn validate(&self) -> Result<()> {
197        if let Some(level) = self.compression_level
198            && !(1..=9).contains(&level)
199        {
200            return Err(ArchiveError::InvalidCompressionLevel { level });
201        }
202        Ok(())
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_creation_config_default() {
212        let config = CreationConfig::default();
213        assert!(!config.follow_symlinks);
214        assert!(!config.include_hidden);
215        assert_eq!(config.max_file_size, None);
216        assert_eq!(config.exclude_patterns.len(), 3);
217        assert!(config.exclude_patterns.contains(&".git".to_string()));
218        assert!(config.exclude_patterns.contains(&".DS_Store".to_string()));
219        assert!(config.exclude_patterns.contains(&"*.tmp".to_string()));
220        assert_eq!(config.strip_prefix, None);
221        assert_eq!(config.compression_level, Some(6));
222        assert!(config.preserve_permissions);
223        assert_eq!(config.format, None);
224    }
225
226    #[test]
227    #[allow(clippy::unwrap_used)]
228    fn test_creation_config_builder() {
229        let config = CreationConfig::default()
230            .with_follow_symlinks(true)
231            .with_include_hidden(true)
232            .with_max_file_size(Some(1024 * 1024))
233            .with_exclude_patterns(vec!["*.log".to_string()])
234            .with_strip_prefix(Some(PathBuf::from("/base")))
235            .with_compression_level(9)
236            .unwrap()
237            .with_preserve_permissions(false)
238            .with_format(Some(ArchiveType::TarGz));
239
240        assert!(config.follow_symlinks);
241        assert!(config.include_hidden);
242        assert_eq!(config.max_file_size, Some(1024 * 1024));
243        assert_eq!(config.exclude_patterns, vec!["*.log".to_string()]);
244        assert_eq!(config.strip_prefix, Some(PathBuf::from("/base")));
245        assert_eq!(config.compression_level, Some(9));
246        assert!(!config.preserve_permissions);
247        assert_eq!(config.format, Some(ArchiveType::TarGz));
248    }
249
250    #[test]
251    #[allow(clippy::unwrap_used)]
252    fn test_creation_config_validate_valid() {
253        let config = CreationConfig::default();
254        assert!(config.validate().is_ok());
255
256        let config = CreationConfig::default().with_compression_level(1).unwrap();
257        assert!(config.validate().is_ok());
258
259        let config = CreationConfig::default().with_compression_level(9).unwrap();
260        assert!(config.validate().is_ok());
261
262        let config = CreationConfig {
263            compression_level: None,
264            ..Default::default()
265        };
266        assert!(config.validate().is_ok());
267    }
268
269    #[test]
270    #[allow(clippy::unwrap_used)]
271    fn test_creation_config_validate_invalid() {
272        let config = CreationConfig {
273            compression_level: Some(0),
274            ..Default::default()
275        };
276        let result = config.validate();
277        assert!(result.is_err());
278        assert!(matches!(
279            result.unwrap_err(),
280            ArchiveError::InvalidCompressionLevel { level: 0 }
281        ));
282
283        let config = CreationConfig {
284            compression_level: Some(10),
285            ..Default::default()
286        };
287        let result = config.validate();
288        assert!(result.is_err());
289        assert!(matches!(
290            result.unwrap_err(),
291            ArchiveError::InvalidCompressionLevel { level: 10 }
292        ));
293    }
294
295    #[test]
296    fn test_creation_config_builder_invalid_compression() {
297        assert!(matches!(
298            CreationConfig::default().with_compression_level(0),
299            Err(ArchiveError::InvalidCompressionLevel { level: 0 })
300        ));
301        assert!(matches!(
302            CreationConfig::default().with_compression_level(10),
303            Err(ArchiveError::InvalidCompressionLevel { level: 10 })
304        ));
305    }
306
307    #[test]
308    fn test_creation_config_new() {
309        let config = CreationConfig::new();
310        assert_eq!(config.compression_level, Some(6));
311        assert!(config.preserve_permissions);
312    }
313
314    #[test]
315    fn test_creation_config_secure_defaults() {
316        let config = CreationConfig::default();
317
318        // Security: Don't follow symlinks by default
319        assert!(
320            !config.follow_symlinks,
321            "should not follow symlinks by default (security)"
322        );
323
324        // Security: Don't include hidden files by default
325        assert!(
326            !config.include_hidden,
327            "should not include hidden files by default"
328        );
329
330        // Security: Exclude sensitive patterns
331        assert!(
332            config.exclude_patterns.contains(&".git".to_string()),
333            "should exclude .git by default"
334        );
335    }
336}