Skip to main content

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<String> = self
166            .header
167            .iter()
168            .flat_map(|header| {
169                header
170                    .lines()
171                    .map(|line| format!("# {line}"))
172                    .chain(std::iter::once(String::new()))
173            })
174            .collect();
175
176        // Add patterns
177        lines.extend(self.patterns.iter().cloned());
178
179        format!("{}\n", lines.join("\n"))
180    }
181}
182
183/// Entry point for building and generating ignore files.
184pub struct IgnoreFiles;
185
186impl IgnoreFiles {
187    /// Create a new builder for generating ignore files.
188    ///
189    /// # Example
190    ///
191    /// ```rust,no_run
192    /// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
193    ///
194    /// let result = IgnoreFiles::builder()
195    ///     .directory(".")
196    ///     .file(IgnoreFile::new("git").pattern("*.log"))
197    ///     .generate()?;
198    /// # Ok::<(), cuenv_ignore::Error>(())
199    /// ```
200    #[must_use]
201    pub fn builder() -> IgnoreFilesBuilder {
202        IgnoreFilesBuilder::default()
203    }
204}
205
206/// Builder for generating multiple ignore files.
207///
208/// # Example
209///
210/// ```rust,no_run
211/// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
212///
213/// let result = IgnoreFiles::builder()
214///     .directory("/path/to/project")
215///     .require_git_repo(true)
216///     .dry_run(false)
217///     .file(IgnoreFile::new("git")
218///         .pattern("node_modules/")
219///         .pattern(".env"))
220///     .file(IgnoreFile::new("docker")
221///         .pattern("target/"))
222///     .generate()?;
223/// # Ok::<(), cuenv_ignore::Error>(())
224/// ```
225#[derive(Debug, Default)]
226pub struct IgnoreFilesBuilder {
227    directory: Option<PathBuf>,
228    files: Vec<IgnoreFile>,
229    require_git_repo: bool,
230    dry_run: bool,
231}
232
233impl IgnoreFilesBuilder {
234    /// Set the directory where ignore files will be generated.
235    ///
236    /// Defaults to the current directory if not set.
237    #[must_use]
238    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
239        self.directory = Some(dir.as_ref().to_path_buf());
240        self
241    }
242
243    /// Add a single ignore file configuration.
244    #[must_use]
245    pub fn file(mut self, file: IgnoreFile) -> Self {
246        self.files.push(file);
247        self
248    }
249
250    /// Add multiple ignore file configurations.
251    #[must_use]
252    pub fn files(mut self, files: impl IntoIterator<Item = IgnoreFile>) -> Self {
253        self.files.extend(files);
254        self
255    }
256
257    /// Require that the directory is within a Git repository.
258    ///
259    /// Defaults to `false`. When `true`, returns an error if the directory
260    /// is not within a Git repository.
261    #[must_use]
262    pub const fn require_git_repo(mut self, require: bool) -> Self {
263        self.require_git_repo = require;
264        self
265    }
266
267    /// Enable dry-run mode.
268    ///
269    /// When `true`, no files will be written. The result will indicate
270    /// what would happen with `WouldCreate` and `WouldUpdate` statuses.
271    #[must_use]
272    pub const fn dry_run(mut self, dry_run: bool) -> Self {
273        self.dry_run = dry_run;
274        self
275    }
276
277    /// Generate the ignore files.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if:
282    /// - `require_git_repo` is true and the directory is not within a Git repository
283    /// - A tool name contains invalid characters (path separators)
284    /// - File I/O fails
285    pub fn generate(self) -> Result<SyncResult> {
286        let dir = self.directory.unwrap_or_else(|| PathBuf::from("."));
287
288        tracing::info!("Starting ignore file generation");
289
290        if self.require_git_repo {
291            verify_git_repository(&dir)?;
292        }
293
294        let mut sorted_files = self.files;
295        sorted_files.sort_by(|a, b| a.tool.cmp(&b.tool));
296
297        let dry_run = self.dry_run;
298        let results = sorted_files
299            .iter()
300            .filter(|f| !f.patterns.is_empty())
301            .map(|file| process_ignore_file(&dir, file, dry_run))
302            .collect::<Result<Vec<_>>>()?;
303
304        Ok(SyncResult { files: results })
305    }
306}
307
308/// Process a single ignore file and return its result.
309fn process_ignore_file(dir: &Path, file: &IgnoreFile, dry_run: bool) -> Result<FileResult> {
310    validate_tool_name(&file.tool)?;
311
312    let filename = file.output_filename();
313    validate_filename(&filename)?;
314
315    let filepath = dir.join(&filename);
316    let content = file.generate();
317    let pattern_count = file.patterns.len();
318
319    let status = determine_file_status(&filepath, &content, dry_run)?;
320
321    tracing::info!(
322        filename = %filename,
323        status = %status,
324        patterns = pattern_count,
325        "Processed ignore file"
326    );
327
328    Ok(FileResult {
329        filename,
330        status,
331        pattern_count,
332    })
333}
334
335/// Determine the status of an ignore file based on `dry_run` mode and existing content.
336fn determine_file_status(filepath: &Path, content: &str, dry_run: bool) -> Result<FileStatus> {
337    if dry_run {
338        Ok(determine_dry_run_status(filepath, content)?)
339    } else {
340        write_ignore_file(filepath, content)
341    }
342}
343
344/// Determine what would happen to a file in dry-run mode.
345fn determine_dry_run_status(filepath: &Path, content: &str) -> Result<FileStatus> {
346    if !filepath.exists() {
347        return Ok(FileStatus::WouldCreate);
348    }
349    let existing = std::fs::read_to_string(filepath)?;
350    Ok(if existing == content {
351        FileStatus::Unchanged
352    } else {
353        FileStatus::WouldUpdate
354    })
355}
356
357// ============================================================================
358// Result types
359// ============================================================================
360
361/// Result of generating ignore files.
362#[derive(Debug)]
363pub struct SyncResult {
364    /// Results for each file that was processed.
365    pub files: Vec<FileResult>,
366}
367
368/// Result for a single ignore file.
369#[derive(Debug)]
370pub struct FileResult {
371    /// The filename that was generated (e.g., ".gitignore").
372    pub filename: String,
373    /// The status of the file operation.
374    pub status: FileStatus,
375    /// Number of patterns in the file.
376    pub pattern_count: usize,
377}
378
379/// Status of a file operation.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum FileStatus {
382    /// File was newly created.
383    Created,
384    /// File existed and was updated with new content.
385    Updated,
386    /// File existed and content was unchanged.
387    Unchanged,
388    /// Would be created (dry-run mode).
389    WouldCreate,
390    /// Would be updated (dry-run mode).
391    WouldUpdate,
392}
393
394impl std::fmt::Display for FileStatus {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Self::Created => write!(f, "Created"),
398            Self::Updated => write!(f, "Updated"),
399            Self::Unchanged => write!(f, "Unchanged"),
400            Self::WouldCreate => write!(f, "Would create"),
401            Self::WouldUpdate => write!(f, "Would update"),
402        }
403    }
404}
405
406// ============================================================================
407// Error types
408// ============================================================================
409
410/// Errors that can occur during ignore file generation.
411#[derive(Debug, thiserror::Error)]
412pub enum Error {
413    /// Invalid tool name (contains path separators or is empty).
414    #[error("Invalid tool name '{name}': {reason}")]
415    InvalidToolName {
416        /// The invalid tool name.
417        name: String,
418        /// Reason why it's invalid.
419        reason: String,
420    },
421
422    /// Not inside a Git repository.
423    #[error("Must be run within a Git repository")]
424    NotInGitRepo,
425
426    /// Cannot operate in a bare repository.
427    #[error("Cannot operate in a bare Git repository")]
428    BareRepository,
429
430    /// Target directory is outside the Git repository.
431    #[error("Target directory must be within the Git repository")]
432    OutsideGitRepo,
433
434    /// IO error during file operations.
435    #[error("IO error: {0}")]
436    Io(#[from] std::io::Error),
437}
438
439/// Result type for ignore operations.
440pub type Result<T> = std::result::Result<T, Error>;
441
442// ============================================================================
443// Internal helpers
444// ============================================================================
445
446/// Verify that the directory is within a Git repository.
447fn verify_git_repository(dir: &Path) -> Result<()> {
448    let repo = gix::discover(dir).map_err(|e| {
449        tracing::debug!("Git discovery failed: {}", e);
450        Error::NotInGitRepo
451    })?;
452
453    let git_root = repo.workdir().ok_or(Error::BareRepository)?;
454
455    // Canonicalize paths for comparison
456    let canonical_dir = std::fs::canonicalize(dir)?;
457    let canonical_git = std::fs::canonicalize(git_root)?;
458
459    if !canonical_dir.starts_with(&canonical_git) {
460        return Err(Error::OutsideGitRepo);
461    }
462
463    tracing::debug!(
464        git_root = %canonical_git.display(),
465        target_dir = %canonical_dir.display(),
466        "Verified directory is within Git repository"
467    );
468
469    Ok(())
470}
471
472/// Validate that a tool name doesn't contain path separators.
473fn validate_tool_name(tool: &str) -> Result<()> {
474    if tool.is_empty() {
475        return Err(Error::InvalidToolName {
476            name: tool.to_string(),
477            reason: "tool name cannot be empty".to_string(),
478        });
479    }
480
481    if tool.contains('/') || tool.contains('\\') {
482        return Err(Error::InvalidToolName {
483            name: tool.to_string(),
484            reason: "tool name cannot contain path separators".to_string(),
485        });
486    }
487
488    if tool.contains("..") {
489        return Err(Error::InvalidToolName {
490            name: tool.to_string(),
491            reason: "tool name cannot contain parent directory references".to_string(),
492        });
493    }
494
495    Ok(())
496}
497
498/// Validate that a filename doesn't contain path separators.
499fn validate_filename(filename: &str) -> Result<()> {
500    if filename.contains('/') || filename.contains('\\') {
501        return Err(Error::InvalidToolName {
502            name: filename.to_string(),
503            reason: "filename cannot contain path separators".to_string(),
504        });
505    }
506
507    if filename.contains("..") {
508        return Err(Error::InvalidToolName {
509            name: filename.to_string(),
510            reason: "filename cannot contain parent directory references".to_string(),
511        });
512    }
513
514    Ok(())
515}
516
517/// Write an ignore file and return the status.
518fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
519    let status = if filepath.exists() {
520        let existing = std::fs::read_to_string(filepath)?;
521        if existing == content {
522            return Ok(FileStatus::Unchanged);
523        }
524        FileStatus::Updated
525    } else {
526        FileStatus::Created
527    };
528
529    std::fs::write(filepath, content)?;
530    Ok(status)
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_ignore_file_new() {
539        let file = IgnoreFile::new("git");
540        assert_eq!(file.tool(), "git");
541        assert!(file.patterns_list().is_empty());
542        assert_eq!(file.output_filename(), ".gitignore");
543    }
544
545    #[test]
546    fn test_ignore_file_builder() {
547        let file = IgnoreFile::new("docker")
548            .pattern("node_modules/")
549            .pattern(".env")
550            .filename(".mydockerignore")
551            .header("My header");
552
553        assert_eq!(file.tool(), "docker");
554        assert_eq!(file.patterns_list(), &["node_modules/", ".env"]);
555        assert_eq!(file.output_filename(), ".mydockerignore");
556    }
557
558    #[test]
559    fn test_ignore_file_patterns() {
560        let file = IgnoreFile::new("git").patterns(["a", "b", "c"]);
561        assert_eq!(file.patterns_list(), &["a", "b", "c"]);
562    }
563
564    #[test]
565    fn test_ignore_file_generate_no_header() {
566        let file = IgnoreFile::new("git")
567            .pattern("node_modules/")
568            .pattern(".env");
569
570        let content = file.generate();
571        assert_eq!(content, "node_modules/\n.env\n");
572    }
573
574    #[test]
575    fn test_ignore_file_generate_with_header() {
576        let file = IgnoreFile::new("git")
577            .pattern("node_modules/")
578            .header("Generated by my-tool\nDo not edit");
579
580        let content = file.generate();
581        assert!(content.starts_with("# Generated by my-tool\n# Do not edit\n\n"));
582        assert!(content.contains("node_modules/"));
583    }
584
585    #[test]
586    fn test_output_filename_default() {
587        assert_eq!(IgnoreFile::new("git").output_filename(), ".gitignore");
588        assert_eq!(IgnoreFile::new("docker").output_filename(), ".dockerignore");
589        assert_eq!(IgnoreFile::new("npm").output_filename(), ".npmignore");
590    }
591
592    #[test]
593    fn test_output_filename_custom() {
594        let file = IgnoreFile::new("git").filename(".my-gitignore");
595        assert_eq!(file.output_filename(), ".my-gitignore");
596    }
597
598    #[test]
599    fn test_validate_tool_name_valid() {
600        assert!(validate_tool_name("git").is_ok());
601        assert!(validate_tool_name("docker").is_ok());
602        assert!(validate_tool_name("my-custom-tool").is_ok());
603        assert!(validate_tool_name("tool_with_underscore").is_ok());
604    }
605
606    #[test]
607    fn test_validate_tool_name_invalid() {
608        assert!(validate_tool_name("").is_err());
609        assert!(validate_tool_name("../etc").is_err());
610        assert!(validate_tool_name("foo/bar").is_err());
611        assert!(validate_tool_name("foo\\bar").is_err());
612        assert!(validate_tool_name("..").is_err());
613        assert!(validate_tool_name("foo..bar").is_err());
614    }
615
616    #[test]
617    fn test_file_status_display() {
618        assert_eq!(FileStatus::Created.to_string(), "Created");
619        assert_eq!(FileStatus::Updated.to_string(), "Updated");
620        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
621        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
622        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
623    }
624
625    #[test]
626    fn test_ignore_file_clone() {
627        let file = IgnoreFile::new("git")
628            .pattern("node_modules/")
629            .header("Test");
630        let cloned = file.clone();
631        assert_eq!(file, cloned);
632    }
633
634    #[test]
635    fn test_ignore_file_debug() {
636        let file = IgnoreFile::new("git");
637        let debug_str = format!("{file:?}");
638        assert!(debug_str.contains("IgnoreFile"));
639        assert!(debug_str.contains("git"));
640    }
641
642    #[test]
643    fn test_ignore_file_equality() {
644        let file1 = IgnoreFile::new("git").pattern("*.log");
645        let file2 = IgnoreFile::new("git").pattern("*.log");
646        let file3 = IgnoreFile::new("docker").pattern("*.log");
647
648        assert_eq!(file1, file2);
649        assert_ne!(file1, file3);
650    }
651
652    #[test]
653    fn test_ignore_file_filename_opt_some() {
654        let file = IgnoreFile::new("git").filename_opt(Some(".custom-ignore"));
655        assert_eq!(file.output_filename(), ".custom-ignore");
656    }
657
658    #[test]
659    fn test_ignore_file_filename_opt_none() {
660        let file: IgnoreFile = IgnoreFile::new("git").filename_opt(None::<String>);
661        assert_eq!(file.output_filename(), ".gitignore");
662    }
663
664    #[test]
665    fn test_ignore_files_builder_default() {
666        let builder = IgnoreFiles::builder();
667        let debug_str = format!("{builder:?}");
668        assert!(debug_str.contains("IgnoreFilesBuilder"));
669    }
670
671    #[test]
672    fn test_validate_filename_valid() {
673        assert!(validate_filename(".gitignore").is_ok());
674        assert!(validate_filename(".my-custom-ignore").is_ok());
675    }
676
677    #[test]
678    fn test_validate_filename_invalid() {
679        assert!(validate_filename("path/to/file").is_err());
680        assert!(validate_filename("..\\file").is_err());
681        assert!(validate_filename("file..test").is_err());
682    }
683
684    #[test]
685    fn test_sync_result_debug() {
686        let result = SyncResult {
687            files: vec![FileResult {
688                filename: ".gitignore".to_string(),
689                status: FileStatus::Created,
690                pattern_count: 5,
691            }],
692        };
693        let debug_str = format!("{result:?}");
694        assert!(debug_str.contains("SyncResult"));
695        assert!(debug_str.contains("gitignore"));
696    }
697
698    #[test]
699    fn test_file_result_debug() {
700        let result = FileResult {
701            filename: ".gitignore".to_string(),
702            status: FileStatus::Updated,
703            pattern_count: 3,
704        };
705        let debug_str = format!("{result:?}");
706        assert!(debug_str.contains("FileResult"));
707        assert!(debug_str.contains("Updated"));
708    }
709
710    #[test]
711    fn test_file_status_eq() {
712        assert_eq!(FileStatus::Created, FileStatus::Created);
713        assert_eq!(FileStatus::WouldCreate, FileStatus::WouldCreate);
714        assert_ne!(FileStatus::Created, FileStatus::Updated);
715    }
716
717    #[test]
718    fn test_file_status_clone() {
719        let status = FileStatus::WouldUpdate;
720        let cloned = status;
721        assert_eq!(status, cloned);
722    }
723
724    #[test]
725    fn test_error_display() {
726        let err = Error::InvalidToolName {
727            name: "foo/bar".to_string(),
728            reason: "cannot contain path separators".to_string(),
729        };
730        let display = format!("{err}");
731        assert!(display.contains("foo/bar"));
732        assert!(display.contains("path separators"));
733    }
734
735    #[test]
736    fn test_error_not_in_git_repo_display() {
737        let err = Error::NotInGitRepo;
738        let display = format!("{err}");
739        assert!(display.contains("Git repository"));
740    }
741
742    #[test]
743    fn test_error_bare_repository_display() {
744        let err = Error::BareRepository;
745        let display = format!("{err}");
746        assert!(display.contains("bare Git repository"));
747    }
748
749    #[test]
750    fn test_error_outside_git_repo_display() {
751        let err = Error::OutsideGitRepo;
752        let display = format!("{err}");
753        assert!(display.contains("within the Git repository"));
754    }
755
756    #[test]
757    fn test_ignore_file_generate_empty() {
758        let file = IgnoreFile::new("git");
759        let content = file.generate();
760        assert_eq!(content, "\n");
761    }
762
763    #[test]
764    fn test_ignore_files_builder_files() {
765        let files = vec![
766            IgnoreFile::new("git").pattern("*.log"),
767            IgnoreFile::new("docker").pattern("target/"),
768        ];
769        let _builder = IgnoreFiles::builder().files(files);
770    }
771}