cuenv_ignore/
lib.rs

1//! Generate ignore files (.gitignore, .dockerignore, etc.)
2//!
3//! This crate provides a builder-based API for generating tool-specific ignore files
4//! from a declarative configuration.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use cuenv_ignore::{IgnoreFile, IgnoreFiles};
10//!
11//! let result = IgnoreFiles::builder()
12//!     .directory(".")
13//!     .file(IgnoreFile::new("git")
14//!         .pattern("node_modules/")
15//!         .pattern(".env"))
16//!     .file(IgnoreFile::new("docker")
17//!         .pattern("target/"))
18//!     .generate()?;
19//!
20//! for file in &result.files {
21//!     println!("{}: {}", file.status, file.filename);
22//! }
23//! # Ok::<(), cuenv_ignore::Error>(())
24//! ```
25//!
26//! # Features
27//!
28//! - `serde`: Enable serde serialization/deserialization for configuration types
29
30#![warn(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34#[cfg(feature = "serde")]
35use serde::{Deserialize, Serialize};
36
37/// A single ignore file configuration.
38///
39/// # Example
40///
41/// ```rust
42/// use cuenv_ignore::IgnoreFile;
43///
44/// let file = IgnoreFile::new("git")
45///     .pattern("node_modules/")
46///     .pattern(".env")
47///     .header("Generated by my-tool");
48///
49/// assert_eq!(file.output_filename(), ".gitignore");
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
53pub struct IgnoreFile {
54    tool: String,
55    patterns: Vec<String>,
56    filename: Option<String>,
57    header: Option<String>,
58}
59
60impl IgnoreFile {
61    /// Create a new ignore file configuration for a tool.
62    ///
63    /// The tool name is used to generate the default filename as `.{tool}ignore`.
64    ///
65    /// # Example
66    ///
67    /// ```rust
68    /// use cuenv_ignore::IgnoreFile;
69    ///
70    /// let file = IgnoreFile::new("git");
71    /// assert_eq!(file.output_filename(), ".gitignore");
72    ///
73    /// let file = IgnoreFile::new("docker");
74    /// assert_eq!(file.output_filename(), ".dockerignore");
75    /// ```
76    #[must_use]
77    pub fn new(tool: impl Into<String>) -> Self {
78        Self {
79            tool: tool.into(),
80            patterns: Vec::new(),
81            filename: None,
82            header: None,
83        }
84    }
85
86    /// Add a single pattern to the ignore file.
87    #[must_use]
88    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
89        self.patterns.push(pattern.into());
90        self
91    }
92
93    /// Add multiple patterns to the ignore file.
94    #[must_use]
95    pub fn patterns(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
96        self.patterns.extend(patterns.into_iter().map(Into::into));
97        self
98    }
99
100    /// Set a custom filename for the ignore file.
101    ///
102    /// If not set, defaults to `.{tool}ignore`.
103    #[must_use]
104    pub fn filename(mut self, filename: impl Into<String>) -> Self {
105        self.filename = Some(filename.into());
106        self
107    }
108
109    /// Optionally set a custom filename for the ignore file.
110    #[must_use]
111    pub fn filename_opt(mut self, filename: Option<impl Into<String>>) -> Self {
112        self.filename = filename.map(Into::into);
113        self
114    }
115
116    /// Set a header comment for the ignore file.
117    ///
118    /// The header will be added at the top of the file with `#` prefixes.
119    /// Each line of the header will be prefixed with `# `.
120    #[must_use]
121    pub fn header(mut self, header: impl Into<String>) -> Self {
122        self.header = Some(header.into());
123        self
124    }
125
126    /// Get the output filename for this ignore file.
127    ///
128    /// Returns the custom filename if set, otherwise `.{tool}ignore`.
129    #[must_use]
130    pub fn output_filename(&self) -> String {
131        self.filename
132            .clone()
133            .unwrap_or_else(|| format!(".{}ignore", self.tool))
134    }
135
136    /// Get the tool name.
137    #[must_use]
138    pub fn tool(&self) -> &str {
139        &self.tool
140    }
141
142    /// Get the patterns.
143    #[must_use]
144    pub fn patterns_list(&self) -> &[String] {
145        &self.patterns
146    }
147
148    /// Generate the file content.
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use cuenv_ignore::IgnoreFile;
154    ///
155    /// let file = IgnoreFile::new("git")
156    ///     .pattern("node_modules/")
157    ///     .header("My header");
158    ///
159    /// let content = file.generate();
160    /// assert!(content.contains("# My header"));
161    /// assert!(content.contains("node_modules/"));
162    /// ```
163    #[must_use]
164    pub fn generate(&self) -> String {
165        let mut lines = Vec::new();
166
167        // Add header if present
168        if let Some(ref header) = self.header {
169            for line in header.lines() {
170                lines.push(format!("# {line}"));
171            }
172            lines.push(String::new());
173        }
174
175        // Add patterns
176        lines.extend(self.patterns.iter().cloned());
177
178        format!("{}\n", lines.join("\n"))
179    }
180}
181
182/// Entry point for building and generating ignore files.
183pub struct IgnoreFiles;
184
185impl IgnoreFiles {
186    /// Create a new builder for generating ignore files.
187    ///
188    /// # Example
189    ///
190    /// ```rust,no_run
191    /// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
192    ///
193    /// let result = IgnoreFiles::builder()
194    ///     .directory(".")
195    ///     .file(IgnoreFile::new("git").pattern("*.log"))
196    ///     .generate()?;
197    /// # Ok::<(), cuenv_ignore::Error>(())
198    /// ```
199    #[must_use]
200    pub fn builder() -> IgnoreFilesBuilder {
201        IgnoreFilesBuilder::default()
202    }
203}
204
205/// Builder for generating multiple ignore files.
206///
207/// # Example
208///
209/// ```rust,no_run
210/// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
211///
212/// let result = IgnoreFiles::builder()
213///     .directory("/path/to/project")
214///     .require_git_repo(true)
215///     .dry_run(false)
216///     .file(IgnoreFile::new("git")
217///         .pattern("node_modules/")
218///         .pattern(".env"))
219///     .file(IgnoreFile::new("docker")
220///         .pattern("target/"))
221///     .generate()?;
222/// # Ok::<(), cuenv_ignore::Error>(())
223/// ```
224#[derive(Debug, Default)]
225pub struct IgnoreFilesBuilder {
226    directory: Option<PathBuf>,
227    files: Vec<IgnoreFile>,
228    require_git_repo: bool,
229    dry_run: bool,
230}
231
232impl IgnoreFilesBuilder {
233    /// Set the directory where ignore files will be generated.
234    ///
235    /// Defaults to the current directory if not set.
236    #[must_use]
237    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
238        self.directory = Some(dir.as_ref().to_path_buf());
239        self
240    }
241
242    /// Add a single ignore file configuration.
243    #[must_use]
244    pub fn file(mut self, file: IgnoreFile) -> Self {
245        self.files.push(file);
246        self
247    }
248
249    /// Add multiple ignore file configurations.
250    #[must_use]
251    pub fn files(mut self, files: impl IntoIterator<Item = IgnoreFile>) -> Self {
252        self.files.extend(files);
253        self
254    }
255
256    /// Require that the directory is within a Git repository.
257    ///
258    /// Defaults to `false`. When `true`, returns an error if the directory
259    /// is not within a Git repository.
260    #[must_use]
261    pub fn require_git_repo(mut self, require: bool) -> Self {
262        self.require_git_repo = require;
263        self
264    }
265
266    /// Enable dry-run mode.
267    ///
268    /// When `true`, no files will be written. The result will indicate
269    /// what would happen with `WouldCreate` and `WouldUpdate` statuses.
270    #[must_use]
271    pub fn dry_run(mut self, dry_run: bool) -> Self {
272        self.dry_run = dry_run;
273        self
274    }
275
276    /// Generate the ignore files.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if:
281    /// - `require_git_repo` is true and the directory is not within a Git repository
282    /// - A tool name contains invalid characters (path separators)
283    /// - File I/O fails
284    pub fn generate(self) -> Result<SyncResult> {
285        let dir = self.directory.unwrap_or_else(|| PathBuf::from("."));
286
287        tracing::info!("Starting ignore file generation");
288
289        // Verify we're in a git repository if required
290        if self.require_git_repo {
291            verify_git_repository(&dir)?;
292        }
293
294        let mut results = Vec::new();
295
296        // Sort files by tool name for deterministic output
297        let mut sorted_files = self.files;
298        sorted_files.sort_by(|a, b| a.tool.cmp(&b.tool));
299
300        for file in sorted_files {
301            // Skip empty pattern lists
302            if file.patterns.is_empty() {
303                tracing::debug!("Skipping tool '{}' - no patterns", file.tool);
304                continue;
305            }
306
307            // Validate tool name
308            validate_tool_name(&file.tool)?;
309
310            // Get and validate filename
311            let filename = file.output_filename();
312            validate_filename(&filename)?;
313
314            let filepath = dir.join(&filename);
315            let content = file.generate();
316
317            let (status, pattern_count) = if self.dry_run {
318                let status = if filepath.exists() {
319                    let existing = std::fs::read_to_string(&filepath)?;
320                    if existing == content {
321                        FileStatus::Unchanged
322                    } else {
323                        FileStatus::WouldUpdate
324                    }
325                } else {
326                    FileStatus::WouldCreate
327                };
328                (status, file.patterns.len())
329            } else {
330                let status = write_ignore_file(&filepath, &content)?;
331                (status, file.patterns.len())
332            };
333
334            tracing::info!(
335                filename = %filename,
336                status = %status,
337                patterns = pattern_count,
338                "Processed ignore file"
339            );
340
341            results.push(FileResult {
342                filename,
343                status,
344                pattern_count,
345            });
346        }
347
348        Ok(SyncResult { files: results })
349    }
350}
351
352// ============================================================================
353// Legacy API for backwards compatibility
354// ============================================================================
355
356/// Configuration for generating a single ignore file.
357///
358/// This is the legacy API. Consider using [`IgnoreFile`] with the builder pattern instead.
359#[derive(Debug, Clone)]
360#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
361pub struct IgnoreConfig {
362    /// Tool name (e.g., "git", "docker", "npm").
363    pub tool: String,
364    /// List of patterns to include in the ignore file.
365    pub patterns: Vec<String>,
366    /// Optional filename override.
367    pub filename: Option<String>,
368}
369
370/// Generate ignore files from the given configurations.
371///
372/// This is the legacy API. Consider using [`IgnoreFiles::builder()`] instead.
373///
374/// # Arguments
375///
376/// * `dir` - Directory where ignore files will be generated
377/// * `configs` - List of ignore configurations
378/// * `dry_run` - If true, don't write files, just report what would happen
379///
380/// # Errors
381///
382/// Returns an error if:
383/// - The directory is not within a Git repository
384/// - A tool name contains invalid characters
385/// - File I/O fails
386pub fn generate_ignore_files(
387    dir: &Path,
388    configs: Vec<IgnoreConfig>,
389    dry_run: bool,
390) -> Result<SyncResult> {
391    let files: Vec<IgnoreFile> = configs
392        .into_iter()
393        .map(|c| {
394            let mut file = IgnoreFile::new(c.tool).patterns(c.patterns);
395            if let Some(filename) = c.filename {
396                file = file.filename(filename);
397            }
398            file
399        })
400        .collect();
401
402    IgnoreFiles::builder()
403        .directory(dir)
404        .require_git_repo(true) // Legacy API always required git
405        .dry_run(dry_run)
406        .files(files)
407        .generate()
408}
409
410// ============================================================================
411// Result types
412// ============================================================================
413
414/// Result of generating ignore files.
415#[derive(Debug)]
416pub struct SyncResult {
417    /// Results for each file that was processed.
418    pub files: Vec<FileResult>,
419}
420
421/// Result for a single ignore file.
422#[derive(Debug)]
423pub struct FileResult {
424    /// The filename that was generated (e.g., ".gitignore").
425    pub filename: String,
426    /// The status of the file operation.
427    pub status: FileStatus,
428    /// Number of patterns in the file.
429    pub pattern_count: usize,
430}
431
432/// Status of a file operation.
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum FileStatus {
435    /// File was newly created.
436    Created,
437    /// File existed and was updated with new content.
438    Updated,
439    /// File existed and content was unchanged.
440    Unchanged,
441    /// Would be created (dry-run mode).
442    WouldCreate,
443    /// Would be updated (dry-run mode).
444    WouldUpdate,
445}
446
447impl std::fmt::Display for FileStatus {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self {
450            Self::Created => write!(f, "Created"),
451            Self::Updated => write!(f, "Updated"),
452            Self::Unchanged => write!(f, "Unchanged"),
453            Self::WouldCreate => write!(f, "Would create"),
454            Self::WouldUpdate => write!(f, "Would update"),
455        }
456    }
457}
458
459// ============================================================================
460// Error types
461// ============================================================================
462
463/// Errors that can occur during ignore file generation.
464#[derive(Debug, thiserror::Error)]
465pub enum Error {
466    /// Invalid tool name (contains path separators or is empty).
467    #[error("Invalid tool name '{name}': {reason}")]
468    InvalidToolName {
469        /// The invalid tool name.
470        name: String,
471        /// Reason why it's invalid.
472        reason: String,
473    },
474
475    /// Not inside a Git repository.
476    #[error("Must be run within a Git repository")]
477    NotInGitRepo,
478
479    /// Cannot operate in a bare repository.
480    #[error("Cannot operate in a bare Git repository")]
481    BareRepository,
482
483    /// Target directory is outside the Git repository.
484    #[error("Target directory must be within the Git repository")]
485    OutsideGitRepo,
486
487    /// IO error during file operations.
488    #[error("IO error: {0}")]
489    Io(#[from] std::io::Error),
490}
491
492/// Result type for ignore operations.
493pub type Result<T> = std::result::Result<T, Error>;
494
495// ============================================================================
496// Internal helpers
497// ============================================================================
498
499/// Verify that the directory is within a Git repository.
500fn verify_git_repository(dir: &Path) -> Result<()> {
501    let repo = gix::discover(dir).map_err(|e| {
502        tracing::debug!("Git discovery failed: {}", e);
503        Error::NotInGitRepo
504    })?;
505
506    let git_root = repo.workdir().ok_or(Error::BareRepository)?;
507
508    // Canonicalize paths for comparison
509    let canonical_dir = std::fs::canonicalize(dir)?;
510    let canonical_git = std::fs::canonicalize(git_root)?;
511
512    if !canonical_dir.starts_with(&canonical_git) {
513        return Err(Error::OutsideGitRepo);
514    }
515
516    tracing::debug!(
517        git_root = %canonical_git.display(),
518        target_dir = %canonical_dir.display(),
519        "Verified directory is within Git repository"
520    );
521
522    Ok(())
523}
524
525/// Validate that a tool name doesn't contain path separators.
526fn validate_tool_name(tool: &str) -> Result<()> {
527    if tool.is_empty() {
528        return Err(Error::InvalidToolName {
529            name: tool.to_string(),
530            reason: "tool name cannot be empty".to_string(),
531        });
532    }
533
534    if tool.contains('/') || tool.contains('\\') {
535        return Err(Error::InvalidToolName {
536            name: tool.to_string(),
537            reason: "tool name cannot contain path separators".to_string(),
538        });
539    }
540
541    if tool.contains("..") {
542        return Err(Error::InvalidToolName {
543            name: tool.to_string(),
544            reason: "tool name cannot contain parent directory references".to_string(),
545        });
546    }
547
548    Ok(())
549}
550
551/// Validate that a filename doesn't contain path separators.
552fn validate_filename(filename: &str) -> Result<()> {
553    if filename.contains('/') || filename.contains('\\') {
554        return Err(Error::InvalidToolName {
555            name: filename.to_string(),
556            reason: "filename cannot contain path separators".to_string(),
557        });
558    }
559
560    if filename.contains("..") {
561        return Err(Error::InvalidToolName {
562            name: filename.to_string(),
563            reason: "filename cannot contain parent directory references".to_string(),
564        });
565    }
566
567    Ok(())
568}
569
570/// Write an ignore file and return the status.
571fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
572    let status = if filepath.exists() {
573        let existing = std::fs::read_to_string(filepath)?;
574        if existing == content {
575            return Ok(FileStatus::Unchanged);
576        }
577        FileStatus::Updated
578    } else {
579        FileStatus::Created
580    };
581
582    std::fs::write(filepath, content)?;
583    Ok(status)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_ignore_file_new() {
592        let file = IgnoreFile::new("git");
593        assert_eq!(file.tool(), "git");
594        assert!(file.patterns_list().is_empty());
595        assert_eq!(file.output_filename(), ".gitignore");
596    }
597
598    #[test]
599    fn test_ignore_file_builder() {
600        let file = IgnoreFile::new("docker")
601            .pattern("node_modules/")
602            .pattern(".env")
603            .filename(".mydockerignore")
604            .header("My header");
605
606        assert_eq!(file.tool(), "docker");
607        assert_eq!(file.patterns_list(), &["node_modules/", ".env"]);
608        assert_eq!(file.output_filename(), ".mydockerignore");
609    }
610
611    #[test]
612    fn test_ignore_file_patterns() {
613        let file = IgnoreFile::new("git").patterns(["a", "b", "c"]);
614        assert_eq!(file.patterns_list(), &["a", "b", "c"]);
615    }
616
617    #[test]
618    fn test_ignore_file_generate_no_header() {
619        let file = IgnoreFile::new("git")
620            .pattern("node_modules/")
621            .pattern(".env");
622
623        let content = file.generate();
624        assert_eq!(content, "node_modules/\n.env\n");
625    }
626
627    #[test]
628    fn test_ignore_file_generate_with_header() {
629        let file = IgnoreFile::new("git")
630            .pattern("node_modules/")
631            .header("Generated by my-tool\nDo not edit");
632
633        let content = file.generate();
634        assert!(content.starts_with("# Generated by my-tool\n# Do not edit\n\n"));
635        assert!(content.contains("node_modules/"));
636    }
637
638    #[test]
639    fn test_output_filename_default() {
640        assert_eq!(IgnoreFile::new("git").output_filename(), ".gitignore");
641        assert_eq!(IgnoreFile::new("docker").output_filename(), ".dockerignore");
642        assert_eq!(IgnoreFile::new("npm").output_filename(), ".npmignore");
643    }
644
645    #[test]
646    fn test_output_filename_custom() {
647        let file = IgnoreFile::new("git").filename(".my-gitignore");
648        assert_eq!(file.output_filename(), ".my-gitignore");
649    }
650
651    #[test]
652    fn test_validate_tool_name_valid() {
653        assert!(validate_tool_name("git").is_ok());
654        assert!(validate_tool_name("docker").is_ok());
655        assert!(validate_tool_name("my-custom-tool").is_ok());
656        assert!(validate_tool_name("tool_with_underscore").is_ok());
657    }
658
659    #[test]
660    fn test_validate_tool_name_invalid() {
661        assert!(validate_tool_name("").is_err());
662        assert!(validate_tool_name("../etc").is_err());
663        assert!(validate_tool_name("foo/bar").is_err());
664        assert!(validate_tool_name("foo\\bar").is_err());
665        assert!(validate_tool_name("..").is_err());
666        assert!(validate_tool_name("foo..bar").is_err());
667    }
668
669    #[test]
670    fn test_file_status_display() {
671        assert_eq!(FileStatus::Created.to_string(), "Created");
672        assert_eq!(FileStatus::Updated.to_string(), "Updated");
673        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
674        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
675        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
676    }
677
678    // Legacy API tests
679    #[test]
680    fn test_ignore_config_to_ignore_file() {
681        let config = IgnoreConfig {
682            tool: "git".to_string(),
683            patterns: vec!["node_modules/".to_string()],
684            filename: Some(".custom".to_string()),
685        };
686
687        let file = IgnoreFile::new(config.tool)
688            .patterns(config.patterns)
689            .filename_opt(config.filename);
690
691        assert_eq!(file.tool(), "git");
692        assert_eq!(file.output_filename(), ".custom");
693    }
694}