1use std::time::Duration;
15
16use super::{ProgressPhase, ProgressUnit, ReceivedObjects};
17
18#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct PhaseRates {
24 pub indexing: f64,
26 pub resolving: f64,
28 pub checkout: f64,
30}
31
32impl Default for PhaseRates {
33 fn default() -> Self {
36 Self {
37 indexing: 250_000.0,
38 resolving: 300_000.0,
39 checkout: 10_000.0,
40 }
41 }
42}
43
44const ASSUMED_DELTA_SHARE: f64 = 0.7;
46const ASSUMED_FILES_PER_OBJECT: f64 = 0.015;
48const TRUST_MEASURED_AFTER: f64 = 0.1;
51const MAX_UNFINISHED_FRACTION: f64 = 0.99;
53
54#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct OverallEstimate {
57 pub fraction: f64,
60 pub remaining: Duration,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66enum Stage {
67 Transfer,
68 Indexing,
69 Resolving,
70 Checkout,
71}
72
73const STAGES: [Stage; 4] = [
74 Stage::Transfer,
75 Stage::Indexing,
76 Stage::Resolving,
77 Stage::Checkout,
78];
79
80impl Stage {
81 fn of(phase: ProgressPhase) -> Option<Self> {
82 match phase {
83 ProgressPhase::ReceivingObjects | ProgressPhase::CopyingObjects => Some(Self::Transfer),
84 ProgressPhase::IndexingObjects => Some(Self::Indexing),
85 ProgressPhase::ResolvingDeltas => Some(Self::Resolving),
86 ProgressPhase::CheckingOutFiles => Some(Self::Checkout),
87 ProgressPhase::TransferringBundle => None,
89 ProgressPhase::StagingFiles => None,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy)]
96struct Active {
97 stage: Stage,
98 total: Option<u64>,
100 current: u64,
101 started: Duration,
102}
103
104#[derive(Debug, Clone)]
123pub struct OverallEstimator {
124 rates: PhaseRates,
125 checkout: bool,
126 objects: Option<u64>,
127 deltas: Option<u64>,
128 files: Option<u64>,
129 received: Option<ReceivedObjects>,
130 active: Option<Active>,
131 finished: Option<Stage>,
133 best: f64,
134}
135
136impl OverallEstimator {
137 #[must_use]
144 pub fn new(checkout: bool) -> Self {
145 Self {
146 rates: PhaseRates::default(),
147 checkout,
148 objects: None,
149 deltas: None,
150 files: None,
151 received: None,
152 active: None,
153 finished: None,
154 best: 0.0,
155 }
156 }
157
158 #[must_use]
160 pub fn with_rates(mut self, rates: PhaseRates) -> Self {
161 self.rates = rates;
162 self
163 }
164
165 pub fn start(
169 &mut self,
170 phase: ProgressPhase,
171 unit: ProgressUnit,
172 total: Option<u64>,
173 at: Duration,
174 ) {
175 self.finish(at);
176 let Some(stage) = Stage::of(phase) else {
177 return;
178 };
179 let total = total.filter(|_| unit != ProgressUnit::Bytes);
180 match stage {
181 Stage::Transfer | Stage::Indexing => {
182 if total.is_some() {
183 self.objects = total;
184 }
185 }
186 Stage::Resolving => self.deltas = total,
187 Stage::Checkout => self.files = total,
188 }
189 self.active = Some(Active {
190 stage,
191 total,
192 current: 0,
193 started: at,
194 });
195 }
196
197 pub fn set(&mut self, current: u64) {
199 if let Some(active) = &mut self.active {
200 active.current = current;
201 }
202 }
203
204 pub fn inc(&mut self, units: u64) {
206 if let Some(active) = &mut self.active {
207 active.current = active.current.saturating_add(units);
208 }
209 }
210
211 pub fn received_objects(&mut self, counts: ReceivedObjects, _at: Duration) {
216 self.objects = Some(counts.total);
217 if counts.received > 0 {
218 let share = counts.deltas as f64 / counts.received as f64;
219 self.deltas = Some((share * counts.total as f64).round() as u64);
220 }
221 self.received = Some(counts);
222 }
223
224 pub fn finish(&mut self, _at: Duration) {
226 if let Some(active) = self.active.take() {
227 self.finished = self.finished.max(Some(active.stage));
228 }
229 }
230
231 pub fn estimate(&mut self, at: Duration) -> Option<OverallEstimate> {
234 let remaining = self.remaining_secs(at)?;
235 let elapsed = at.as_secs_f64();
236 let all_done = self.active.is_none() && self.finished == Some(self.last_stage());
237 let raw = if all_done {
238 1.0
239 } else if elapsed + remaining <= 0.0 {
240 0.0
241 } else {
242 (elapsed / (elapsed + remaining)).min(MAX_UNFINISHED_FRACTION)
243 };
244 self.best = self.best.max(raw);
245 Some(OverallEstimate {
246 fraction: self.best,
247 remaining: Duration::from_secs_f64(remaining.max(0.0)),
248 })
249 }
250
251 fn last_stage(&self) -> Stage {
252 if self.checkout {
253 Stage::Checkout
254 } else {
255 Stage::Resolving
256 }
257 }
258
259 fn remaining_secs(&self, at: Duration) -> Option<f64> {
260 let mut total = 0.0;
261 for stage in STAGES {
262 if stage > self.last_stage() || Some(stage) <= self.finished {
263 continue;
264 }
265 match self.active {
266 Some(active) if active.stage == stage => total += self.active_secs(active, at)?,
267 Some(active) if active.stage > stage => {}
269 _ => total += self.pending_secs(stage)?,
270 }
271 }
272 Some(total)
273 }
274
275 fn active_secs(&self, active: Active, at: Duration) -> Option<f64> {
277 let elapsed = at.saturating_sub(active.started).as_secs_f64();
278 let (done, total) = match (active.stage, active.total) {
279 (_, Some(total)) => (active.current, total),
280 (Stage::Transfer, None) => {
281 let counts = self.received?;
282 (counts.received, counts.total)
283 }
284 (_, None) => return self.pending_secs(active.stage),
285 };
286 let left = total.saturating_sub(done) as f64;
287 if left == 0.0 {
288 return Some(0.0);
289 }
290 let share = if total == 0 {
291 1.0
292 } else {
293 done as f64 / total as f64
294 };
295 let typical = self.rate(active.stage).map(|rate| left / rate);
296 if share >= TRUST_MEASURED_AFTER || typical.is_none() {
297 if done == 0 {
298 return None;
299 }
300 return Some(elapsed * left / done as f64);
301 }
302 typical
303 }
304
305 fn pending_secs(&self, stage: Stage) -> Option<f64> {
307 let objects = self.objects? as f64;
308 let units = match stage {
309 Stage::Transfer => return None,
311 Stage::Indexing => objects,
312 Stage::Resolving => self
313 .deltas
314 .map_or(objects * ASSUMED_DELTA_SHARE, |d| d as f64),
315 Stage::Checkout => self
316 .files
317 .map_or(objects * ASSUMED_FILES_PER_OBJECT, |f| f as f64),
318 };
319 Some(units / self.rate(stage)?)
320 }
321
322 fn rate(&self, stage: Stage) -> Option<f64> {
323 let rate = match stage {
324 Stage::Transfer => return None,
325 Stage::Indexing => self.rates.indexing,
326 Stage::Resolving => self.rates.resolving,
327 Stage::Checkout => self.rates.checkout,
328 };
329 (rate > 0.0).then_some(rate)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn secs(s: f64) -> Duration {
338 Duration::from_secs_f64(s)
339 }
340
341 fn rates() -> PhaseRates {
342 PhaseRates {
343 indexing: 1_000.0,
344 resolving: 1_000.0,
345 checkout: 100.0,
346 }
347 }
348
349 fn receiving(est: &mut OverallEstimator, at: f64) {
350 est.start(
351 ProgressPhase::ReceivingObjects,
352 ProgressUnit::Bytes,
353 None,
354 secs(at),
355 );
356 }
357
358 #[test]
359 fn no_estimate_until_objects_are_counted() {
360 let mut est = OverallEstimator::new(true);
361 assert!(est.estimate(secs(1.0)).is_none());
362 receiving(&mut est, 1.0);
363 est.inc(4096);
364 assert!(
365 est.estimate(secs(2.0)).is_none(),
366 "bytes alone are not enough"
367 );
368 }
369
370 #[test]
371 fn receive_projects_its_pace_and_sizes_later_phases() {
372 let mut est = OverallEstimator::new(true).with_rates(rates());
373 receiving(&mut est, 0.0);
374 est.received_objects(
376 ReceivedObjects {
377 received: 1_000,
378 total: 2_000,
379 deltas: 600,
380 },
381 secs(4.0),
382 );
383 let e = est.estimate(secs(4.0)).unwrap();
384 assert!(
386 (e.remaining.as_secs_f64() - 7.5).abs() < 1e-6,
387 "{:?}",
388 e.remaining
389 );
390 assert!((e.fraction - 4.0 / 11.5).abs() < 1e-6, "{}", e.fraction);
391 }
392
393 #[test]
394 fn active_phase_uses_measured_pace_once_trustworthy() {
395 let mut est = OverallEstimator::new(false).with_rates(rates());
396 est.start(
397 ProgressPhase::IndexingObjects,
398 ProgressUnit::Objects,
399 Some(1_000),
400 secs(10.0),
401 );
402 est.set(10);
404 let early = est.estimate(secs(10.5)).unwrap();
405 assert!((early.remaining.as_secs_f64() - (0.99 + 0.7)).abs() < 1e-6);
406 est.set(500);
408 let later = est.estimate(secs(12.0)).unwrap();
409 assert!((later.remaining.as_secs_f64() - (2.0 + 0.7)).abs() < 1e-6);
410 }
411
412 #[test]
413 fn known_totals_replace_assumptions() {
414 let mut est = OverallEstimator::new(true).with_rates(rates());
415 est.start(
416 ProgressPhase::ResolvingDeltas,
417 ProgressUnit::Objects,
418 Some(0),
419 secs(1.0),
420 );
421 est.finish(secs(1.0));
422 est.start(
423 ProgressPhase::CheckingOutFiles,
424 ProgressUnit::Files,
425 Some(200),
426 secs(1.0),
427 );
428 let e = est.estimate(secs(1.0)).unwrap();
430 assert!((e.remaining.as_secs_f64() - 2.0).abs() < 1e-6);
431 }
432
433 #[test]
434 fn fraction_never_goes_backwards_and_reaches_one_when_done() {
435 let mut est = OverallEstimator::new(false).with_rates(rates());
436 receiving(&mut est, 0.0);
437 est.received_objects(
438 ReceivedObjects {
439 received: 900,
440 total: 1_000,
441 deltas: 0,
442 },
443 secs(9.0),
444 );
445 let high = est.estimate(secs(9.0)).unwrap().fraction;
446 let stalled = est.estimate(secs(30.0)).unwrap().fraction;
448 assert!(stalled >= high);
449 est.start(
450 ProgressPhase::IndexingObjects,
451 ProgressUnit::Objects,
452 Some(1_000),
453 secs(30.0),
454 );
455 est.set(1_000);
456 est.start(
457 ProgressPhase::ResolvingDeltas,
458 ProgressUnit::Objects,
459 Some(0),
460 secs(31.0),
461 );
462 let unfinished = est.estimate(secs(31.0)).unwrap().fraction;
463 assert!(unfinished < 1.0);
464 est.finish(secs(31.0));
465 let done = est.estimate(secs(31.0)).unwrap();
466 assert_eq!(done.fraction, 1.0);
467 assert_eq!(done.remaining, Duration::ZERO);
468 }
469
470 #[test]
471 fn local_copy_with_known_total_is_estimated() {
472 let mut est = OverallEstimator::new(false).with_rates(rates());
473 est.start(
474 ProgressPhase::CopyingObjects,
475 ProgressUnit::Objects,
476 Some(100),
477 secs(0.0),
478 );
479 est.set(50);
480 let e = est.estimate(secs(1.0)).unwrap();
481 assert!((e.remaining.as_secs_f64() - 1.17).abs() < 1e-6, "{e:?}");
483 }
484}