1use std::path::{Path, PathBuf};
5
6use std::collections::{HashMap, HashSet};
9use rayon::prelude::*;
10
11use crate::filter::Filter;
12
13use crate::index::{Manifest, RIPSYNC_DIR};
14use crate::report::{Event, NullReporter, Reporter, RunPhase};
15use crate::walk::{Entry, EntryKind, walk_controlled};
16use crate::{Error, Result, RunControl};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Action {
21 Copy,
23 Update,
25 Skip,
27}
28
29#[derive(Debug, Clone)]
31pub struct PlannedAction {
32 pub entry: Entry,
34 pub action: Action,
36}
37
38#[derive(Debug, Clone)]
40pub struct Deletion {
41 pub rel: PathBuf,
43 pub is_dir: bool,
45}
46
47#[derive(Debug, Clone, Copy, Default)]
49#[allow(clippy::struct_excessive_bools)]
50pub struct PlanOptions {
51 pub checksum: bool,
53 pub delete: bool,
55 pub threads: usize,
57 pub index: bool,
59 pub hard_links: bool,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct SyncPlan {
66 pub actions: Vec<PlannedAction>,
68 pub deletions: Vec<Deletion>,
70}
71
72impl SyncPlan {
73 #[must_use]
75 pub fn count(&self, action: Action) -> usize {
76 self.actions.iter().filter(|a| a.action == action).count()
77 }
78
79 #[must_use]
81 pub fn bytes_to_transfer(&self) -> u64 {
82 self.actions
83 .iter()
84 .filter(|a| a.action != Action::Skip && a.entry.is_file())
85 .map(|a| a.entry.len)
86 .sum()
87 }
88}
89
90fn file_hash(path: &Path) -> Option<[u8; 32]> {
93 const MMAP_HASH_THRESHOLD: u64 = 16 * 1024 * 1024;
94 let len = std::fs::metadata(path).ok()?.len();
95 let mut hasher = blake3::Hasher::new();
96 if len >= MMAP_HASH_THRESHOLD {
97 hasher.update_mmap_rayon(path).ok()?;
98 } else {
99 let bytes = std::fs::read(path).ok()?;
100 hasher.update(&bytes);
101 }
102 Some(*hasher.finalize().as_bytes())
103}
104
105fn files_differ(
107 src_root: &Path,
108 dst_root: &Path,
109 entry: &Entry,
110 dst: &Entry,
111 checksum: bool,
112) -> bool {
113 let metadata_differs = entry.mode & 0o7777 != dst.mode & 0o7777
114 || entry.mtime.unix_seconds() != dst.mtime.unix_seconds();
115 if checksum {
116 let sp = src_root.join(&entry.rel);
117 let dp = dst_root.join(&dst.rel);
118 match (file_hash(&sp), file_hash(&dp)) {
119 (Some(a), Some(b)) => metadata_differs || a != b,
120 _ => true,
121 }
122 } else {
123 entry.len != dst.len || metadata_differs
125 }
126}
127
128pub fn build_plan(
136 src: &Path,
137 dst: &Path,
138 opts: PlanOptions,
139 filter: &Filter,
140) -> Result<SyncPlan> {
141 build_plan_controlled(
142 src,
143 dst,
144 opts,
145 filter,
146 &RunControl::default(),
147 &NullReporter,
148 )
149}
150
151pub fn build_plan_controlled<R: Reporter>(
157 src: &Path,
158 dst: &Path,
159 opts: PlanOptions,
160 filter: &Filter,
161 control: &RunControl,
162 reporter: &R,
163) -> Result<SyncPlan> {
164 reporter.event(Event::Phase(RunPhase::Planning));
165 control.checkpoint()?;
166 let mut src_entries = walk_controlled(src, opts.threads, filter, control)?;
167 control.checkpoint()?;
168 reporter.event(Event::PlanningProgress {
169 entries: src_entries.len(),
170 });
171 src_entries.retain(|e| !e.rel.starts_with(RIPSYNC_DIR));
173
174 if opts.delete && src_entries.is_empty() {
176 return Err(Error::EmptySource(src.to_path_buf()));
177 }
178
179 if opts.index && !opts.delete {
181 if let Some(manifest) = Manifest::load(dst) {
182 return Ok(plan_from_manifest(src, dst, &src_entries, &manifest, opts));
183 }
184 }
185
186 let mut dst_entries = if dst.exists() {
187 walk_controlled(dst, opts.threads, filter, control)?
188 } else {
189 Vec::new()
190 };
191 control.checkpoint()?;
192 reporter.event(Event::PlanningProgress {
193 entries: src_entries.len() + dst_entries.len(),
194 });
195 dst_entries.retain(|e| !e.rel.starts_with(RIPSYNC_DIR));
196 let dst_map: HashMap<&Path, &Entry> =
197 dst_entries.iter().map(|e| (e.rel.as_path(), e)).collect();
198
199 let mut actions: Vec<PlannedAction> = src_entries
202 .par_iter()
203 .map(|entry| {
204 let action = match dst_map.get(entry.rel.as_path()) {
205 None => Action::Copy,
206 Some(dst_entry) => classify_existing(src, dst, entry, dst_entry, opts.checksum),
207 };
208 PlannedAction {
209 entry: entry.clone(),
210 action,
211 }
212 })
213 .collect();
214 if opts.hard_links {
215 enforce_hardlink_actions(&mut actions, |rel| {
216 dst_map.get(rel).map(|entry| (entry.dev, entry.ino))
217 });
218 }
219
220 let mut deletions = Vec::new();
221 if opts.delete {
222 let src_set: HashSet<&Path> = src_entries.iter().map(|e| e.rel.as_path()).collect();
223 for d in &dst_entries {
224 if !src_set.contains(d.rel.as_path()) {
225 deletions.push(Deletion {
226 rel: d.rel.clone(),
227 is_dir: d.is_dir(),
228 });
229 }
230 }
231 deletions.sort_by(|a, b| b.rel.cmp(&a.rel));
233 }
234
235 Ok(SyncPlan { actions, deletions })
236}
237
238fn plan_from_manifest(
242 src: &Path,
243 dst: &Path,
244 src_entries: &[Entry],
245 manifest: &Manifest,
246 opts: PlanOptions,
247) -> SyncPlan {
248 let mut actions: Vec<PlannedAction> = src_entries
249 .par_iter()
250 .map(|entry| PlannedAction {
251 entry: entry.clone(),
252 action: manifest.classify(entry, opts.checksum, src, dst),
253 })
254 .collect();
255 if opts.hard_links {
256 enforce_hardlink_actions(&mut actions, |rel| {
257 crate::meta::meta_min(&dst.join(rel))
258 .ok()
259 .map(|entry| (entry.dev, entry.ino))
260 });
261 }
262
263 let mut deletions = Vec::new();
264 if opts.delete {
265 let src_set: HashSet<&Path> = src_entries.iter().map(|e| e.rel.as_path()).collect();
266 for (rel, rec) in &manifest.entries {
267 if !src_set.contains(rel.as_path()) {
268 deletions.push(Deletion {
269 rel: rel.clone(),
270 is_dir: rec.kind == crate::index::Kind::Dir,
271 });
272 }
273 }
274 deletions.sort_by(|a, b| b.rel.cmp(&a.rel));
275 }
276
277 SyncPlan { actions, deletions }
278}
279
280fn enforce_hardlink_actions(
283 actions: &mut [PlannedAction],
284 destination_identity: impl Fn(&Path) -> Option<(u64, u64)>,
285) {
286 let mut first: HashMap<(u64, u64), Option<(u64, u64)>> = HashMap::new();
287 for planned in actions {
288 if !planned.entry.is_file() {
289 continue;
290 }
291 let source_id = (planned.entry.dev, planned.entry.ino);
292 if let Some(canonical_destination) = first.get(&source_id) {
293 if destination_identity(&planned.entry.rel) != *canonical_destination {
294 planned.action = Action::Update;
295 }
296 } else {
297 first.insert(source_id, destination_identity(&planned.entry.rel));
298 }
299 }
300}
301
302fn classify_existing(
304 src: &Path,
305 dst: &Path,
306 entry: &Entry,
307 dst_entry: &Entry,
308 checksum: bool,
309) -> Action {
310 match (&entry.kind, &dst_entry.kind) {
311 (EntryKind::Dir, EntryKind::Dir) => {
312 if entry.mode & 0o7777 == dst_entry.mode & 0o7777
313 && entry.mtime.unix_seconds() == dst_entry.mtime.unix_seconds()
314 {
315 Action::Skip
316 } else {
317 Action::Update
318 }
319 }
320 (EntryKind::Symlink(a), EntryKind::Symlink(b)) => {
321 if a == b && entry.mtime.unix_seconds() == dst_entry.mtime.unix_seconds() {
322 Action::Skip
323 } else {
324 Action::Update
325 }
326 }
327 (EntryKind::File, EntryKind::File) => {
328 if files_differ(src, dst, entry, dst_entry, checksum) {
329 Action::Update
330 } else {
331 Action::Skip
332 }
333 }
334 _ => Action::Update,
336 }
337}