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