ccsync_core/
sync.rs

1//! Bidirectional synchronization engine
2//!
3//! This module implements the core sync logic for to-local and to-global operations.
4//! Interactive prompts are NOT implemented here - they will be added in Task 4.
5//! The sync engine uses ConflictStrategy from config/CLI flags directly.
6
7mod actions;
8mod executor;
9mod orchestrator;
10mod reporting;
11
12// Public exports for CLI integration
13pub use actions::SyncAction;
14pub use orchestrator::{ApprovalCallback, SyncEngine};
15pub use reporting::SyncReporter;
16
17/// Synchronization result with statistics
18#[derive(Debug, Clone, Default)]
19pub struct SyncResult {
20    /// Files created
21    pub created: usize,
22    /// Files updated
23    pub updated: usize,
24    /// Files deleted
25    pub deleted: usize,
26    /// Files skipped
27    pub skipped: usize,
28    /// Skip reasons with counts
29    pub skip_reasons: std::collections::HashMap<String, usize>,
30    /// Conflicts encountered
31    pub conflicts: usize,
32    /// Errors encountered
33    pub errors: Vec<String>,
34}
35
36impl SyncResult {
37    /// Total operations performed
38    #[must_use]
39    pub const fn total_operations(&self) -> usize {
40        self.created + self.updated + self.deleted
41    }
42
43    /// Whether sync was successful (no errors)
44    #[must_use]
45    pub const fn is_success(&self) -> bool {
46        self.errors.is_empty()
47    }
48}
49
50#[cfg(test)]
51mod integration_tests {
52    use std::fs;
53    use std::path::Path;
54
55    use tempfile::TempDir;
56
57    use super::*;
58    use crate::comparison::ConflictStrategy;
59    use crate::config::{Config, SyncDirection};
60
61    fn setup_test_dirs() -> (TempDir, TempDir) {
62        let source = TempDir::new().unwrap();
63        let dest = TempDir::new().unwrap();
64        (source, dest)
65    }
66
67    fn create_test_file(dir: &Path, rel_path: &str, content: &str) {
68        let path = dir.join(rel_path);
69        if let Some(parent) = path.parent() {
70            fs::create_dir_all(parent).unwrap();
71        }
72        fs::write(path, content).unwrap();
73    }
74
75    #[test]
76    fn test_sync_create_new_files() {
77        let (source_dir, dest_dir) = setup_test_dirs();
78
79        // Create files in source
80        create_test_file(source_dir.path(), "agents/test.md", "test agent");
81        create_test_file(source_dir.path(), "skills/skill1/SKILL.md", "test skill");
82
83        let config = Config::default();
84        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
85
86        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
87
88        assert_eq!(result.created, 2);
89        assert_eq!(result.updated, 0);
90        assert_eq!(result.skipped, 0);
91        assert!(result.is_success());
92
93        // Verify files were created
94        assert!(dest_dir.path().join("agents/test.md").exists());
95        assert!(dest_dir.path().join("skills/skill1/SKILL.md").exists());
96    }
97
98    #[test]
99    fn test_sync_skip_identical_files() {
100        let (source_dir, dest_dir) = setup_test_dirs();
101
102        // Create identical files in both
103        let content = "identical content";
104        create_test_file(source_dir.path(), "agents/test.md", content);
105        create_test_file(dest_dir.path(), "agents/test.md", content);
106
107        let config = Config::default();
108        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
109
110        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
111
112        assert_eq!(result.created, 0);
113        assert_eq!(result.updated, 0);
114        assert_eq!(result.skipped, 1);
115        assert!(result.is_success());
116    }
117
118    #[test]
119    fn test_sync_with_ignore_patterns() {
120        let (source_dir, dest_dir) = setup_test_dirs();
121
122        create_test_file(source_dir.path(), "agents/include.md", "include");
123        create_test_file(source_dir.path(), "agents/ignore.md", "ignore");
124
125        let mut config = Config::default();
126        config.ignore = vec!["**/ignore.md".to_string()];
127
128        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
129        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
130
131        assert_eq!(result.created, 1);
132        assert_eq!(result.skipped, 1);
133        assert!(dest_dir.path().join("agents/include.md").exists());
134        assert!(!dest_dir.path().join("agents/ignore.md").exists());
135    }
136
137    #[test]
138    fn test_sync_conflict_fail_strategy() {
139        let (source_dir, dest_dir) = setup_test_dirs();
140
141        // Create different content in both
142        create_test_file(source_dir.path(), "agents/test.md", "source content");
143        create_test_file(dest_dir.path(), "agents/test.md", "dest content");
144
145        let config = Config::default(); // Default is Fail
146        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
147
148        // Should fail due to conflict with Fail strategy
149        let result = engine.sync(source_dir.path(), dest_dir.path());
150        assert!(result.is_err());
151
152        let err = result.unwrap_err();
153        let err_msg = err.to_string();
154        assert!(err_msg.contains("Sync failed"));
155        assert!(err_msg.contains("Conflict"));
156    }
157
158    #[test]
159    fn test_sync_dry_run() {
160        let (source_dir, dest_dir) = setup_test_dirs();
161
162        create_test_file(source_dir.path(), "agents/test.md", "test content");
163
164        let mut config = Config::default();
165        config.dry_run = Some(true);
166
167        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
168        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
169
170        // Should report as created
171        assert_eq!(result.created, 1);
172        assert!(result.is_success());
173
174        // But file should NOT actually exist (dry run)
175        assert!(!dest_dir.path().join("agents/test.md").exists());
176    }
177
178    #[test]
179    fn test_sync_bidirectional() {
180        let (dir1, dir2) = setup_test_dirs();
181
182        // Create file in dir1
183        create_test_file(dir1.path(), "agents/from1.md", "content 1");
184
185        let config = Config::default();
186
187        // Sync to dir2
188        let engine = SyncEngine::new(config.clone(), SyncDirection::ToLocal).unwrap();
189        let result = engine.sync(dir1.path(), dir2.path()).unwrap();
190        assert_eq!(result.created, 1);
191
192        // Create file in dir2
193        create_test_file(dir2.path(), "agents/from2.md", "content 2");
194
195        // Sync back to dir1
196        let engine = SyncEngine::new(config, SyncDirection::ToGlobal).unwrap();
197        let result = engine.sync(dir2.path(), dir1.path()).unwrap();
198        assert_eq!(result.created, 1);
199
200        // Both files should exist in both directories
201        assert!(dir1.path().join("agents/from1.md").exists());
202        assert!(dir1.path().join("agents/from2.md").exists());
203        assert!(dir2.path().join("agents/from1.md").exists());
204        assert!(dir2.path().join("agents/from2.md").exists());
205    }
206
207    #[test]
208    fn test_sync_update_existing_files() {
209        let (source_dir, dest_dir) = setup_test_dirs();
210
211        // Create initial identical files
212        create_test_file(source_dir.path(), "agents/test.md", "v1");
213        create_test_file(dest_dir.path(), "agents/test.md", "v1");
214
215        // Update source file
216        create_test_file(source_dir.path(), "agents/test.md", "v2");
217
218        let mut config = Config::default();
219        config.conflict_strategy = Some(ConflictStrategy::Overwrite);
220
221        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
222        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
223
224        // Should update the file
225        assert_eq!(result.updated, 1);
226        assert_eq!(result.created, 0);
227        assert!(result.is_success());
228
229        // Verify content was updated
230        let content = fs::read_to_string(dest_dir.path().join("agents/test.md")).unwrap();
231        assert_eq!(content, "v2");
232    }
233
234    #[test]
235    fn test_sync_reporter() {
236        let mut result = SyncResult::default();
237        result.created = 5;
238        result.updated = 3;
239        result.skipped = 2;
240
241        let summary = SyncReporter::generate_summary(&result);
242
243        assert!(summary.contains("Created:  5"));
244        assert!(summary.contains("Updated:  3"));
245        assert!(summary.contains("Skipped:  2"));
246        assert!(summary.contains("Total operations: 8"));
247        assert!(summary.contains("✓ Success"));
248    }
249
250    #[test]
251    fn test_sync_reporter_with_errors() {
252        let mut result = SyncResult::default();
253        result.created = 1;
254        result.errors.push("Test error".to_string());
255
256        let summary = SyncReporter::generate_summary(&result);
257
258        assert!(summary.contains("Errors (1)"));
259        assert!(summary.contains("Test error"));
260        assert!(summary.contains("✗ Completed with errors"));
261        assert!(!result.is_success());
262    }
263
264    #[test]
265    fn test_sync_pattern_matching_with_relative_paths() {
266        let (source_dir, dest_dir) = setup_test_dirs();
267
268        // Create multiple files with git-* pattern in agents/
269        create_test_file(source_dir.path(), "agents/git-commit.md", "git commit agent");
270        create_test_file(source_dir.path(), "agents/git-helper.md", "git helper agent");
271        create_test_file(source_dir.path(), "agents/other-agent.md", "other agent");
272        // Create a skill (skills/test-skill/SKILL.md is the expected structure)
273        create_test_file(source_dir.path(), "skills/test-skill/SKILL.md", "test skill");
274
275        // Configure to ignore agents/git-* pattern (relative path)
276        let mut config = Config::default();
277        config.ignore = vec!["agents/git-*".to_string()];
278
279        let engine = SyncEngine::new(config, SyncDirection::ToLocal).unwrap();
280        let result = engine.sync(source_dir.path(), dest_dir.path()).unwrap();
281
282        // Should create 2 files (other-agent.md and skills/test-skill/SKILL.md)
283        // Should skip 2 files (git-commit.md and git-helper.md)
284        assert_eq!(result.created, 2);
285        assert_eq!(result.skipped, 2);
286        assert!(result.is_success());
287
288        // Verify git-* files were NOT created
289        assert!(!dest_dir.path().join("agents/git-commit.md").exists());
290        assert!(!dest_dir.path().join("agents/git-helper.md").exists());
291
292        // Verify other files WERE created
293        assert!(dest_dir.path().join("agents/other-agent.md").exists());
294        assert!(dest_dir.path().join("skills/test-skill/SKILL.md").exists());
295    }
296}