use std::sync::Arc;
use crate::adapters::{ForgePort, ForgeResult};
use crate::types::Repo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncOp {
Create,
Update,
Delete,
InSync,
}
#[derive(Debug, Clone)]
pub struct RepoOp {
pub repo: Repo,
pub op: SyncOp,
}
#[derive(Debug, Clone)]
pub struct SyncDiff {
pub org: String,
pub ops: Vec<RepoOp>,
}
impl SyncDiff {
pub fn to_create(&self) -> Vec<&Repo> {
self.ops
.iter()
.filter(|op| op.op == SyncOp::Create)
.map(|op| &op.repo)
.collect()
}
pub fn to_update(&self) -> Vec<&Repo> {
self.ops
.iter()
.filter(|op| op.op == SyncOp::Update)
.map(|op| &op.repo)
.collect()
}
pub fn to_delete(&self) -> Vec<&Repo> {
self.ops
.iter()
.filter(|op| op.op == SyncOp::Delete)
.map(|op| &op.repo)
.collect()
}
pub fn in_sync(&self) -> Vec<&Repo> {
self.ops
.iter()
.filter(|op| op.op == SyncOp::InSync)
.map(|op| &op.repo)
.collect()
}
pub fn has_changes(&self) -> bool {
self.ops.iter().any(|op| op.op != SyncOp::InSync)
}
}
pub struct SymmetricSyncService;
impl SymmetricSyncService {
pub fn new() -> Self {
Self
}
pub async fn diff(
&self,
source: Arc<dyn ForgePort>,
target: Arc<dyn ForgePort>,
org: &str,
) -> ForgeResult<SyncDiff> {
let source_repos = source.list_repos(org).await?;
let target_repos = target.list_repos(org).await?;
let mut target_map: std::collections::HashMap<String, Repo> = target_repos
.into_iter()
.map(|r| (r.name.clone(), r))
.collect();
let mut ops = Vec::new();
for source_repo in source_repos {
if let Some(target_repo) = target_map.remove(&source_repo.name) {
if repos_differ(&source_repo, &target_repo) {
ops.push(RepoOp {
repo: source_repo,
op: SyncOp::Update,
});
} else {
ops.push(RepoOp {
repo: source_repo,
op: SyncOp::InSync,
});
}
} else {
ops.push(RepoOp {
repo: source_repo,
op: SyncOp::Create,
});
}
}
for (_, target_repo) in target_map {
ops.push(RepoOp {
repo: target_repo,
op: SyncOp::Delete,
});
}
Ok(SyncDiff {
org: org.to_string(),
ops,
})
}
pub async fn sync(
&self,
source: Arc<dyn ForgePort>,
target: Arc<dyn ForgePort>,
org: &str,
dry_run: bool,
) -> ForgeResult<SyncDiff> {
let diff = self.diff(source.clone(), target.clone(), org).await?;
if dry_run {
return Ok(diff);
}
for op in &diff.ops {
match op.op {
SyncOp::Create => {
target.create_repo(org, &op.repo).await?;
}
SyncOp::Update => {
target.update_repo(org, &op.repo).await?;
}
SyncOp::Delete => {
target.delete_repo(org, &op.repo.name).await?;
}
SyncOp::InSync => {
}
}
}
Ok(diff)
}
pub async fn sync_with_origins(
&self,
source: Arc<dyn ForgePort>,
forges: std::collections::HashMap<String, Arc<dyn ForgePort>>,
org: &str,
dry_run: bool,
) -> ForgeResult<Vec<SyncDiff>> {
use crate::adapters::LocalForge;
use crate::types::Forge;
let all_repos = source.list_repos(org).await?;
let mut diffs = Vec::new();
for (forge_name, forge_adapter) in forges {
let forge_type = match forge_name.to_lowercase().as_str() {
"github" => Forge::GitHub,
"codeberg" => Forge::Codeberg,
"gitlab" => Forge::GitLab,
_ => continue, };
let repos_for_forge: Vec<_> = all_repos
.iter()
.filter(|r| {
r.origin == forge_type || r.mirrors.contains(&forge_type)
})
.cloned()
.collect();
if repos_for_forge.is_empty() {
continue; }
let filtered_local = Arc::new(LocalForge::new(org));
for repo in repos_for_forge {
if let Err(e) = filtered_local.create_repo(org, &repo).await {
eprintln!("Failed to add repo {} to filtered forge: {}", repo.name, e);
}
}
let diff = self
.sync(filtered_local, forge_adapter, org, dry_run)
.await?;
diffs.push(diff);
}
Ok(diffs)
}
}
impl Default for SymmetricSyncService {
fn default() -> Self {
Self::new()
}
}
fn repos_differ(a: &Repo, b: &Repo) -> bool {
a.description != b.description || a.visibility != b.visibility
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::LocalForge;
use crate::types::{Forge, Visibility};
#[tokio::test]
async fn test_diff_empty_forges() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let diff = service.diff(source, target, "testorg").await.unwrap();
assert_eq!(diff.ops.len(), 0);
assert!(!diff.has_changes());
}
#[tokio::test]
async fn test_diff_create_needed() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("new-repo", Forge::GitHub);
source.create_repo("testorg", &repo).await.unwrap();
let diff = service.diff(source, target, "testorg").await.unwrap();
assert_eq!(diff.to_create().len(), 1);
assert_eq!(diff.to_create()[0].name, "new-repo");
assert!(diff.has_changes());
}
#[tokio::test]
async fn test_diff_update_needed() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo_source = Repo::new("test-repo", Forge::GitHub)
.with_description("New description");
let repo_target = Repo::new("test-repo", Forge::GitHub)
.with_description("Old description");
source.create_repo("testorg", &repo_source).await.unwrap();
target.create_repo("testorg", &repo_target).await.unwrap();
let diff = service.diff(source, target, "testorg").await.unwrap();
assert_eq!(diff.to_update().len(), 1);
assert_eq!(diff.to_update()[0].name, "test-repo");
assert!(diff.has_changes());
}
#[tokio::test]
async fn test_diff_delete_needed() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("old-repo", Forge::GitHub);
target.create_repo("testorg", &repo).await.unwrap();
let diff = service.diff(source, target, "testorg").await.unwrap();
assert_eq!(diff.to_delete().len(), 1);
assert_eq!(diff.to_delete()[0].name, "old-repo");
assert!(diff.has_changes());
}
#[tokio::test]
async fn test_diff_in_sync() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("synced-repo", Forge::GitHub)
.with_description("Same description");
source.create_repo("testorg", &repo).await.unwrap();
target.create_repo("testorg", &repo).await.unwrap();
let diff = service.diff(source, target, "testorg").await.unwrap();
assert_eq!(diff.in_sync().len(), 1);
assert_eq!(diff.in_sync()[0].name, "synced-repo");
assert!(!diff.has_changes());
}
#[tokio::test]
async fn test_sync_creates_repos() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("new-repo", Forge::GitHub);
source.create_repo("testorg", &repo).await.unwrap();
let diff = service
.sync(source.clone(), target.clone(), "testorg", false)
.await
.unwrap();
assert_eq!(diff.to_create().len(), 1);
let target_repo = target.get_repo("testorg", "new-repo").await.unwrap();
assert_eq!(target_repo.name, "new-repo");
}
#[tokio::test]
async fn test_sync_dry_run() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let target = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("new-repo", Forge::GitHub);
source.create_repo("testorg", &repo).await.unwrap();
let diff = service
.sync(source.clone(), target.clone(), "testorg", true)
.await
.unwrap();
assert_eq!(diff.to_create().len(), 1);
let result = target.get_repo("testorg", "new-repo").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_repos_differ_description() {
let repo1 = Repo::new("test", Forge::GitHub).with_description("Desc 1");
let repo2 = Repo::new("test", Forge::GitHub).with_description("Desc 2");
assert!(repos_differ(&repo1, &repo2));
}
#[tokio::test]
async fn test_repos_differ_visibility() {
let repo1 = Repo::new("test", Forge::GitHub).with_visibility(Visibility::Public);
let repo2 = Repo::new("test", Forge::GitHub).with_visibility(Visibility::Private);
assert!(repos_differ(&repo1, &repo2));
}
#[tokio::test]
async fn test_repos_same() {
let repo1 = Repo::new("test", Forge::GitHub).with_description("Same");
let repo2 = Repo::new("test", Forge::GitHub).with_description("Same");
assert!(!repos_differ(&repo1, &repo2));
}
#[tokio::test]
async fn test_sync_with_origins_filters_by_forge() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let repo1 = Repo::new("github-only", Forge::GitHub);
let repo2 = Repo::new("codeberg-only", Forge::Codeberg);
let repo3 = Repo::new("github-mirrored", Forge::GitHub)
.with_mirror(Forge::Codeberg);
source.create_repo("testorg", &repo1).await.unwrap();
source.create_repo("testorg", &repo2).await.unwrap();
source.create_repo("testorg", &repo3).await.unwrap();
let github_target = Arc::new(LocalForge::new("testorg"));
let codeberg_target = Arc::new(LocalForge::new("testorg"));
let mut forges: std::collections::HashMap<String, Arc<dyn ForgePort>> = std::collections::HashMap::new();
forges.insert("github".to_string(), github_target.clone());
forges.insert("codeberg".to_string(), codeberg_target.clone());
let diffs = service
.sync_with_origins(source, forges, "testorg", false)
.await
.unwrap();
assert_eq!(diffs.len(), 2);
let github_repos = github_target.list_repos("testorg").await.unwrap();
assert_eq!(github_repos.len(), 2);
let github_names: Vec<_> = github_repos.iter().map(|r| r.name.as_str()).collect();
assert!(github_names.contains(&"github-only"));
assert!(github_names.contains(&"github-mirrored"));
let codeberg_repos = codeberg_target.list_repos("testorg").await.unwrap();
assert_eq!(codeberg_repos.len(), 2);
let codeberg_names: Vec<_> = codeberg_repos.iter().map(|r| r.name.as_str()).collect();
assert!(codeberg_names.contains(&"codeberg-only"));
assert!(codeberg_names.contains(&"github-mirrored"));
}
#[tokio::test]
async fn test_sync_with_origins_respects_mirrors() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let repo = Repo::new("multi-mirror", Forge::GitHub)
.with_mirrors(vec![Forge::Codeberg, Forge::GitLab]);
source.create_repo("testorg", &repo).await.unwrap();
let github_target = Arc::new(LocalForge::new("testorg"));
let codeberg_target = Arc::new(LocalForge::new("testorg"));
let gitlab_target = Arc::new(LocalForge::new("testorg"));
let mut forges: std::collections::HashMap<String, Arc<dyn ForgePort>> = std::collections::HashMap::new();
forges.insert("github".to_string(), github_target.clone());
forges.insert("codeberg".to_string(), codeberg_target.clone());
forges.insert("gitlab".to_string(), gitlab_target.clone());
service
.sync_with_origins(source, forges, "testorg", false)
.await
.unwrap();
assert_eq!(github_target.list_repos("testorg").await.unwrap().len(), 1);
assert_eq!(codeberg_target.list_repos("testorg").await.unwrap().len(), 1);
assert_eq!(gitlab_target.list_repos("testorg").await.unwrap().len(), 1);
}
#[tokio::test]
async fn test_sync_with_origins_no_cross_contamination() {
let service = SymmetricSyncService::new();
let source = Arc::new(LocalForge::new("testorg"));
let repo1 = Repo::new("github-repo", Forge::GitHub);
let repo2 = Repo::new("codeberg-repo", Forge::Codeberg);
source.create_repo("testorg", &repo1).await.unwrap();
source.create_repo("testorg", &repo2).await.unwrap();
let github_target = Arc::new(LocalForge::new("testorg"));
let codeberg_target = Arc::new(LocalForge::new("testorg"));
let mut forges: std::collections::HashMap<String, Arc<dyn ForgePort>> = std::collections::HashMap::new();
forges.insert("github".to_string(), github_target.clone());
forges.insert("codeberg".to_string(), codeberg_target.clone());
service
.sync_with_origins(source, forges, "testorg", false)
.await
.unwrap();
let github_repos = github_target.list_repos("testorg").await.unwrap();
assert_eq!(github_repos.len(), 1);
assert_eq!(github_repos[0].name, "github-repo");
let codeberg_repos = codeberg_target.list_repos("testorg").await.unwrap();
assert_eq!(codeberg_repos.len(), 1);
assert_eq!(codeberg_repos[0].name, "codeberg-repo");
}
}