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::new();
166
167 if let Some(ref header) = self.header {
169 for line in header.lines() {
170 lines.push(format!("# {line}"));
171 }
172 lines.push(String::new());
173 }
174
175 lines.extend(self.patterns.iter().cloned());
177
178 format!("{}\n", lines.join("\n"))
179 }
180}
181
182pub struct IgnoreFiles;
184
185impl IgnoreFiles {
186 #[must_use]
200 pub fn builder() -> IgnoreFilesBuilder {
201 IgnoreFilesBuilder::default()
202 }
203}
204
205#[derive(Debug, Default)]
225pub struct IgnoreFilesBuilder {
226 directory: Option<PathBuf>,
227 files: Vec<IgnoreFile>,
228 require_git_repo: bool,
229 dry_run: bool,
230}
231
232impl IgnoreFilesBuilder {
233 #[must_use]
237 pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
238 self.directory = Some(dir.as_ref().to_path_buf());
239 self
240 }
241
242 #[must_use]
244 pub fn file(mut self, file: IgnoreFile) -> Self {
245 self.files.push(file);
246 self
247 }
248
249 #[must_use]
251 pub fn files(mut self, files: impl IntoIterator<Item = IgnoreFile>) -> Self {
252 self.files.extend(files);
253 self
254 }
255
256 #[must_use]
261 pub fn require_git_repo(mut self, require: bool) -> Self {
262 self.require_git_repo = require;
263 self
264 }
265
266 #[must_use]
271 pub fn dry_run(mut self, dry_run: bool) -> Self {
272 self.dry_run = dry_run;
273 self
274 }
275
276 pub fn generate(self) -> Result<SyncResult> {
285 let dir = self.directory.unwrap_or_else(|| PathBuf::from("."));
286
287 tracing::info!("Starting ignore file generation");
288
289 if self.require_git_repo {
291 verify_git_repository(&dir)?;
292 }
293
294 let mut results = Vec::new();
295
296 let mut sorted_files = self.files;
298 sorted_files.sort_by(|a, b| a.tool.cmp(&b.tool));
299
300 for file in sorted_files {
301 if file.patterns.is_empty() {
303 tracing::debug!("Skipping tool '{}' - no patterns", file.tool);
304 continue;
305 }
306
307 validate_tool_name(&file.tool)?;
309
310 let filename = file.output_filename();
312 validate_filename(&filename)?;
313
314 let filepath = dir.join(&filename);
315 let content = file.generate();
316
317 let (status, pattern_count) = if self.dry_run {
318 let status = if filepath.exists() {
319 let existing = std::fs::read_to_string(&filepath)?;
320 if existing == content {
321 FileStatus::Unchanged
322 } else {
323 FileStatus::WouldUpdate
324 }
325 } else {
326 FileStatus::WouldCreate
327 };
328 (status, file.patterns.len())
329 } else {
330 let status = write_ignore_file(&filepath, &content)?;
331 (status, file.patterns.len())
332 };
333
334 tracing::info!(
335 filename = %filename,
336 status = %status,
337 patterns = pattern_count,
338 "Processed ignore file"
339 );
340
341 results.push(FileResult {
342 filename,
343 status,
344 pattern_count,
345 });
346 }
347
348 Ok(SyncResult { files: results })
349 }
350}
351
352#[derive(Debug, Clone)]
360#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
361pub struct IgnoreConfig {
362 pub tool: String,
364 pub patterns: Vec<String>,
366 pub filename: Option<String>,
368}
369
370pub fn generate_ignore_files(
387 dir: &Path,
388 configs: Vec<IgnoreConfig>,
389 dry_run: bool,
390) -> Result<SyncResult> {
391 let files: Vec<IgnoreFile> = configs
392 .into_iter()
393 .map(|c| {
394 let mut file = IgnoreFile::new(c.tool).patterns(c.patterns);
395 if let Some(filename) = c.filename {
396 file = file.filename(filename);
397 }
398 file
399 })
400 .collect();
401
402 IgnoreFiles::builder()
403 .directory(dir)
404 .require_git_repo(true) .dry_run(dry_run)
406 .files(files)
407 .generate()
408}
409
410#[derive(Debug)]
416pub struct SyncResult {
417 pub files: Vec<FileResult>,
419}
420
421#[derive(Debug)]
423pub struct FileResult {
424 pub filename: String,
426 pub status: FileStatus,
428 pub pattern_count: usize,
430}
431
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum FileStatus {
435 Created,
437 Updated,
439 Unchanged,
441 WouldCreate,
443 WouldUpdate,
445}
446
447impl std::fmt::Display for FileStatus {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 match self {
450 Self::Created => write!(f, "Created"),
451 Self::Updated => write!(f, "Updated"),
452 Self::Unchanged => write!(f, "Unchanged"),
453 Self::WouldCreate => write!(f, "Would create"),
454 Self::WouldUpdate => write!(f, "Would update"),
455 }
456 }
457}
458
459#[derive(Debug, thiserror::Error)]
465pub enum Error {
466 #[error("Invalid tool name '{name}': {reason}")]
468 InvalidToolName {
469 name: String,
471 reason: String,
473 },
474
475 #[error("Must be run within a Git repository")]
477 NotInGitRepo,
478
479 #[error("Cannot operate in a bare Git repository")]
481 BareRepository,
482
483 #[error("Target directory must be within the Git repository")]
485 OutsideGitRepo,
486
487 #[error("IO error: {0}")]
489 Io(#[from] std::io::Error),
490}
491
492pub type Result<T> = std::result::Result<T, Error>;
494
495fn verify_git_repository(dir: &Path) -> Result<()> {
501 let repo = gix::discover(dir).map_err(|e| {
502 tracing::debug!("Git discovery failed: {}", e);
503 Error::NotInGitRepo
504 })?;
505
506 let git_root = repo.workdir().ok_or(Error::BareRepository)?;
507
508 let canonical_dir = std::fs::canonicalize(dir)?;
510 let canonical_git = std::fs::canonicalize(git_root)?;
511
512 if !canonical_dir.starts_with(&canonical_git) {
513 return Err(Error::OutsideGitRepo);
514 }
515
516 tracing::debug!(
517 git_root = %canonical_git.display(),
518 target_dir = %canonical_dir.display(),
519 "Verified directory is within Git repository"
520 );
521
522 Ok(())
523}
524
525fn validate_tool_name(tool: &str) -> Result<()> {
527 if tool.is_empty() {
528 return Err(Error::InvalidToolName {
529 name: tool.to_string(),
530 reason: "tool name cannot be empty".to_string(),
531 });
532 }
533
534 if tool.contains('/') || tool.contains('\\') {
535 return Err(Error::InvalidToolName {
536 name: tool.to_string(),
537 reason: "tool name cannot contain path separators".to_string(),
538 });
539 }
540
541 if tool.contains("..") {
542 return Err(Error::InvalidToolName {
543 name: tool.to_string(),
544 reason: "tool name cannot contain parent directory references".to_string(),
545 });
546 }
547
548 Ok(())
549}
550
551fn validate_filename(filename: &str) -> Result<()> {
553 if filename.contains('/') || filename.contains('\\') {
554 return Err(Error::InvalidToolName {
555 name: filename.to_string(),
556 reason: "filename cannot contain path separators".to_string(),
557 });
558 }
559
560 if filename.contains("..") {
561 return Err(Error::InvalidToolName {
562 name: filename.to_string(),
563 reason: "filename cannot contain parent directory references".to_string(),
564 });
565 }
566
567 Ok(())
568}
569
570fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
572 let status = if filepath.exists() {
573 let existing = std::fs::read_to_string(filepath)?;
574 if existing == content {
575 return Ok(FileStatus::Unchanged);
576 }
577 FileStatus::Updated
578 } else {
579 FileStatus::Created
580 };
581
582 std::fs::write(filepath, content)?;
583 Ok(status)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn test_ignore_file_new() {
592 let file = IgnoreFile::new("git");
593 assert_eq!(file.tool(), "git");
594 assert!(file.patterns_list().is_empty());
595 assert_eq!(file.output_filename(), ".gitignore");
596 }
597
598 #[test]
599 fn test_ignore_file_builder() {
600 let file = IgnoreFile::new("docker")
601 .pattern("node_modules/")
602 .pattern(".env")
603 .filename(".mydockerignore")
604 .header("My header");
605
606 assert_eq!(file.tool(), "docker");
607 assert_eq!(file.patterns_list(), &["node_modules/", ".env"]);
608 assert_eq!(file.output_filename(), ".mydockerignore");
609 }
610
611 #[test]
612 fn test_ignore_file_patterns() {
613 let file = IgnoreFile::new("git").patterns(["a", "b", "c"]);
614 assert_eq!(file.patterns_list(), &["a", "b", "c"]);
615 }
616
617 #[test]
618 fn test_ignore_file_generate_no_header() {
619 let file = IgnoreFile::new("git")
620 .pattern("node_modules/")
621 .pattern(".env");
622
623 let content = file.generate();
624 assert_eq!(content, "node_modules/\n.env\n");
625 }
626
627 #[test]
628 fn test_ignore_file_generate_with_header() {
629 let file = IgnoreFile::new("git")
630 .pattern("node_modules/")
631 .header("Generated by my-tool\nDo not edit");
632
633 let content = file.generate();
634 assert!(content.starts_with("# Generated by my-tool\n# Do not edit\n\n"));
635 assert!(content.contains("node_modules/"));
636 }
637
638 #[test]
639 fn test_output_filename_default() {
640 assert_eq!(IgnoreFile::new("git").output_filename(), ".gitignore");
641 assert_eq!(IgnoreFile::new("docker").output_filename(), ".dockerignore");
642 assert_eq!(IgnoreFile::new("npm").output_filename(), ".npmignore");
643 }
644
645 #[test]
646 fn test_output_filename_custom() {
647 let file = IgnoreFile::new("git").filename(".my-gitignore");
648 assert_eq!(file.output_filename(), ".my-gitignore");
649 }
650
651 #[test]
652 fn test_validate_tool_name_valid() {
653 assert!(validate_tool_name("git").is_ok());
654 assert!(validate_tool_name("docker").is_ok());
655 assert!(validate_tool_name("my-custom-tool").is_ok());
656 assert!(validate_tool_name("tool_with_underscore").is_ok());
657 }
658
659 #[test]
660 fn test_validate_tool_name_invalid() {
661 assert!(validate_tool_name("").is_err());
662 assert!(validate_tool_name("../etc").is_err());
663 assert!(validate_tool_name("foo/bar").is_err());
664 assert!(validate_tool_name("foo\\bar").is_err());
665 assert!(validate_tool_name("..").is_err());
666 assert!(validate_tool_name("foo..bar").is_err());
667 }
668
669 #[test]
670 fn test_file_status_display() {
671 assert_eq!(FileStatus::Created.to_string(), "Created");
672 assert_eq!(FileStatus::Updated.to_string(), "Updated");
673 assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
674 assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
675 assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
676 }
677
678 #[test]
680 fn test_ignore_config_to_ignore_file() {
681 let config = IgnoreConfig {
682 tool: "git".to_string(),
683 patterns: vec!["node_modules/".to_string()],
684 filename: Some(".custom".to_string()),
685 };
686
687 let file = IgnoreFile::new(config.tool)
688 .patterns(config.patterns)
689 .filename_opt(config.filename);
690
691 assert_eq!(file.tool(), "git");
692 assert_eq!(file.output_filename(), ".custom");
693 }
694}