ccsync_core/sync/
orchestrator.rs

1//! Sync orchestration - coordinates the sync workflow
2
3use std::path::Path;
4
5use anyhow::Context;
6
7use super::SyncResult;
8use super::actions::{SyncAction, SyncActionResolver};
9use super::executor::FileOperationExecutor;
10use crate::comparison::{ConflictStrategy, DirectoryComparator, FileComparator};
11use crate::config::{Config, PatternMatcher, SyncDirection};
12use crate::error::Result;
13use crate::scanner::{FileFilter, Scanner};
14
15/// Approval callback for interactive sync operations
16pub type ApprovalCallback = Box<dyn FnMut(&SyncAction) -> Result<bool>>;
17
18/// Main sync engine
19pub struct SyncEngine {
20    config: Config,
21    /// Sync direction (currently unused, will be used for direction-specific rules and reporting)
22    #[allow(dead_code)]
23    direction: SyncDirection,
24    pattern_matcher: Option<PatternMatcher>,
25}
26
27impl SyncEngine {
28    /// Create a new sync engine
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if pattern compilation fails.
33    pub fn new(config: Config, direction: SyncDirection) -> Result<Self> {
34        // Compile pattern matcher once during construction
35        let pattern_matcher = if !config.ignore.is_empty() || !config.include.is_empty() {
36            Some(PatternMatcher::with_patterns(
37                &config.ignore,
38                &config.include,
39            )?)
40        } else {
41            None
42        };
43
44        Ok(Self {
45            config,
46            direction,
47            pattern_matcher,
48        })
49    }
50
51    /// Execute the sync operation
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if sync fails.
56    pub fn sync(&self, source_root: &Path, dest_root: &Path) -> Result<SyncResult> {
57        self.sync_with_approver(source_root, dest_root, None)
58    }
59
60    /// Execute the sync operation with an optional approval callback
61    ///
62    /// The approver callback is called before executing each action.
63    /// It should return Ok(true) to proceed, Ok(false) to skip, or Err to abort.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if sync fails or approver returns an error.
68    pub fn sync_with_approver(
69        &self,
70        source_root: &Path,
71        dest_root: &Path,
72        mut approver: Option<ApprovalCallback>,
73    ) -> Result<SyncResult> {
74        let mut result = SyncResult::default();
75
76        // Scan source directory
77        let filter = FileFilter::new();
78        let scanner = Scanner::new(filter, self.config.preserve_symlinks == Some(true));
79        let scan_result = scanner.scan(source_root);
80
81        // Process each scanned file
82        let executor = FileOperationExecutor::new(self.config.dry_run == Some(true));
83        let conflict_strategy = self.get_conflict_strategy();
84
85        for file in &scan_result.files {
86            // Get relative path first (needed for pattern matching)
87            let rel_path = file
88                .path
89                .strip_prefix(source_root)
90                .with_context(|| format!("Failed to strip prefix from {}", file.path.display()))?;
91
92            // Apply pattern filter to relative path
93            let is_dir = file.path.is_dir();
94            if let Some(ref matcher) = self.pattern_matcher
95                && !matcher.should_include(rel_path, is_dir)
96            {
97                result.skipped += 1;
98                continue;
99            }
100
101            let dest_path = dest_root.join(rel_path);
102
103            // Determine action based on whether it's a file or directory
104            let action = Self::determine_sync_action(&file.path, &dest_path, is_dir, conflict_strategy)?;
105
106            // Skip actions don't need approval (they're automatic decisions)
107            if matches!(action, super::actions::SyncAction::Skip { .. }) {
108                if let Err(e) = executor.execute(&action, &mut result) {
109                    eprintln!("Error: {e}");
110                    result.errors.push(e.to_string());
111                }
112                continue;
113            }
114
115            // Check approval if callback provided (only for Create and Conflict actions)
116            match Self::apply_approval(&action, &mut approver, &mut result) {
117                Ok(Some(action_to_execute)) => {
118                    // Execute action
119                    if let Err(e) = executor.execute(&action_to_execute, &mut result) {
120                        eprintln!("Error: {e}");
121                        result.errors.push(e.to_string());
122                    }
123                }
124                Ok(None) => {
125                    // User skipped - move to next file
126                }
127                Err(e) => {
128                    // User aborted or error in approval
129                    return Err(e);
130                }
131            }
132        }
133
134        // Log warnings from scanner
135        for warning in &scan_result.warnings {
136            eprintln!("Warning: {warning}");
137        }
138
139        // Fail fast if any errors occurred
140        if !result.errors.is_empty() {
141            anyhow::bail!(
142                "Sync failed with {} error(s):\n  - {}",
143                result.errors.len(),
144                result.errors.join("\n  - ")
145            );
146        }
147
148        Ok(result)
149    }
150
151    /// Get conflict strategy from config or use default
152    const fn get_conflict_strategy(&self) -> ConflictStrategy {
153        match self.config.conflict_strategy {
154            Some(strategy) => strategy,
155            None => ConflictStrategy::Fail,
156        }
157    }
158
159    /// Determine the sync action for a file or directory
160    fn determine_sync_action(
161        source_path: &Path,
162        dest_path: &Path,
163        is_dir: bool,
164        conflict_strategy: ConflictStrategy,
165    ) -> Result<SyncAction> {
166        if is_dir {
167            // Handle directory syncing
168            if dest_path.exists() {
169                // Both exist - compare directories
170                let dir_comparison = DirectoryComparator::compare(source_path, dest_path)?;
171
172                if dir_comparison.is_identical() {
173                    Ok(SyncAction::Skip {
174                        path: source_path.to_path_buf(),
175                        reason: "identical content".to_string(),
176                    })
177                } else {
178                    // Directories differ - check if source is newer
179                    let source_newer = DirectoryComparator::is_source_newer(source_path, dest_path)?;
180                    Ok(SyncAction::DirectoryConflict {
181                        source: source_path.to_path_buf(),
182                        dest: dest_path.to_path_buf(),
183                        strategy: conflict_strategy,
184                        source_newer,
185                    })
186                }
187            } else {
188                // Destination doesn't exist - create it
189                Ok(SyncAction::CreateDirectory {
190                    source: source_path.to_path_buf(),
191                    dest: dest_path.to_path_buf(),
192                })
193            }
194        } else {
195            // Handle file syncing
196            let comparison = FileComparator::compare(source_path, dest_path, conflict_strategy)?;
197            Ok(SyncActionResolver::resolve(
198                source_path.to_path_buf(),
199                dest_path.to_path_buf(),
200                &comparison,
201            ))
202        }
203    }
204
205    /// Apply approval logic to a sync action
206    /// Returns Ok(Some(action)) if approved, Ok(None) if user skipped, or Err if aborted
207    fn apply_approval(
208        action: &SyncAction,
209        approver: &mut Option<ApprovalCallback>,
210        result: &mut SyncResult,
211    ) -> Result<Option<SyncAction>> {
212        if let Some(approve) = approver {
213            match approve(action) {
214                Ok(true) => {
215                    // Approved - if this is a Fail conflict, treat as Overwrite
216                    Ok(Some(match action {
217                        SyncAction::Conflict {
218                            source,
219                            dest,
220                            strategy: ConflictStrategy::Fail,
221                            source_newer,
222                        } => SyncAction::Conflict {
223                            source: source.clone(),
224                            dest: dest.clone(),
225                            strategy: ConflictStrategy::Overwrite,
226                            source_newer: *source_newer,
227                        },
228                        SyncAction::DirectoryConflict {
229                            source,
230                            dest,
231                            strategy: ConflictStrategy::Fail,
232                            source_newer,
233                        } => SyncAction::DirectoryConflict {
234                            source: source.clone(),
235                            dest: dest.clone(),
236                            strategy: ConflictStrategy::Overwrite,
237                            source_newer: *source_newer,
238                        },
239                        _ => action.clone(),
240                    }))
241                }
242                Ok(false) => {
243                    // Skipped by user
244                    result.skipped += 1;
245                    *result
246                        .skip_reasons
247                        .entry("user skipped".to_string())
248                        .or_insert(0) += 1;
249                    Ok(None)
250                }
251                Err(e) => Err(e),
252            }
253        } else {
254            Ok(Some(action.clone()))
255        }
256    }
257}