use crate::global::discovery::{DiscoveredProject, DiscoveryEngine, DiscoveryError};
use crate::global::registry::{GlobalRegistry, GlobalRegistryError};
use crate::global::{INITIAL_BACKOFF_SECS, MAX_BACKOFF_SECS};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SyncError {
#[error("Registry error: {0}")]
Registry(#[from] GlobalRegistryError),
#[error("Discovery error: {0}")]
Discovery(#[from] DiscoveryError),
#[error("Sync cancelled")]
Cancelled,
}
pub type Result<T> = std::result::Result<T, SyncError>;
#[derive(Debug, Clone)]
pub struct CloneGroup {
pub canonical_id: String,
pub clone_paths: Vec<PathBuf>,
pub similarity_score: f32,
}
#[derive(Debug, Clone, Default)]
pub struct SyncReport {
pub total_discovered: usize,
pub new_projects: usize,
pub updated_projects: usize,
pub clones_detected: Vec<CloneGroup>,
pub skipped_projects: usize,
pub errors: Vec<String>,
}
impl SyncReport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
}
#[must_use]
pub fn is_success(&self) -> bool {
self.errors.is_empty() || self.errors.iter().all(|e| !e.contains("critical"))
}
}
pub struct SyncEngine {
registry: GlobalRegistry,
discovery: DiscoveryEngine,
}
impl SyncEngine {
#[must_use]
pub fn new(registry: GlobalRegistry, discovery: DiscoveryEngine) -> Self {
Self {
registry,
discovery,
}
}
pub fn with_defaults() -> Result<Self> {
let registry = GlobalRegistry::init_default()?;
let discovery = DiscoveryEngine::new();
Ok(Self::new(registry, discovery))
}
pub fn sync(&mut self) -> Result<SyncReport> {
self.sync_with_options(true, true)
}
fn sync_with_options(&mut self, detect_clones: bool, register_new: bool) -> Result<SyncReport> {
let mut report = SyncReport::new();
let discovered = match self.discovery.discover() {
Ok(projects) => projects,
Err(e) => {
report.add_error(format!("Discovery failed: {}", e));
return Ok(report);
}
};
report.total_discovered = discovered.len();
let existing = match self.registry.list_projects() {
Ok(projects) => projects
.into_iter()
.map(|p| (p.unique_id.to_string(), p))
.collect::<HashMap<_, _>>(),
Err(_) => HashMap::new(),
};
let mut fingerprint_groups: HashMap<String, Vec<DiscoveredProject>> = HashMap::new();
for project in discovered {
if !project.is_valid {
report.skipped_projects += 1;
continue;
}
let fp = project.content_fingerprint.clone();
fingerprint_groups
.entry(fp)
.or_default()
.push(project.clone());
let id = project.unique_id.to_string();
if let Some(existing_project) = existing.get(&id) {
if project.file_count != existing_project.file_count
|| project.language != existing_project.language
{
report.updated_projects += 1;
if register_new {
}
}
} else {
report.new_projects += 1;
if register_new {
if let Err(e) = self.registry.register_project(
&project.path,
project.language.clone(),
project.file_count,
&project.content_fingerprint,
) {
report.add_error(format!("Failed to register {}: {}", id, e));
}
}
}
}
if detect_clones {
report.clones_detected = self.detect_clones(&fingerprint_groups);
}
Ok(report)
}
fn detect_clones(&self, groups: &HashMap<String, Vec<DiscoveredProject>>) -> Vec<CloneGroup> {
let mut clone_groups = Vec::new();
for projects in groups.values().filter(|p| p.len() > 1) {
if let Some(first) = projects.first() {
let canonical_id = first.unique_id.to_string();
let clone_paths = projects.iter().map(|p| p.path.clone()).collect();
let similarity_score = 1.0;
clone_groups.push(CloneGroup {
canonical_id,
clone_paths,
similarity_score,
});
}
}
clone_groups
}
#[must_use]
pub const fn registry(&self) -> &GlobalRegistry {
&self.registry
}
pub fn registry_mut(&mut self) -> &mut GlobalRegistry {
&mut self.registry
}
#[must_use]
pub const fn discovery(&self) -> &DiscoveryEngine {
&self.discovery
}
}
pub struct BackgroundSync {
engine: SyncEngine,
backoff_secs: u64,
running: bool,
refresh_requested: bool,
}
impl BackgroundSync {
#[must_use]
pub fn new(engine: SyncEngine) -> Self {
Self {
engine,
backoff_secs: INITIAL_BACKOFF_SECS,
running: false,
refresh_requested: false,
}
}
pub fn with_defaults() -> Result<Self> {
Ok(Self::new(SyncEngine::with_defaults()?))
}
pub fn run(&mut self) -> Result<Vec<SyncReport>> {
self.running = true;
let mut reports = Vec::new();
while self.running {
if self.refresh_requested {
self.backoff_secs = INITIAL_BACKOFF_SECS;
self.refresh_requested = false;
}
match self.engine.sync() {
Ok(report) => {
self.backoff_secs = INITIAL_BACKOFF_SECS;
reports.push(report);
}
Err(_) => {
let new_backoff = self.backoff_secs * 2;
self.backoff_secs = if new_backoff > MAX_BACKOFF_SECS {
MAX_BACKOFF_SECS
} else {
new_backoff
};
}
}
if !self.running {
break;
}
std::thread::sleep(Duration::from_secs(self.backoff_secs));
}
Ok(reports)
}
#[must_use]
pub fn spawn(self) -> std::thread::JoinHandle<Vec<SyncReport>> {
std::thread::spawn(move || {
let mut bg_sync = self;
bg_sync.run().unwrap_or_default()
})
}
pub fn refresh(&mut self) {
self.refresh_requested = true;
}
pub fn stop(&mut self) {
self.running = false;
}
#[must_use]
pub const fn backoff_secs(&self) -> u64 {
self.backoff_secs
}
#[must_use]
pub const fn is_running(&self) -> bool {
self.running
}
#[must_use]
pub const fn calculate_backoff(attempt: u32) -> u64 {
let capped_attempt = if attempt > 20 { 20 } else { attempt };
let delay = INITIAL_BACKOFF_SECS * 2u32.pow(capped_attempt) as u64;
if delay > MAX_BACKOFF_SECS {
MAX_BACKOFF_SECS
} else {
delay
}
}
#[must_use]
pub const fn engine(&self) -> &SyncEngine {
&self.engine
}
pub fn engine_mut(&mut self) -> &mut SyncEngine {
&mut self.engine
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_sync_report_new() {
let report = SyncReport::new();
assert_eq!(report.total_discovered, 0);
assert_eq!(report.new_projects, 0);
assert!(report.is_success());
}
#[test]
fn test_sync_report_add_error() {
let mut report = SyncReport::new();
report.add_error("test error".to_string());
assert!(!report.errors.is_empty());
assert!(report.is_success()); }
#[test]
fn test_sync_report_critical_error() {
let mut report = SyncReport::new();
report.add_error("critical failure".to_string());
assert!(!report.is_success());
}
#[test]
fn test_sync_engine_sync() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let registry = GlobalRegistry::init(&db_path).unwrap();
let project_path = temp_dir.path().join("testproject");
std::fs::create_dir_all(project_path.join(".git")).unwrap();
for i in 0..15 {
std::fs::write(project_path.join(format!("file{}.rs", i)), "").unwrap();
}
let discovery = DiscoveryEngine::with_roots(vec![temp_dir.path().to_path_buf()]);
let mut engine = SyncEngine::new(registry, discovery);
let report = engine.sync().unwrap();
assert_eq!(report.total_discovered, 1);
assert_eq!(report.new_projects, 1);
assert!(report.is_success());
}
#[test]
fn test_calculate_backoff() {
assert_eq!(BackgroundSync::calculate_backoff(0), 1);
assert_eq!(BackgroundSync::calculate_backoff(1), 2);
assert_eq!(BackgroundSync::calculate_backoff(2), 4);
assert_eq!(BackgroundSync::calculate_backoff(3), 8);
assert_eq!(BackgroundSync::calculate_backoff(8), 256);
assert_eq!(BackgroundSync::calculate_backoff(20), MAX_BACKOFF_SECS);
assert_eq!(BackgroundSync::calculate_backoff(100), MAX_BACKOFF_SECS);
}
#[test]
fn test_clone_group_structure() {
let group = CloneGroup {
canonical_id: "test-id".to_string(),
clone_paths: vec![PathBuf::from("/path1"), PathBuf::from("/path2")],
similarity_score: 1.0,
};
assert_eq!(group.canonical_id, "test-id");
assert_eq!(group.clone_paths.len(), 2);
assert_eq!(group.similarity_score, 1.0);
}
}