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 const fn require_git_repo(mut self, require: bool) -> Self {
263 self.require_git_repo = require;
264 self
265 }
266
267 #[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 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
308fn 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
335fn 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
344fn 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#[derive(Debug)]
363pub struct SyncResult {
364 pub files: Vec<FileResult>,
366}
367
368#[derive(Debug)]
370pub struct FileResult {
371 pub filename: String,
373 pub status: FileStatus,
375 pub pattern_count: usize,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum FileStatus {
382 Created,
384 Updated,
386 Unchanged,
388 WouldCreate,
390 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#[derive(Debug, thiserror::Error)]
412pub enum Error {
413 #[error("Invalid tool name '{name}': {reason}")]
415 InvalidToolName {
416 name: String,
418 reason: String,
420 },
421
422 #[error("Must be run within a Git repository")]
424 NotInGitRepo,
425
426 #[error("Cannot operate in a bare Git repository")]
428 BareRepository,
429
430 #[error("Target directory must be within the Git repository")]
432 OutsideGitRepo,
433
434 #[error("IO error: {0}")]
436 Io(#[from] std::io::Error),
437}
438
439pub type Result<T> = std::result::Result<T, Error>;
441
442fn 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 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
472fn 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
498fn 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
517fn 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}