1use super::*;
11use std::path::{Path, PathBuf};
12
13#[cfg(feature = "git-support")]
14use git2::{build::RepoBuilder, FetchOptions, Repository};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum GitRef {
19 Tag(String),
21 Branch(String),
23 Commit(String),
25 Default,
27}
28
29impl GitRef {
30 pub fn parse(s: &str) -> Self {
37 if s.is_empty() {
38 return GitRef::Default;
39 }
40
41 if s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) {
43 return GitRef::Commit(s.to_string());
44 }
45
46 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 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#[derive(Debug, Clone)]
69pub struct GitPluginSource {
70 pub url: String,
72 pub git_ref: GitRef,
74 pub subdirectory: Option<String>,
76}
77
78impl GitPluginSource {
79 pub fn parse(input: &str) -> LoaderResult<Self> {
86 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 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#[derive(Debug, Clone)]
124pub struct GitPluginConfig {
125 pub cache_dir: PathBuf,
127 pub shallow_clone: bool,
129 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#[cfg(feature = "git-support")]
148pub struct GitPluginLoader {
149 config: GitPluginConfig,
150}
151
152#[cfg(feature = "git-support")]
153impl GitPluginLoader {
154 pub fn new(config: GitPluginConfig) -> LoaderResult<Self> {
156 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 pub async fn clone_from_git(&self, source: &GitPluginSource) -> LoaderResult<PathBuf> {
172 tracing::info!("Cloning plugin from Git: {}", source);
173
174 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 if repo_path.exists() && Repository::open(&repo_path).is_ok() {
180 tracing::info!("Using cached repository at: {}", repo_path.display());
181
182 self.update_repository(&repo_path, source).await?;
184 } else {
185 self.clone_repository(&source.url, &repo_path, source).await?;
187 }
188
189 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 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 let mut fetch_options = FetchOptions::new();
218
219 if self.config.shallow_clone && matches!(source.git_ref, GitRef::Tag(_) | GitRef::Branch(_))
221 {
222 fetch_options.depth(1);
223 }
224
225 let mut repo_builder = RepoBuilder::new();
227 repo_builder.fetch_options(fetch_options);
228
229 if let GitRef::Branch(ref branch) = source.git_ref {
231 repo_builder.branch(branch);
232 }
233
234 let repo = repo_builder
236 .clone(url, dest)
237 .map_err(|e| PluginLoaderError::load(format!("Failed to clone repository: {}", e)))?;
238
239 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 }
250 }
251
252 if self.config.include_submodules {
254 self.init_submodules(&repo)?;
255 }
256
257 tracing::info!("Repository cloned successfully");
258 Ok(())
259 }
260
261 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 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 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 self.pull_current_branch(&repo)?;
296 }
297 }
298
299 tracing::info!("Repository updated successfully");
300 Ok(())
301 }
302
303 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 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 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 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 fn pull_current_branch(&self, repo: &Repository) -> LoaderResult<()> {
356 let head = repo
358 .head()
359 .map_err(|e| PluginLoaderError::load(format!("Failed to get HEAD: {}", e)))?;
360
361 if !head.is_branch() {
362 return Ok(());
364 }
365
366 let branch = head
367 .shorthand()
368 .ok_or_else(|| PluginLoaderError::load("Failed to get branch name"))?;
369
370 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 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 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 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 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 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 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 #[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 #[test]
538 fn test_git_ref_parse() {
539 assert_eq!(GitRef::parse("v1.0.0"), GitRef::Tag("v1.0.0".to_string()));
540 assert_eq!(GitRef::parse("main"), GitRef::Branch("main".to_string()));
541 assert_eq!(
542 GitRef::parse("abc123def456789012345678901234567890abcd"),
543 GitRef::Commit("abc123def456789012345678901234567890abcd".to_string())
544 );
545 assert_eq!(GitRef::parse(""), GitRef::Default);
546 }
547
548 #[test]
549 fn test_git_plugin_source_parse() {
550 let source = GitPluginSource::parse("https://github.com/user/repo").unwrap();
552 assert_eq!(source.url, "https://github.com/user/repo");
553 assert_eq!(source.git_ref, GitRef::Default);
554 assert_eq!(source.subdirectory, None);
555
556 let source = GitPluginSource::parse("https://github.com/user/repo#v1.0.0").unwrap();
558 assert_eq!(source.url, "https://github.com/user/repo");
559 assert_eq!(source.git_ref, GitRef::Tag("v1.0.0".to_string()));
560 assert_eq!(source.subdirectory, None);
561
562 let source =
564 GitPluginSource::parse("https://github.com/user/repo#main:plugins/auth").unwrap();
565 assert_eq!(source.url, "https://github.com/user/repo");
566 assert_eq!(source.git_ref, GitRef::Branch("main".to_string()));
567 assert_eq!(source.subdirectory, Some("plugins/auth".to_string()));
568 }
569
570 #[test]
571 fn test_git_ref_display() {
572 assert_eq!(GitRef::Tag("v1.0.0".to_string()).to_string(), "tag:v1.0.0");
573 assert_eq!(GitRef::Branch("main".to_string()).to_string(), "branch:main");
574 assert_eq!(GitRef::Commit("abc123".to_string()).to_string(), "commit:abc123");
575 assert_eq!(GitRef::Default.to_string(), "default");
576 }
577
578 #[test]
579 fn test_git_plugin_source_display() {
580 let source = GitPluginSource {
581 url: "https://github.com/user/repo".to_string(),
582 git_ref: GitRef::Tag("v1.0.0".to_string()),
583 subdirectory: Some("plugins".to_string()),
584 };
585 assert_eq!(source.to_string(), "https://github.com/user/repo#tag:v1.0.0:plugins");
586 }
587}