Skip to main content

mockforge_plugin_loader/
git.rs

1//! Git repository plugin loading
2//!
3//! This module provides functionality for cloning plugins from Git repositories
4//! with support for:
5//! - Version pinning (tags, branches, commits)
6//! - Shallow clones for performance
7//! - SSH and HTTPS authentication
8//! - Repository caching
9
10use super::*;
11use std::path::{Path, PathBuf};
12
13#[cfg(feature = "git")]
14use git2::{build::RepoBuilder, FetchOptions, Repository};
15
16/// Git repository reference (tag, branch, or commit)
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum GitRef {
19    /// A specific tag (e.g., "v1.0.0")
20    Tag(String),
21    /// A branch name (e.g., "main", "develop")
22    Branch(String),
23    /// A commit SHA (e.g., "abc123def456")
24    Commit(String),
25    /// Default branch (usually "main" or "master")
26    Default,
27}
28
29impl GitRef {
30    /// Parse a Git reference from a string
31    ///
32    /// Examples:
33    /// - "v1.0.0" -> Tag("v1.0.0")
34    /// - "main" -> Branch("main")
35    /// - "abc123" -> Commit("abc123") if it looks like a commit SHA
36    pub fn parse(s: &str) -> Self {
37        if s.is_empty() {
38            return GitRef::Default;
39        }
40
41        // Check if it's a commit SHA (40-char hex string)
42        if s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) {
43            return GitRef::Commit(s.to_string());
44        }
45
46        // Check if it starts with 'v' followed by numbers (version tag)
47        if s.starts_with('v') && s[1..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
48            return GitRef::Tag(s.to_string());
49        }
50
51        // Otherwise, treat as branch
52        GitRef::Branch(s.to_string())
53    }
54}
55
56impl std::fmt::Display for GitRef {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            GitRef::Tag(tag) => write!(f, "tag:{}", tag),
60            GitRef::Branch(branch) => write!(f, "branch:{}", branch),
61            GitRef::Commit(commit) => write!(f, "commit:{}", commit),
62            GitRef::Default => write!(f, "default"),
63        }
64    }
65}
66
67/// Git plugin source specification
68#[derive(Debug, Clone)]
69pub struct GitPluginSource {
70    /// Repository URL (HTTPS or SSH)
71    pub url: String,
72    /// Git reference (tag, branch, or commit)
73    pub git_ref: GitRef,
74    /// Subdirectory within the repo (optional)
75    pub subdirectory: Option<String>,
76}
77
78impl GitPluginSource {
79    /// Parse a Git plugin source from a string
80    ///
81    /// Formats:
82    /// - `https://github.com/user/repo` - Default branch
83    /// - `https://github.com/user/repo#v1.0.0` - Specific tag/branch/commit
84    /// - `https://github.com/user/repo#v1.0.0:subdir` - With subdirectory
85    pub fn parse(input: &str) -> LoaderResult<Self> {
86        // Split on '#' for ref specification
87        let (url_part, ref_part) = if let Some((url, ref_spec)) = input.split_once('#') {
88            (url, Some(ref_spec))
89        } else {
90            (input, None)
91        };
92
93        // Parse ref and subdirectory
94        let (git_ref, subdirectory) = if let Some(ref_spec) = ref_part {
95            if let Some((ref_str, subdir)) = ref_spec.split_once(':') {
96                (GitRef::parse(ref_str), Some(subdir.to_string()))
97            } else {
98                (GitRef::parse(ref_spec), None)
99            }
100        } else {
101            (GitRef::Default, None)
102        };
103
104        Ok(Self {
105            url: url_part.to_string(),
106            git_ref,
107            subdirectory,
108        })
109    }
110}
111
112impl std::fmt::Display for GitPluginSource {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(f, "{}#{}", self.url, self.git_ref)?;
115        if let Some(ref subdir) = self.subdirectory {
116            write!(f, ":{}", subdir)?;
117        }
118        Ok(())
119    }
120}
121
122/// Configuration for Git plugin loading
123#[derive(Debug, Clone)]
124pub struct GitPluginConfig {
125    /// Cache directory for cloned repositories
126    pub cache_dir: PathBuf,
127    /// Use shallow clones (depth=1) for performance
128    pub shallow_clone: bool,
129    /// Include submodules when cloning
130    pub include_submodules: bool,
131}
132
133impl Default for GitPluginConfig {
134    fn default() -> Self {
135        Self {
136            cache_dir: dirs::cache_dir()
137                .unwrap_or_else(|| PathBuf::from(".cache"))
138                .join("mockforge")
139                .join("git-plugins"),
140            shallow_clone: true,
141            include_submodules: false,
142        }
143    }
144}
145
146/// Git plugin loader for cloning plugins from Git repositories
147#[cfg(feature = "git")]
148pub struct GitPluginLoader {
149    config: GitPluginConfig,
150}
151
152#[cfg(feature = "git")]
153impl GitPluginLoader {
154    /// Create a new Git plugin loader
155    pub fn new(config: GitPluginConfig) -> LoaderResult<Self> {
156        // Create cache directory if it doesn't exist
157        std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
158            PluginLoaderError::fs(format!(
159                "Failed to create cache directory {}: {}",
160                config.cache_dir.display(),
161                e
162            ))
163        })?;
164
165        Ok(Self { config })
166    }
167
168    /// Clone a plugin from a Git repository
169    ///
170    /// Returns the path to the cloned plugin directory
171    pub async fn clone_from_git(&self, source: &GitPluginSource) -> LoaderResult<PathBuf> {
172        tracing::info!("Cloning plugin from Git: {}", source);
173
174        // Generate cache key from repository URL and ref
175        let cache_key = self.generate_cache_key(&source.url, &source.git_ref);
176        let repo_path = self.config.cache_dir.join(&cache_key);
177
178        // Check if repository is already cloned
179        if repo_path.exists() && Repository::open(&repo_path).is_ok() {
180            tracing::info!("Using cached repository at: {}", repo_path.display());
181
182            // Update the repository
183            self.update_repository(&repo_path, source).await?;
184        } else {
185            // Clone the repository
186            self.clone_repository(&source.url, &repo_path, source).await?;
187        }
188
189        // If subdirectory is specified, return that path
190        let plugin_path = if let Some(ref subdir) = source.subdirectory {
191            let subdir_path = repo_path.join(subdir);
192            if !subdir_path.exists() {
193                return Err(PluginLoaderError::load(format!(
194                    "Subdirectory '{}' not found in repository",
195                    subdir
196                )));
197            }
198            subdir_path
199        } else {
200            repo_path
201        };
202
203        tracing::info!("Plugin cloned to: {}", plugin_path.display());
204        Ok(plugin_path)
205    }
206
207    /// Clone a repository
208    async fn clone_repository(
209        &self,
210        url: &str,
211        dest: &Path,
212        source: &GitPluginSource,
213    ) -> LoaderResult<()> {
214        tracing::info!("Cloning repository from: {}", url);
215
216        // Prepare fetch options
217        let mut fetch_options = FetchOptions::new();
218
219        // Configure shallow clone if enabled
220        if self.config.shallow_clone && matches!(source.git_ref, GitRef::Tag(_) | GitRef::Branch(_))
221        {
222            fetch_options.depth(1);
223        }
224
225        // Build repository
226        let mut repo_builder = RepoBuilder::new();
227        repo_builder.fetch_options(fetch_options);
228
229        // Set branch if specified
230        if let GitRef::Branch(ref branch) = source.git_ref {
231            repo_builder.branch(branch);
232        }
233
234        // Clone the repository
235        let repo = repo_builder
236            .clone(url, dest)
237            .map_err(|e| PluginLoaderError::load(format!("Failed to clone repository: {}", e)))?;
238
239        // Checkout specific ref if needed
240        match &source.git_ref {
241            GitRef::Tag(tag) => {
242                self.checkout_tag(&repo, tag)?;
243            }
244            GitRef::Commit(commit) => {
245                self.checkout_commit(&repo, commit)?;
246            }
247            GitRef::Branch(_) | GitRef::Default => {
248                // Already on the correct branch from clone
249            }
250        }
251
252        // Initialize submodules if enabled
253        if self.config.include_submodules {
254            self.init_submodules(&repo)?;
255        }
256
257        tracing::info!("Repository cloned successfully");
258        Ok(())
259    }
260
261    /// Update an existing repository
262    async fn update_repository(
263        &self,
264        repo_path: &Path,
265        source: &GitPluginSource,
266    ) -> LoaderResult<()> {
267        tracing::info!("Updating repository at: {}", repo_path.display());
268
269        let repo = Repository::open(repo_path)
270            .map_err(|e| PluginLoaderError::load(format!("Failed to open repository: {}", e)))?;
271
272        // Fetch latest changes
273        let mut remote = repo
274            .find_remote("origin")
275            .map_err(|e| PluginLoaderError::load(format!("Failed to find remote: {}", e)))?;
276
277        let mut fetch_options = FetchOptions::new();
278        remote
279            .fetch(&[] as &[&str], Some(&mut fetch_options), None)
280            .map_err(|e| PluginLoaderError::load(format!("Failed to fetch: {}", e)))?;
281
282        // Checkout the specified ref
283        match &source.git_ref {
284            GitRef::Tag(tag) => {
285                self.checkout_tag(&repo, tag)?;
286            }
287            GitRef::Branch(branch) => {
288                self.checkout_branch(&repo, branch)?;
289            }
290            GitRef::Commit(commit) => {
291                self.checkout_commit(&repo, commit)?;
292            }
293            GitRef::Default => {
294                // Stay on current branch, just pull
295                self.pull_current_branch(&repo)?;
296            }
297        }
298
299        tracing::info!("Repository updated successfully");
300        Ok(())
301    }
302
303    /// Checkout a specific tag
304    fn checkout_tag(&self, repo: &Repository, tag: &str) -> LoaderResult<()> {
305        let refname = format!("refs/tags/{}", tag);
306        let obj = repo
307            .revparse_single(&refname)
308            .map_err(|e| PluginLoaderError::load(format!("Failed to find tag '{}': {}", tag, e)))?;
309
310        repo.checkout_tree(&obj, None)
311            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout tag: {}", e)))?;
312
313        repo.set_head_detached(obj.id())
314            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
315
316        Ok(())
317    }
318
319    /// Checkout a specific branch
320    fn checkout_branch(&self, repo: &Repository, branch: &str) -> LoaderResult<()> {
321        let refname = format!("refs/remotes/origin/{}", branch);
322        let obj = repo.revparse_single(&refname).map_err(|e| {
323            PluginLoaderError::load(format!("Failed to find branch '{}': {}", branch, e))
324        })?;
325
326        repo.checkout_tree(&obj, None)
327            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout branch: {}", e)))?;
328
329        // Create local branch if it doesn't exist
330        let branch_refname = format!("refs/heads/{}", branch);
331        let _ = repo.reference(&branch_refname, obj.id(), true, "checkout branch");
332
333        repo.set_head(&branch_refname)
334            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
335
336        Ok(())
337    }
338
339    /// Checkout a specific commit
340    fn checkout_commit(&self, repo: &Repository, commit: &str) -> LoaderResult<()> {
341        let obj = repo.revparse_single(commit).map_err(|e| {
342            PluginLoaderError::load(format!("Failed to find commit '{}': {}", commit, e))
343        })?;
344
345        repo.checkout_tree(&obj, None)
346            .map_err(|e| PluginLoaderError::load(format!("Failed to checkout commit: {}", e)))?;
347
348        repo.set_head_detached(obj.id())
349            .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
350
351        Ok(())
352    }
353
354    /// Pull the current branch
355    fn pull_current_branch(&self, repo: &Repository) -> LoaderResult<()> {
356        // Get current branch
357        let head = repo
358            .head()
359            .map_err(|e| PluginLoaderError::load(format!("Failed to get HEAD: {}", e)))?;
360
361        if !head.is_branch() {
362            // Detached HEAD, nothing to pull
363            return Ok(());
364        }
365
366        let branch = head
367            .shorthand()
368            .ok_or_else(|| PluginLoaderError::load("Failed to get branch name"))?;
369
370        // Fetch and merge
371        let mut remote = repo
372            .find_remote("origin")
373            .map_err(|e| PluginLoaderError::load(format!("Failed to find remote: {}", e)))?;
374
375        let mut fetch_options = FetchOptions::new();
376        remote
377            .fetch(&[branch], Some(&mut fetch_options), None)
378            .map_err(|e| PluginLoaderError::load(format!("Failed to fetch: {}", e)))?;
379
380        // Fast-forward merge
381        let fetch_head = repo
382            .find_reference("FETCH_HEAD")
383            .map_err(|e| PluginLoaderError::load(format!("Failed to find FETCH_HEAD: {}", e)))?;
384
385        let fetch_commit = repo
386            .reference_to_annotated_commit(&fetch_head)
387            .map_err(|e| PluginLoaderError::load(format!("Failed to get commit: {}", e)))?;
388
389        // Perform fast-forward
390        let (analysis, _) = repo
391            .merge_analysis(&[&fetch_commit])
392            .map_err(|e| PluginLoaderError::load(format!("Failed to analyze merge: {}", e)))?;
393
394        if analysis.is_fast_forward() {
395            let mut reference = repo
396                .find_reference(&format!("refs/heads/{}", branch))
397                .map_err(|e| PluginLoaderError::load(format!("Failed to find reference: {}", e)))?;
398
399            reference
400                .set_target(fetch_commit.id(), "Fast-forward")
401                .map_err(|e| PluginLoaderError::load(format!("Failed to fast-forward: {}", e)))?;
402
403            repo.set_head(&format!("refs/heads/{}", branch))
404                .map_err(|e| PluginLoaderError::load(format!("Failed to set HEAD: {}", e)))?;
405
406            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
407                .map_err(|e| PluginLoaderError::load(format!("Failed to checkout HEAD: {}", e)))?;
408        }
409
410        Ok(())
411    }
412
413    /// Initialize submodules
414    fn init_submodules(&self, repo: &Repository) -> LoaderResult<()> {
415        repo.submodules()
416            .map_err(|e| PluginLoaderError::load(format!("Failed to get submodules: {}", e)))?
417            .iter_mut()
418            .try_for_each(|submodule| {
419                submodule.update(true, None).map_err(|e| {
420                    PluginLoaderError::load(format!("Failed to update submodule: {}", e))
421                })
422            })?;
423
424        Ok(())
425    }
426
427    /// Generate a cache key from repository URL and ref
428    fn generate_cache_key(&self, url: &str, git_ref: &GitRef) -> String {
429        use ring::digest::{Context, SHA256};
430
431        let combined = format!("{}#{}", url, git_ref);
432        let mut context = Context::new(&SHA256);
433        context.update(combined.as_bytes());
434        let digest = context.finish();
435        hex::encode(digest.as_ref())
436    }
437
438    /// Clear the Git repository cache
439    pub async fn clear_cache(&self) -> LoaderResult<()> {
440        if self.config.cache_dir.exists() {
441            tokio::fs::remove_dir_all(&self.config.cache_dir).await.map_err(|e| {
442                PluginLoaderError::fs(format!("Failed to clear cache directory: {}", e))
443            })?;
444            tokio::fs::create_dir_all(&self.config.cache_dir).await.map_err(|e| {
445                PluginLoaderError::fs(format!("Failed to recreate cache directory: {}", e))
446            })?;
447        }
448        Ok(())
449    }
450
451    /// Get the size of the Git repository cache
452    pub fn get_cache_size(&self) -> LoaderResult<u64> {
453        let mut total_size = 0u64;
454
455        if !self.config.cache_dir.exists() {
456            return Ok(0);
457        }
458
459        for entry in std::fs::read_dir(&self.config.cache_dir)
460            .map_err(|e| PluginLoaderError::fs(format!("Failed to read cache directory: {}", e)))?
461        {
462            let entry =
463                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
464            let metadata = entry
465                .metadata()
466                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
467
468            if metadata.is_file() {
469                total_size += metadata.len();
470            } else if metadata.is_dir() {
471                total_size += self.calculate_dir_size(&entry.path())?;
472            }
473        }
474
475        Ok(total_size)
476    }
477
478    /// Calculate the size of a directory recursively
479    #[allow(clippy::only_used_in_recursion)]
480    fn calculate_dir_size(&self, dir: &Path) -> LoaderResult<u64> {
481        let mut total_size = 0u64;
482
483        for entry in std::fs::read_dir(dir)
484            .map_err(|e| PluginLoaderError::fs(format!("Failed to read directory: {}", e)))?
485        {
486            let entry =
487                entry.map_err(|e| PluginLoaderError::fs(format!("Failed to read entry: {}", e)))?;
488            let metadata = entry
489                .metadata()
490                .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata: {}", e)))?;
491
492            if metadata.is_file() {
493                total_size += metadata.len();
494            } else if metadata.is_dir() {
495                total_size += self.calculate_dir_size(&entry.path())?;
496            }
497        }
498
499        Ok(total_size)
500    }
501}
502
503#[cfg(not(feature = "git"))]
504pub struct GitPluginLoader;
505
506#[cfg(not(feature = "git"))]
507impl GitPluginLoader {
508    pub fn new(_config: GitPluginConfig) -> LoaderResult<Self> {
509        Err(PluginLoaderError::load("Git support not enabled. Recompile with 'git' feature"))
510    }
511
512    pub async fn clone_from_git(&self, _source: &GitPluginSource) -> LoaderResult<PathBuf> {
513        Err(PluginLoaderError::load("Git support not enabled. Recompile with 'git' feature"))
514    }
515
516    pub async fn clear_cache(&self) -> LoaderResult<()> {
517        Err(PluginLoaderError::load("Git support not enabled. Recompile with 'git' feature"))
518    }
519
520    pub fn get_cache_size(&self) -> LoaderResult<u64> {
521        Err(PluginLoaderError::load("Git support not enabled. Recompile with 'git' feature"))
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    // ===== GitRef Tests =====
530
531    #[test]
532    fn test_git_ref_parse() {
533        assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
534        assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
535        assert_eq!(
536            GitRef::parse("abc123def456789012345678901234567890abcd"),
537            GitRef::Commit("abc123def456789012345678901234567890abcd".to_string())
538        );
539        assert_eq!(GitRef::parse(""), GitRef::Default);
540    }
541
542    #[test]
543    fn test_git_ref_parse_version_tags() {
544        assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
545        assert_eq!(GitRef::parse("v2.3.4"), GitRef::Tag("v2.3.4".to_string()));
546        assert_eq!(GitRef::parse("v0.1.0-alpha"), GitRef::Tag("v0.1.0-alpha".to_string()));
547    }
548
549    #[test]
550    fn test_git_ref_parse_branches() {
551        assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
552        assert_eq!(GitRef::parse("develop"), GitRef::Branch("develop".to_string()));
553        assert_eq!(
554            GitRef::parse("feature/new-thing"),
555            GitRef::Branch("feature/new-thing".to_string())
556        );
557    }
558
559    #[test]
560    fn test_git_ref_parse_commit() {
561        let commit = "abc123def456789012345678901234567890abcd";
562        assert_eq!(GitRef::parse(commit), GitRef::Commit(commit.to_string()));
563    }
564
565    #[test]
566    fn test_git_ref_parse_short_hash_as_branch() {
567        // Short commit hashes (not 40 chars) should be treated as branches
568        assert_eq!(GitRef::parse("abc123"), GitRef::Branch("abc123".to_string()));
569    }
570
571    #[test]
572    fn test_git_ref_equality() {
573        assert_eq!(GitRef::Tag("v1.0.0".to_string()), GitRef::Tag("v1.0.0".to_string()));
574        assert_ne!(GitRef::Tag("v1.0.0".to_string()), GitRef::Tag("v2.0.0".to_string()));
575        assert_ne!(GitRef::Tag("v1.0.0".to_string()), GitRef::Branch("v1.0.0".to_string()));
576    }
577
578    #[test]
579    fn test_git_ref_clone() {
580        let git_ref = GitRef::Tag("v1.0.0".to_string());
581        let cloned = git_ref.clone();
582        assert_eq!(git_ref, cloned);
583    }
584
585    #[test]
586    fn test_git_ref_display() {
587        assert_eq!(GitRef::Tag("v1.0.0".to_string()).to_string(), "tag:v1.0.0");
588        assert_eq!(GitRef::Branch("main".to_string()).to_string(), "branch:main");
589        assert_eq!(GitRef::Commit("abc123".to_string()).to_string(), "commit:abc123");
590        assert_eq!(GitRef::Default.to_string(), "default");
591    }
592
593    // ===== GitPluginSource Tests =====
594
595    #[test]
596    fn test_git_plugin_source_parse() {
597        // URL only
598        let source = GitPluginSource::parse("https://github.com/user/repo").unwrap();
599        assert_eq!(source.url, "https://github.com/user/repo");
600        assert_eq!(source.git_ref, GitRef::Default);
601        assert_eq!(source.subdirectory, None);
602
603        // URL with tag
604        let source = GitPluginSource::parse("https://github.com/user/repo#v1.0.0").unwrap();
605        assert_eq!(source.url, "https://github.com/user/repo");
606        assert_eq!(source.git_ref, GitRef::Tag("v1.0.0".to_string()));
607        assert_eq!(source.subdirectory, None);
608
609        // URL with branch and subdirectory
610        let source =
611            GitPluginSource::parse("https://github.com/user/repo#main:plugins/auth").unwrap();
612        assert_eq!(source.url, "https://github.com/user/repo");
613        assert_eq!(source.git_ref, GitRef::Branch("main".to_string()));
614        assert_eq!(source.subdirectory, Some("plugins/auth".to_string()));
615    }
616
617    #[test]
618    fn test_git_plugin_source_parse_ssh_url() {
619        let source = GitPluginSource::parse("git@github.com:user/repo.git").unwrap();
620        assert_eq!(source.url, "git@github.com:user/repo.git");
621        assert_eq!(source.git_ref, GitRef::Default);
622    }
623
624    #[test]
625    fn test_git_plugin_source_parse_with_commit() {
626        let commit = "abc123def456789012345678901234567890abcd";
627        let source =
628            GitPluginSource::parse(&format!("https://github.com/user/repo#{}", commit)).unwrap();
629        assert_eq!(source.git_ref, GitRef::Commit(commit.to_string()));
630    }
631
632    #[test]
633    fn test_git_plugin_source_parse_with_subdirectory_only() {
634        let source = GitPluginSource::parse("https://github.com/user/repo#:subdir").unwrap();
635        assert_eq!(source.git_ref, GitRef::Default);
636        assert_eq!(source.subdirectory, Some("subdir".to_string()));
637    }
638
639    #[test]
640    fn test_git_plugin_source_clone() {
641        let source = GitPluginSource {
642            url: "https://github.com/user/repo".to_string(),
643            git_ref: GitRef::Tag("v1.0.0".to_string()),
644            subdirectory: Some("plugins".to_string()),
645        };
646
647        let cloned = source.clone();
648        assert_eq!(source.url, cloned.url);
649        assert_eq!(source.git_ref, cloned.git_ref);
650        assert_eq!(source.subdirectory, cloned.subdirectory);
651    }
652
653    #[test]
654    fn test_git_plugin_source_display() {
655        let source = GitPluginSource {
656            url: "https://github.com/user/repo".to_string(),
657            git_ref: GitRef::Tag("v1.0.0".to_string()),
658            subdirectory: Some("plugins".to_string()),
659        };
660        assert_eq!(source.to_string(), "https://github.com/user/repo#tag:v1.0.0:plugins");
661    }
662
663    #[test]
664    fn test_git_plugin_source_display_without_subdirectory() {
665        let source = GitPluginSource {
666            url: "https://github.com/user/repo".to_string(),
667            git_ref: GitRef::Branch("main".to_string()),
668            subdirectory: None,
669        };
670        assert_eq!(source.to_string(), "https://github.com/user/repo#branch:main");
671    }
672
673    // ===== GitPluginConfig Tests =====
674
675    #[test]
676    fn test_git_plugin_config_default() {
677        let config = GitPluginConfig::default();
678        assert!(config.shallow_clone);
679        assert!(!config.include_submodules);
680        assert!(config.cache_dir.to_string_lossy().contains("mockforge"));
681        assert!(config.cache_dir.to_string_lossy().contains("git-plugins"));
682    }
683
684    #[test]
685    fn test_git_plugin_config_clone() {
686        let config = GitPluginConfig::default();
687        let cloned = config.clone();
688        assert_eq!(config.shallow_clone, cloned.shallow_clone);
689        assert_eq!(config.include_submodules, cloned.include_submodules);
690        assert_eq!(config.cache_dir, cloned.cache_dir);
691    }
692
693    #[test]
694    fn test_git_plugin_config_custom() {
695        let config = GitPluginConfig {
696            cache_dir: PathBuf::from("/tmp/custom-cache"),
697            shallow_clone: false,
698            include_submodules: true,
699        };
700
701        assert!(!config.shallow_clone);
702        assert!(config.include_submodules);
703        assert_eq!(config.cache_dir, PathBuf::from("/tmp/custom-cache"));
704    }
705
706    // ===== Edge Cases =====
707
708    #[test]
709    fn test_git_ref_parse_empty_string() {
710        assert_eq!(GitRef::parse(""), GitRef::Default);
711    }
712
713    #[test]
714    fn test_git_plugin_source_parse_gitlab() {
715        let source = GitPluginSource::parse("https://gitlab.com/group/project").unwrap();
716        assert_eq!(source.url, "https://gitlab.com/group/project");
717    }
718
719    #[test]
720    fn test_git_plugin_source_parse_complex_subdirectory() {
721        let source =
722            GitPluginSource::parse("https://github.com/user/repo#v1.0.0:path/to/plugin").unwrap();
723        assert_eq!(source.subdirectory, Some("path/to/plugin".to_string()));
724    }
725
726    #[test]
727    fn test_git_ref_debug() {
728        let git_ref = GitRef::Tag("v1.0.0".to_string());
729        let debug_str = format!("{:?}", git_ref);
730        assert!(debug_str.contains("Tag"));
731        assert!(debug_str.contains("v1.0.0"));
732    }
733}