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 = if is_dir {
105                // Handle directory syncing
106                if dest_path.exists() {
107                    // Both exist - compare directories
108                    let dir_comparison =
109                        DirectoryComparator::compare(&file.path, &dest_path)?;
110
111                    if dir_comparison.is_identical() {
112                        SyncAction::Skip {
113                            path: file.path.clone(),
114                            reason: "identical content".to_string(),
115                        }
116                    } else {
117                        // Directories differ - check if source is newer
118                        let source_newer =
119                            DirectoryComparator::is_source_newer(&file.path, &dest_path)?;
120                        SyncAction::DirectoryConflict {
121                            source: file.path.clone(),
122                            dest: dest_path,
123                            strategy: conflict_strategy,
124                            source_newer,
125                        }
126                    }
127                } else {
128                    // Destination doesn't exist - create it
129                    SyncAction::CreateDirectory {
130                        source: file.path.clone(),
131                        dest: dest_path,
132                    }
133                }
134            } else {
135                // Handle file syncing (existing logic)
136                let comparison =
137                    FileComparator::compare(&file.path, &dest_path, conflict_strategy)?;
138                SyncActionResolver::resolve(file.path.clone(), dest_path, &comparison)
139            };
140
141            // Skip actions don't need approval (they're automatic decisions)
142            if matches!(action, super::actions::SyncAction::Skip { .. }) {
143                if let Err(e) = executor.execute(&action, &mut result) {
144                    eprintln!("Error: {e}");
145                    result.errors.push(e.to_string());
146                }
147                continue;
148            }
149
150            // Check approval if callback provided (only for Create and Conflict actions)
151            if let Some(ref mut approve) = approver {
152                match approve(&action) {
153                    Ok(true) => {
154                        // Approved - continue to execution
155                    }
156                    Ok(false) => {
157                        // Skipped by user
158                        result.skipped += 1;
159                        *result
160                            .skip_reasons
161                            .entry("user skipped".to_string())
162                            .or_insert(0) += 1;
163                        continue;
164                    }
165                    Err(e) => {
166                        // User aborted or error in approval
167                        return Err(e);
168                    }
169                }
170            }
171
172            // Execute action
173            if let Err(e) = executor.execute(&action, &mut result) {
174                eprintln!("Error: {e}");
175                result.errors.push(e.to_string());
176            }
177        }
178
179        // Log warnings from scanner
180        for warning in &scan_result.warnings {
181            eprintln!("Warning: {warning}");
182        }
183
184        // Fail fast if any errors occurred
185        if !result.errors.is_empty() {
186            anyhow::bail!(
187                "Sync failed with {} error(s):\n  - {}",
188                result.errors.len(),
189                result.errors.join("\n  - ")
190            );
191        }
192
193        Ok(result)
194    }
195
196    /// Get conflict strategy from config or use default
197    const fn get_conflict_strategy(&self) -> ConflictStrategy {
198        match self.config.conflict_strategy {
199            Some(strategy) => strategy,
200            None => ConflictStrategy::Fail,
201        }
202    }
203}