1#![warn(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34#[cfg(feature = "serde")]
35use serde::{Deserialize, Serialize};
36
37#[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 #[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 #[must_use]
88 pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
89 self.patterns.push(pattern.into());
90 self
91 }
92
93 #[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 #[must_use]
104 pub fn filename(mut self, filename: impl Into<String>) -> Self {
105 self.filename = Some(filename.into());
106 self
107 }
108
109 #[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 #[must_use]
121 pub fn header(mut self, header: impl Into<String>) -> Self {
122 self.header = Some(header.into());
123 self
124 }
125
126 #[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 #[must_use]
138 pub fn tool(&self) -> &str {
139 &self.tool
140 }
141
142 #[must_use]
144 pub fn patterns_list(&self) -> &[String] {
145 &self.patterns
146 }
147
148 #[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 lines.extend(self.patterns.iter().cloned());
178
179 format!("{}\n", lines.join("\n"))
180 }
181}
182
183pub struct IgnoreFiles;
185
186impl IgnoreFiles {
187 #[must_use]
201 pub fn builder() -> IgnoreFilesBuilder {
202 IgnoreFilesBuilder::default()
203 }
204}
205
206#[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 #[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 #[must_use]
245 pub fn file(mut self, file: IgnoreFile) -> Self {
246 self.files.push(file);
247 self
248 }
249
250 #[must_use]
252 pub fn files(mut self, files: impl IntoIterator<Item = IgnoreFile>) -> Self {
253 self.files.extend(files);
254 self
255 }
256
257 #[must_use]
262 pub fn require_git_repo(mut self, require: bool) -> Self {
263 self.require_git_repo = require;
264 self
265 }
266
267 #[must_use]
272 pub fn dry_run(mut self, dry_run: bool) -> Self {
273 self.dry_run = dry_run;
274 self
275 }
276
277 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 {
292 verify_git_repository(&dir)?;
293 }
294
295 let mut results = Vec::new();
296
297 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 if file.patterns.is_empty() {
304 tracing::debug!("Skipping tool '{}' - no patterns", file.tool);
305 continue;
306 }
307
308 validate_tool_name(&file.tool)?;
310
311 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#[derive(Debug, Clone)]
361#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362pub struct IgnoreConfig {
363 pub tool: String,
365 pub patterns: Vec<String>,
367 pub filename: Option<String>,
369}
370
371pub 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) .dry_run(dry_run)
407 .files(files)
408 .generate()
409}
410
411#[derive(Debug)]
417pub struct SyncResult {
418 pub files: Vec<FileResult>,
420}
421
422#[derive(Debug)]
424pub struct FileResult {
425 pub filename: String,
427 pub status: FileStatus,
429 pub pattern_count: usize,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum FileStatus {
436 Created,
438 Updated,
440 Unchanged,
442 WouldCreate,
444 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#[derive(Debug, thiserror::Error)]
466pub enum Error {
467 #[error("Invalid tool name '{name}': {reason}")]
469 InvalidToolName {
470 name: String,
472 reason: String,
474 },
475
476 #[error("Must be run within a Git repository")]
478 NotInGitRepo,
479
480 #[error("Cannot operate in a bare Git repository")]
482 BareRepository,
483
484 #[error("Target directory must be within the Git repository")]
486 OutsideGitRepo,
487
488 #[error("IO error: {0}")]
490 Io(#[from] std::io::Error),
491}
492
493pub type Result<T> = std::result::Result<T, Error>;
495
496fn 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 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
526fn 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
552fn 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
571fn 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 #[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}