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-support")]
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-support")]
148pub struct GitPluginLoader {
149    config: GitPluginConfig,
150}
151
152#[cfg(feature = "git-support")]
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-support"))]
504pub struct GitPluginLoader;
505
506#[cfg(not(feature = "git-support"))]
507impl GitPluginLoader {
508    pub fn new(_config: GitPluginConfig) -> LoaderResult<Self> {
509        Err(PluginLoaderError::load(
510            "Git support not enabled. Recompile with 'git-support' feature",
511        ))
512    }
513
514    pub async fn clone_from_git(&self, _source: &GitPluginSource) -> LoaderResult<PathBuf> {
515        Err(PluginLoaderError::load(
516            "Git support not enabled. Recompile with 'git-support' feature",
517        ))
518    }
519
520    pub async fn clear_cache(&self) -> LoaderResult<()> {
521        Err(PluginLoaderError::load(
522            "Git support not enabled. Recompile with 'git-support' feature",
523        ))
524    }
525
526    pub fn get_cache_size(&self) -> LoaderResult<u64> {
527        Err(PluginLoaderError::load(
528            "Git support not enabled. Recompile with 'git-support' feature",
529        ))
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    // ===== GitRef Tests =====
538
539    #[test]
540    fn test_git_ref_parse() {
541        assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
542        assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
543        assert_eq!(
544            GitRef::parse("abc123def456789012345678901234567890abcd"),
545            GitRef::Commit("abc123def456789012345678901234567890abcd".to_string())
546        );
547        assert_eq!(GitRef::parse(""), GitRef::Default);
548    }
549
550    #[test]
551    fn test_git_ref_parse_version_tags() {
552        assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
553        assert_eq!(GitRef::parse("v2.3.4"), GitRef::Tag("v2.3.4".to_string()));
554        assert_eq!(GitRef::parse("v0.1.0-alpha"), GitRef::Tag("v0.1.0-alpha".to_string()));
555    }
556
557    #[test]
558    fn test_git_ref_parse_branches() {
559        assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
560        assert_eq!(GitRef::parse("develop"), GitRef::Branch("develop".to_string()));
561        assert_eq!(
562            GitRef::parse("feature/new-thing"),
563            GitRef::Branch("feature/new-thing".to_string())
564        );
565    }
566
567    #[test]
568    fn test_git_ref_parse_commit() {
569        let commit = "abc123def456789012345678901234567890abcd";
570        assert_eq!(GitRef::parse(commit), GitRef::Commit(commit.to_string()));
571    }
572
573    #[test]
574    fn test_git_ref_parse_short_hash_as_branch() {
575        // Short commit hashes (not 40 chars) should be treated as branches
576        assert_eq!(GitRef::parse("abc123"), GitRef::Branch("abc123".to_string()));
577    }
578
579    #[test]
580    fn test_git_ref_equality() {
581        assert_eq!(GitRef::Tag("v1.0.0".to_string()), GitRef::Tag("v1.0.0".to_string()));
582        assert_ne!(GitRef::Tag("v1.0.0".to_string()), GitRef::Tag("v2.0.0".to_string()));
583        assert_ne!(GitRef::Tag("v1.0.0".to_string()), GitRef::Branch("v1.0.0".to_string()));
584    }
585
586    #[test]
587    fn test_git_ref_clone() {
588        let git_ref = GitRef::Tag("v1.0.0".to_string());
589        let cloned = git_ref.clone();
590        assert_eq!(git_ref, cloned);
591    }
592
593    #[test]
594    fn test_git_ref_display() {
595        assert_eq!(GitRef::Tag("v1.0.0".to_string()).to_string(), "tag:v1.0.0");
596        assert_eq!(GitRef::Branch("main".to_string()).to_string(), "branch:main");
597        assert_eq!(GitRef::Commit("abc123".to_string()).to_string(), "commit:abc123");
598        assert_eq!(GitRef::Default.to_string(), "default");
599    }
600
601    // ===== GitPluginSource Tests =====
602
603    #[test]
604    fn test_git_plugin_source_parse() {
605        // URL only
606        let source = GitPluginSource::parse("https://github.com/user/repo").unwrap();
607        assert_eq!(source.url, "https://github.com/user/repo");
608        assert_eq!(source.git_ref, GitRef::Default);
609        assert_eq!(source.subdirectory, None);
610
611        // URL with tag
612        let source = GitPluginSource::parse("https://github.com/user/repo#v1.0.0").unwrap();
613        assert_eq!(source.url, "https://github.com/user/repo");
614        assert_eq!(source.git_ref, GitRef::Tag("v1.0.0".to_string()));
615        assert_eq!(source.subdirectory, None);
616
617        // URL with branch and subdirectory
618        let source =
619            GitPluginSource::parse("https://github.com/user/repo#main:plugins/auth").unwrap();
620        assert_eq!(source.url, "https://github.com/user/repo");
621        assert_eq!(source.git_ref, GitRef::Branch("main".to_string()));
622        assert_eq!(source.subdirectory, Some("plugins/auth".to_string()));
623    }
624
625    #[test]
626    fn test_git_plugin_source_parse_ssh_url() {
627        let source = GitPluginSource::parse("git@github.com:user/repo.git").unwrap();
628        assert_eq!(source.url, "git@github.com:user/repo.git");
629        assert_eq!(source.git_ref, GitRef::Default);
630    }
631
632    #[test]
633    fn test_git_plugin_source_parse_with_commit() {
634        let commit = "abc123def456789012345678901234567890abcd";
635        let source =
636            GitPluginSource::parse(&format!("https://github.com/user/repo#{}", commit)).unwrap();
637        assert_eq!(source.git_ref, GitRef::Commit(commit.to_string()));
638    }
639
640    #[test]
641    fn test_git_plugin_source_parse_with_subdirectory_only() {
642        let source = GitPluginSource::parse("https://github.com/user/repo#:subdir").unwrap();
643        assert_eq!(source.git_ref, GitRef::Default);
644        assert_eq!(source.subdirectory, Some("subdir".to_string()));
645    }
646
647    #[test]
648    fn test_git_plugin_source_clone() {
649        let source = GitPluginSource {
650            url: "https://github.com/user/repo".to_string(),
651            git_ref: GitRef::Tag("v1.0.0".to_string()),
652            subdirectory: Some("plugins".to_string()),
653        };
654
655        let cloned = source.clone();
656        assert_eq!(source.url, cloned.url);
657        assert_eq!(source.git_ref, cloned.git_ref);
658        assert_eq!(source.subdirectory, cloned.subdirectory);
659    }
660
661    #[test]
662    fn test_git_plugin_source_display() {
663        let source = GitPluginSource {
664            url: "https://github.com/user/repo".to_string(),
665            git_ref: GitRef::Tag("v1.0.0".to_string()),
666            subdirectory: Some("plugins".to_string()),
667        };
668        assert_eq!(source.to_string(), "https://github.com/user/repo#tag:v1.0.0:plugins");
669    }
670
671    #[test]
672    fn test_git_plugin_source_display_without_subdirectory() {
673        let source = GitPluginSource {
674            url: "https://github.com/user/repo".to_string(),
675            git_ref: GitRef::Branch("main".to_string()),
676            subdirectory: None,
677        };
678        assert_eq!(source.to_string(), "https://github.com/user/repo#branch:main");
679    }
680
681    // ===== GitPluginConfig Tests =====
682
683    #[test]
684    fn test_git_plugin_config_default() {
685        let config = GitPluginConfig::default();
686        assert!(config.shallow_clone);
687        assert!(!config.include_submodules);
688        assert!(config.cache_dir.to_string_lossy().contains("mockforge"));
689        assert!(config.cache_dir.to_string_lossy().contains("git-plugins"));
690    }
691
692    #[test]
693    fn test_git_plugin_config_clone() {
694        let config = GitPluginConfig::default();
695        let cloned = config.clone();
696        assert_eq!(config.shallow_clone, cloned.shallow_clone);
697        assert_eq!(config.include_submodules, cloned.include_submodules);
698        assert_eq!(config.cache_dir, cloned.cache_dir);
699    }
700
701    #[test]
702    fn test_git_plugin_config_custom() {
703        let config = GitPluginConfig {
704            cache_dir: PathBuf::from("/tmp/custom-cache"),
705            shallow_clone: false,
706            include_submodules: true,
707        };
708
709        assert!(!config.shallow_clone);
710        assert!(config.include_submodules);
711        assert_eq!(config.cache_dir, PathBuf::from("/tmp/custom-cache"));
712    }
713
714    // ===== Edge Cases =====
715
716    #[test]
717    fn test_git_ref_parse_empty_string() {
718        assert_eq!(GitRef::parse(""), GitRef::Default);
719    }
720
721    #[test]
722    fn test_git_plugin_source_parse_gitlab() {
723        let source = GitPluginSource::parse("https://gitlab.com/group/project").unwrap();
724        assert_eq!(source.url, "https://gitlab.com/group/project");
725    }
726
727    #[test]
728    fn test_git_plugin_source_parse_complex_subdirectory() {
729        let source =
730            GitPluginSource::parse("https://github.com/user/repo#v1.0.0:path/to/plugin").unwrap();
731        assert_eq!(source.subdirectory, Some("path/to/plugin".to_string()));
732    }
733
734    #[test]
735    fn test_git_ref_debug() {
736        let git_ref = GitRef::Tag("v1.0.0".to_string());
737        let debug_str = format!("{:?}", git_ref);
738        assert!(debug_str.contains("Tag"));
739        assert!(debug_str.contains("v1.0.0"));
740    }
741}