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