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 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 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        // Verify we're in a git repository if required
291        if self.require_git_repo {
292            verify_git_repository(&dir)?;
293        }
294
295        let mut results = Vec::new();
296
297        // Sort files by tool name for deterministic output
298        let mut sorted_files = self.files;
299        sorted_files.sort_by(|a, b| a.tool.cmp(&b.tool));
300
301        for file in sorted_files {
302            // Skip empty pattern lists
303            if file.patterns.is_empty() {
304                tracing::debug!("Skipping tool '{}' - no patterns", file.tool);
305                continue;
306            }
307
308            // Validate tool name
309            validate_tool_name(&file.tool)?;
310
311            // Get and validate filename
312            let filename = file.output_filename();
313            validate_filename(&filename)?;
314
315            let filepath = dir.join(&filename);
316            let content = file.generate();
317
318            let (status, pattern_count) = if self.dry_run {
319                let status = if filepath.exists() {
320                    let existing = std::fs::read_to_string(&filepath)?;
321                    if existing == content {
322                        FileStatus::Unchanged
323                    } else {
324                        FileStatus::WouldUpdate
325                    }
326                } else {
327                    FileStatus::WouldCreate
328                };
329                (status, file.patterns.len())
330            } else {
331                let status = write_ignore_file(&filepath, &content)?;
332                (status, file.patterns.len())
333            };
334
335            tracing::info!(
336                filename = %filename,
337                status = %status,
338                patterns = pattern_count,
339                "Processed ignore file"
340            );
341
342            results.push(FileResult {
343                filename,
344                status,
345                pattern_count,
346            });
347        }
348
349        Ok(SyncResult { files: results })
350    }
351}
352
353// ============================================================================
354// Legacy API for backwards compatibility
355// ============================================================================
356
357/// Configuration for generating a single ignore file.
358///
359/// This is the legacy API. Consider using [`IgnoreFile`] with the builder pattern instead.
360#[derive(Debug, Clone)]
361#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362pub struct IgnoreConfig {
363    /// Tool name (e.g., "git", "docker", "npm").
364    pub tool: String,
365    /// List of patterns to include in the ignore file.
366    pub patterns: Vec<String>,
367    /// Optional filename override.
368    pub filename: Option<String>,
369}
370
371/// Generate ignore files from the given configurations.
372///
373/// This is the legacy API. Consider using [`IgnoreFiles::builder()`] instead.
374///
375/// # Arguments
376///
377/// * `dir` - Directory where ignore files will be generated
378/// * `configs` - List of ignore configurations
379/// * `dry_run` - If true, don't write files, just report what would happen
380///
381/// # Errors
382///
383/// Returns an error if:
384/// - The directory is not within a Git repository
385/// - A tool name contains invalid characters
386/// - File I/O fails
387pub fn generate_ignore_files(
388    dir: &Path,
389    configs: Vec<IgnoreConfig>,
390    dry_run: bool,
391) -> Result<SyncResult> {
392    let files: Vec<IgnoreFile> = configs
393        .into_iter()
394        .map(|c| {
395            let mut file = IgnoreFile::new(c.tool).patterns(c.patterns);
396            if let Some(filename) = c.filename {
397                file = file.filename(filename);
398            }
399            file
400        })
401        .collect();
402
403    IgnoreFiles::builder()
404        .directory(dir)
405        .require_git_repo(true) // Legacy API always required git
406        .dry_run(dry_run)
407        .files(files)
408        .generate()
409}
410
411// ============================================================================
412// Result types
413// ============================================================================
414
415/// Result of generating ignore files.
416#[derive(Debug)]
417pub struct SyncResult {
418    /// Results for each file that was processed.
419    pub files: Vec<FileResult>,
420}
421
422/// Result for a single ignore file.
423#[derive(Debug)]
424pub struct FileResult {
425    /// The filename that was generated (e.g., ".gitignore").
426    pub filename: String,
427    /// The status of the file operation.
428    pub status: FileStatus,
429    /// Number of patterns in the file.
430    pub pattern_count: usize,
431}
432
433/// Status of a file operation.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum FileStatus {
436    /// File was newly created.
437    Created,
438    /// File existed and was updated with new content.
439    Updated,
440    /// File existed and content was unchanged.
441    Unchanged,
442    /// Would be created (dry-run mode).
443    WouldCreate,
444    /// Would be updated (dry-run mode).
445    WouldUpdate,
446}
447
448impl std::fmt::Display for FileStatus {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        match self {
451            Self::Created => write!(f, "Created"),
452            Self::Updated => write!(f, "Updated"),
453            Self::Unchanged => write!(f, "Unchanged"),
454            Self::WouldCreate => write!(f, "Would create"),
455            Self::WouldUpdate => write!(f, "Would update"),
456        }
457    }
458}
459
460// ============================================================================
461// Error types
462// ============================================================================
463
464/// Errors that can occur during ignore file generation.
465#[derive(Debug, thiserror::Error)]
466pub enum Error {
467    /// Invalid tool name (contains path separators or is empty).
468    #[error("Invalid tool name '{name}': {reason}")]
469    InvalidToolName {
470        /// The invalid tool name.
471        name: String,
472        /// Reason why it's invalid.
473        reason: String,
474    },
475
476    /// Not inside a Git repository.
477    #[error("Must be run within a Git repository")]
478    NotInGitRepo,
479
480    /// Cannot operate in a bare repository.
481    #[error("Cannot operate in a bare Git repository")]
482    BareRepository,
483
484    /// Target directory is outside the Git repository.
485    #[error("Target directory must be within the Git repository")]
486    OutsideGitRepo,
487
488    /// IO error during file operations.
489    #[error("IO error: {0}")]
490    Io(#[from] std::io::Error),
491}
492
493/// Result type for ignore operations.
494pub type Result<T> = std::result::Result<T, Error>;
495
496// ============================================================================
497// Internal helpers
498// ============================================================================
499
500/// Verify that the directory is within a Git repository.
501fn verify_git_repository(dir: &Path) -> Result<()> {
502    let repo = gix::discover(dir).map_err(|e| {
503        tracing::debug!("Git discovery failed: {}", e);
504        Error::NotInGitRepo
505    })?;
506
507    let git_root = repo.workdir().ok_or(Error::BareRepository)?;
508
509    // Canonicalize paths for comparison
510    let canonical_dir = std::fs::canonicalize(dir)?;
511    let canonical_git = std::fs::canonicalize(git_root)?;
512
513    if !canonical_dir.starts_with(&canonical_git) {
514        return Err(Error::OutsideGitRepo);
515    }
516
517    tracing::debug!(
518        git_root = %canonical_git.display(),
519        target_dir = %canonical_dir.display(),
520        "Verified directory is within Git repository"
521    );
522
523    Ok(())
524}
525
526/// Validate that a tool name doesn't contain path separators.
527fn validate_tool_name(tool: &str) -> Result<()> {
528    if tool.is_empty() {
529        return Err(Error::InvalidToolName {
530            name: tool.to_string(),
531            reason: "tool name cannot be empty".to_string(),
532        });
533    }
534
535    if tool.contains('/') || tool.contains('\\') {
536        return Err(Error::InvalidToolName {
537            name: tool.to_string(),
538            reason: "tool name cannot contain path separators".to_string(),
539        });
540    }
541
542    if tool.contains("..") {
543        return Err(Error::InvalidToolName {
544            name: tool.to_string(),
545            reason: "tool name cannot contain parent directory references".to_string(),
546        });
547    }
548
549    Ok(())
550}
551
552/// Validate that a filename doesn't contain path separators.
553fn validate_filename(filename: &str) -> Result<()> {
554    if filename.contains('/') || filename.contains('\\') {
555        return Err(Error::InvalidToolName {
556            name: filename.to_string(),
557            reason: "filename cannot contain path separators".to_string(),
558        });
559    }
560
561    if filename.contains("..") {
562        return Err(Error::InvalidToolName {
563            name: filename.to_string(),
564            reason: "filename cannot contain parent directory references".to_string(),
565        });
566    }
567
568    Ok(())
569}
570
571/// Write an ignore file and return the status.
572fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
573    let status = if filepath.exists() {
574        let existing = std::fs::read_to_string(filepath)?;
575        if existing == content {
576            return Ok(FileStatus::Unchanged);
577        }
578        FileStatus::Updated
579    } else {
580        FileStatus::Created
581    };
582
583    std::fs::write(filepath, content)?;
584    Ok(status)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_ignore_file_new() {
593        let file = IgnoreFile::new("git");
594        assert_eq!(file.tool(), "git");
595        assert!(file.patterns_list().is_empty());
596        assert_eq!(file.output_filename(), ".gitignore");
597    }
598
599    #[test]
600    fn test_ignore_file_builder() {
601        let file = IgnoreFile::new("docker")
602            .pattern("node_modules/")
603            .pattern(".env")
604            .filename(".mydockerignore")
605            .header("My header");
606
607        assert_eq!(file.tool(), "docker");
608        assert_eq!(file.patterns_list(), &["node_modules/", ".env"]);
609        assert_eq!(file.output_filename(), ".mydockerignore");
610    }
611
612    #[test]
613    fn test_ignore_file_patterns() {
614        let file = IgnoreFile::new("git").patterns(["a", "b", "c"]);
615        assert_eq!(file.patterns_list(), &["a", "b", "c"]);
616    }
617
618    #[test]
619    fn test_ignore_file_generate_no_header() {
620        let file = IgnoreFile::new("git")
621            .pattern("node_modules/")
622            .pattern(".env");
623
624        let content = file.generate();
625        assert_eq!(content, "node_modules/\n.env\n");
626    }
627
628    #[test]
629    fn test_ignore_file_generate_with_header() {
630        let file = IgnoreFile::new("git")
631            .pattern("node_modules/")
632            .header("Generated by my-tool\nDo not edit");
633
634        let content = file.generate();
635        assert!(content.starts_with("# Generated by my-tool\n# Do not edit\n\n"));
636        assert!(content.contains("node_modules/"));
637    }
638
639    #[test]
640    fn test_output_filename_default() {
641        assert_eq!(IgnoreFile::new("git").output_filename(), ".gitignore");
642        assert_eq!(IgnoreFile::new("docker").output_filename(), ".dockerignore");
643        assert_eq!(IgnoreFile::new("npm").output_filename(), ".npmignore");
644    }
645
646    #[test]
647    fn test_output_filename_custom() {
648        let file = IgnoreFile::new("git").filename(".my-gitignore");
649        assert_eq!(file.output_filename(), ".my-gitignore");
650    }
651
652    #[test]
653    fn test_validate_tool_name_valid() {
654        assert!(validate_tool_name("git").is_ok());
655        assert!(validate_tool_name("docker").is_ok());
656        assert!(validate_tool_name("my-custom-tool").is_ok());
657        assert!(validate_tool_name("tool_with_underscore").is_ok());
658    }
659
660    #[test]
661    fn test_validate_tool_name_invalid() {
662        assert!(validate_tool_name("").is_err());
663        assert!(validate_tool_name("../etc").is_err());
664        assert!(validate_tool_name("foo/bar").is_err());
665        assert!(validate_tool_name("foo\\bar").is_err());
666        assert!(validate_tool_name("..").is_err());
667        assert!(validate_tool_name("foo..bar").is_err());
668    }
669
670    #[test]
671    fn test_file_status_display() {
672        assert_eq!(FileStatus::Created.to_string(), "Created");
673        assert_eq!(FileStatus::Updated.to_string(), "Updated");
674        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
675        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
676        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
677    }
678
679    // Legacy API tests
680    #[test]
681    fn test_ignore_config_to_ignore_file() {
682        let config = IgnoreConfig {
683            tool: "git".to_string(),
684            patterns: vec!["node_modules/".to_string()],
685            filename: Some(".custom".to_string()),
686        };
687
688        let file = IgnoreFile::new(config.tool)
689            .patterns(config.patterns)
690            .filename_opt(config.filename);
691
692        assert_eq!(file.tool(), "git");
693        assert_eq!(file.output_filename(), ".custom");
694    }
695}