cuenv_ignore/
lib.rs

1//! Ignore file generation for cuenv.
2//!
3//! This crate provides functionality to generate tool-specific ignore files
4//! (.gitignore, .dockerignore, etc.) from a declarative configuration.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use cuenv_ignore::{generate_ignore_files, IgnoreConfig};
10//! use std::path::Path;
11//!
12//! let configs = vec![
13//!     IgnoreConfig {
14//!         tool: "git".to_string(),
15//!         patterns: vec!["node_modules/".to_string(), ".env".to_string()],
16//!         filename: None,
17//!     },
18//! ];
19//!
20//! let result = generate_ignore_files(Path::new("."), configs, false);
21//! ```
22
23use std::path::Path;
24
25/// Configuration for generating a single ignore file.
26#[derive(Debug, Clone)]
27pub struct IgnoreConfig {
28    /// Tool name (e.g., "git", "docker", "npm").
29    /// Used to generate the filename as `.{tool}ignore` unless overridden.
30    pub tool: String,
31    /// List of patterns to include in the ignore file.
32    pub patterns: Vec<String>,
33    /// Optional filename override. If None, defaults to `.{tool}ignore`.
34    pub filename: Option<String>,
35}
36
37/// Result of generating ignore files.
38#[derive(Debug)]
39pub struct SyncResult {
40    /// Results for each file that was processed.
41    pub files: Vec<FileResult>,
42}
43
44/// Result for a single ignore file.
45#[derive(Debug)]
46pub struct FileResult {
47    /// The filename that was generated (e.g., ".gitignore").
48    pub filename: String,
49    /// The status of the file operation.
50    pub status: FileStatus,
51    /// Number of patterns in the file.
52    pub pattern_count: usize,
53}
54
55/// Status of a file operation.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum FileStatus {
58    /// File was newly created.
59    Created,
60    /// File existed and was updated with new content.
61    Updated,
62    /// File existed and content was unchanged.
63    Unchanged,
64    /// Would be created (dry-run mode).
65    WouldCreate,
66    /// Would be updated (dry-run mode).
67    WouldUpdate,
68}
69
70impl std::fmt::Display for FileStatus {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Created => write!(f, "Created"),
74            Self::Updated => write!(f, "Updated"),
75            Self::Unchanged => write!(f, "Unchanged"),
76            Self::WouldCreate => write!(f, "Would create"),
77            Self::WouldUpdate => write!(f, "Would update"),
78        }
79    }
80}
81
82/// Errors that can occur during ignore file generation.
83#[derive(Debug, thiserror::Error)]
84pub enum Error {
85    /// Invalid tool name (contains path separators or is empty).
86    #[error("Invalid tool name '{name}': {reason}")]
87    InvalidToolName {
88        /// The invalid tool name.
89        name: String,
90        /// Reason why it's invalid.
91        reason: String,
92    },
93
94    /// Not inside a Git repository.
95    #[error("cuenv sync ignore must be run within a Git repository")]
96    NotInGitRepo,
97
98    /// Cannot operate in a bare repository.
99    #[error("Cannot sync in a bare Git repository")]
100    BareRepository,
101
102    /// Target directory is outside the Git repository.
103    #[error("Target directory must be within the Git repository")]
104    OutsideGitRepo,
105
106    /// IO error during file operations.
107    #[error("IO error: {0}")]
108    Io(#[from] std::io::Error),
109}
110
111/// Result type for ignore operations.
112pub type Result<T> = std::result::Result<T, Error>;
113
114/// Generate ignore files from the given configurations.
115///
116/// # Arguments
117///
118/// * `dir` - Directory where ignore files will be generated
119/// * `configs` - List of ignore configurations
120/// * `dry_run` - If true, don't write files, just report what would happen
121///
122/// # Errors
123///
124/// Returns an error if:
125/// - The directory is not within a Git repository
126/// - A tool name contains invalid characters (path separators)
127/// - File I/O fails
128///
129/// # Example
130///
131/// ```no_run
132/// use cuenv_ignore::{generate_ignore_files, IgnoreConfig};
133/// use std::path::Path;
134///
135/// let configs = vec![
136///     IgnoreConfig {
137///         tool: "git".to_string(),
138///         patterns: vec!["node_modules/".to_string()],
139///         filename: None,
140///     },
141/// ];
142///
143/// let result = generate_ignore_files(Path::new("."), configs, false)?;
144/// for file in &result.files {
145///     println!("{}: {} ({} patterns)", file.status, file.filename, file.pattern_count);
146/// }
147/// # Ok::<(), cuenv_ignore::Error>(())
148/// ```
149pub fn generate_ignore_files(
150    dir: &Path,
151    configs: Vec<IgnoreConfig>,
152    dry_run: bool,
153) -> Result<SyncResult> {
154    tracing::info!("Starting ignore file generation");
155
156    // Verify we're in a git repository
157    verify_git_repository(dir)?;
158
159    let mut results = Vec::new();
160
161    // Sort configs by tool name for deterministic output
162    let mut sorted_configs = configs;
163    sorted_configs.sort_by(|a, b| a.tool.cmp(&b.tool));
164
165    for config in sorted_configs {
166        // Skip empty pattern lists
167        if config.patterns.is_empty() {
168            tracing::debug!("Skipping tool '{}' - no patterns", config.tool);
169            continue;
170        }
171
172        // Validate tool name
173        validate_tool_name(&config.tool)?;
174
175        // Get filename (use override or default)
176        let filename = get_ignore_filename(&config.tool, config.filename.as_deref());
177
178        // Validate filename doesn't contain path separators
179        validate_filename(&filename)?;
180
181        let filepath = dir.join(&filename);
182        let content = generate_ignore_content(&config.patterns);
183
184        let (status, pattern_count) = if dry_run {
185            let status = if filepath.exists() {
186                let existing = std::fs::read_to_string(&filepath)?;
187                if existing == content {
188                    FileStatus::Unchanged
189                } else {
190                    FileStatus::WouldUpdate
191                }
192            } else {
193                FileStatus::WouldCreate
194            };
195            (status, config.patterns.len())
196        } else {
197            let status = write_ignore_file(&filepath, &content)?;
198            (status, config.patterns.len())
199        };
200
201        tracing::info!(
202            filename = %filename,
203            status = %status,
204            patterns = pattern_count,
205            "Processed ignore file"
206        );
207
208        results.push(FileResult {
209            filename,
210            status,
211            pattern_count,
212        });
213    }
214
215    Ok(SyncResult { files: results })
216}
217
218/// Verify that the directory is within a Git repository.
219fn verify_git_repository(dir: &Path) -> Result<()> {
220    let repo = gix::discover(dir).map_err(|e| {
221        tracing::debug!("Git discovery failed: {}", e);
222        Error::NotInGitRepo
223    })?;
224
225    let git_root = repo.workdir().ok_or(Error::BareRepository)?;
226
227    // Canonicalize paths for comparison
228    let canonical_dir = std::fs::canonicalize(dir)?;
229    let canonical_git = std::fs::canonicalize(git_root)?;
230
231    if !canonical_dir.starts_with(&canonical_git) {
232        return Err(Error::OutsideGitRepo);
233    }
234
235    tracing::debug!(
236        git_root = %canonical_git.display(),
237        target_dir = %canonical_dir.display(),
238        "Verified directory is within Git repository"
239    );
240
241    Ok(())
242}
243
244/// Validate that a tool name doesn't contain path separators.
245fn validate_tool_name(tool: &str) -> Result<()> {
246    if tool.is_empty() {
247        return Err(Error::InvalidToolName {
248            name: tool.to_string(),
249            reason: "tool name cannot be empty".to_string(),
250        });
251    }
252
253    if tool.contains('/') || tool.contains('\\') {
254        return Err(Error::InvalidToolName {
255            name: tool.to_string(),
256            reason: "tool name cannot contain path separators".to_string(),
257        });
258    }
259
260    if tool.contains("..") {
261        return Err(Error::InvalidToolName {
262            name: tool.to_string(),
263            reason: "tool name cannot contain parent directory references".to_string(),
264        });
265    }
266
267    Ok(())
268}
269
270/// Validate that a filename doesn't contain path separators.
271fn validate_filename(filename: &str) -> Result<()> {
272    if filename.contains('/') || filename.contains('\\') {
273        return Err(Error::InvalidToolName {
274            name: filename.to_string(),
275            reason: "filename cannot contain path separators".to_string(),
276        });
277    }
278
279    if filename.contains("..") {
280        return Err(Error::InvalidToolName {
281            name: filename.to_string(),
282            reason: "filename cannot contain parent directory references".to_string(),
283        });
284    }
285
286    Ok(())
287}
288
289/// Get the ignore filename for a tool.
290fn get_ignore_filename(tool: &str, override_filename: Option<&str>) -> String {
291    override_filename.map_or_else(|| format!(".{tool}ignore"), String::from)
292}
293
294/// Generate the content for an ignore file.
295fn generate_ignore_content(patterns: &[String]) -> String {
296    let mut lines = vec![
297        "# Generated by cuenv - do not edit".to_string(),
298        "# Source: env.cue".to_string(),
299        String::new(),
300    ];
301    lines.extend(patterns.iter().cloned());
302    format!("{}\n", lines.join("\n"))
303}
304
305/// Write an ignore file and return the status.
306fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
307    let status = if filepath.exists() {
308        let existing = std::fs::read_to_string(filepath)?;
309        if existing == content {
310            return Ok(FileStatus::Unchanged);
311        }
312        FileStatus::Updated
313    } else {
314        FileStatus::Created
315    };
316
317    std::fs::write(filepath, content)?;
318    Ok(status)
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_get_ignore_filename_default() {
327        assert_eq!(get_ignore_filename("git", None), ".gitignore");
328        assert_eq!(get_ignore_filename("docker", None), ".dockerignore");
329        assert_eq!(get_ignore_filename("npm", None), ".npmignore");
330        assert_eq!(get_ignore_filename("custom", None), ".customignore");
331    }
332
333    #[test]
334    fn test_get_ignore_filename_override() {
335        assert_eq!(
336            get_ignore_filename("git", Some(".my-gitignore")),
337            ".my-gitignore"
338        );
339        assert_eq!(get_ignore_filename("custom", Some(".special")), ".special");
340    }
341
342    #[test]
343    fn test_validate_tool_name_valid() {
344        assert!(validate_tool_name("git").is_ok());
345        assert!(validate_tool_name("docker").is_ok());
346        assert!(validate_tool_name("my-custom-tool").is_ok());
347        assert!(validate_tool_name("tool_with_underscore").is_ok());
348    }
349
350    #[test]
351    fn test_validate_tool_name_invalid() {
352        // Empty
353        assert!(validate_tool_name("").is_err());
354
355        // Path separators
356        assert!(validate_tool_name("../etc").is_err());
357        assert!(validate_tool_name("foo/bar").is_err());
358        assert!(validate_tool_name("foo\\bar").is_err());
359
360        // Parent directory reference
361        assert!(validate_tool_name("..").is_err());
362        assert!(validate_tool_name("foo..bar").is_err());
363    }
364
365    #[test]
366    fn test_generate_ignore_content() {
367        let patterns = vec![
368            "node_modules/".to_string(),
369            ".env".to_string(),
370            "*.log".to_string(),
371        ];
372        let content = generate_ignore_content(&patterns);
373
374        assert!(content.starts_with("# Generated by cuenv - do not edit"));
375        assert!(content.contains("# Source: env.cue"));
376        assert!(content.contains("node_modules/"));
377        assert!(content.contains(".env"));
378        assert!(content.contains("*.log"));
379        assert!(content.ends_with('\n'));
380    }
381
382    #[test]
383    fn test_generate_ignore_content_empty() {
384        let patterns: Vec<String> = vec![];
385        let content = generate_ignore_content(&patterns);
386
387        assert!(content.starts_with("# Generated by cuenv - do not edit"));
388        assert!(content.contains("# Source: env.cue"));
389        assert!(content.ends_with('\n'));
390    }
391
392    #[test]
393    fn test_file_status_display() {
394        assert_eq!(FileStatus::Created.to_string(), "Created");
395        assert_eq!(FileStatus::Updated.to_string(), "Updated");
396        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
397        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
398        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
399    }
400}