//! What storage cap to offer, and which one to propose. //! //! Policy rather than drawing, which is why it is here and not in either screen: //! audiofiles has two of them - the shipped egui panel and the described one //! under [`crate::quasi`] - and a cap the two disagreed about would be two //! products. They disagreed before this module existed; both drew a logarithmic //! slider from the floor to 10 TiB, and one of them had no bounds at all. //! //! # The cap answers a question the app can already answer //! //! `Database::synced_storage_stats` returns the exact size of what blob sync //! would upload, locally and instantly. So the screen states the need, proposes //! a cap with headroom, and shows what each option costs; it does not solicit a //! number. See wiki `af-storage-cap`. //! //! # These are not tiers //! //! Pricing is continuous - `max($2.00, ceil(GiB) x 0.8c)` - so a named cap //! commits us to no price and invents no product. It is a bookmark on a //! continuum, and an exact figure is always accepted alongside. That is why //! choosing this list is a UI decision and not a pricing one. /// One gibibyte. pub const GIB: i64 = 1024 * 1024 * 1024; /// The caps offered by name, in GiB. /// /// The floor is the server's `MIN_CAP_BYTES` and the ceiling its /// `MAX_CAP_BYTES`; the steps between roughly double, which is how a person /// reads a size. Always filtered against the pricing the server actually sent /// rather than trusted, so a floor that moves past a named size drops it /// instead of offering a cap the route will refuse. pub const OFFERED_GIB: &[i64] = &[250, 500, 1024, 2048, 5120, 10240]; /// How much room over today's need a proposed cap leaves: half again. /// /// Not double. A sample library grows, so a cap with no headroom is one the user /// re-decides almost immediately; but headroom is bought by the gibibyte, and /// proposing double is proposing to spend double. Half again is the smallest /// margin that does not read as "you are already full". /// /// Proposing slightly low is cheap now: raising a cap takes effect immediately /// rather than at the next renewal (MNW `752d1cac`). const HEADROOM_NUMERATOR: i64 = 3; const HEADROOM_DENOMINATOR: i64 = 2; /// The threshold at which a cap is reported as filling, as a percentage. /// /// Ninety, and shared so the meter's colour and the sentence beside it can never /// disagree about whether this is a problem. pub const NEARLY_FULL_PERCENT: i64 = 90; /// The named caps this pricing permits, smallest first. pub fn offered(min_bytes: i64, max_bytes: i64) -> impl Iterator { OFFERED_GIB .iter() .map(|gib| gib * GIB) .filter(move |bytes| *bytes >= min_bytes && *bytes <= max_bytes) } /// The cap to propose: the smallest named size covering the need with headroom. /// /// `need` of `None` is "cannot look" and `Some(0)` is "nothing is set to sync"; /// both propose the floor, which is the cheapest thing on offer rather than a /// guess dressed as one. A need larger than every named size gets the largest, /// so the proposal is never nothing. pub fn proposed(need: Option, min_bytes: i64, max_bytes: i64) -> i64 { let floor = offered(min_bytes, max_bytes).next().unwrap_or(min_bytes); let Some(need) = need.filter(|bytes| *bytes > 0) else { return floor; }; let want = need .saturating_mul(HEADROOM_NUMERATOR) .saturating_div(HEADROOM_DENOMINATOR); offered(min_bytes, max_bytes) .find(|bytes| *bytes >= want) .or_else(|| offered(min_bytes, max_bytes).last()) .unwrap_or(floor) } /// Whether a cap is close enough to full to say so. pub fn nearly_full(used_bytes: i64, limit_bytes: i64) -> bool { limit_bytes > 0 && used_bytes.saturating_mul(100) >= limit_bytes.saturating_mul(NEARLY_FULL_PERCENT) } #[cfg(test)] mod tests { use super::*; const MIN: i64 = 250 * GIB; const MAX: i64 = 10240 * GIB; #[test] fn a_need_gets_the_smallest_named_cap_that_covers_it_with_headroom() { // 400 + half again = 600, and 512 is not on the list, so 1024. assert_eq!(proposed(Some(400 * GIB), MIN, MAX), 1024 * GIB); // 300 + half again = 450, which 500 covers. assert_eq!(proposed(Some(300 * GIB), MIN, MAX), 500 * GIB); } #[test] fn nothing_to_size_against_proposes_the_floor() { // The two are different facts - cannot look, and nothing set to sync - // and the screens say different things about them. The proposal is the // same either way, because in both cases there is nothing to size to. assert_eq!(proposed(None, MIN, MAX), MIN); assert_eq!(proposed(Some(0), MIN, MAX), MIN); } #[test] fn a_library_past_every_named_cap_gets_the_largest() { assert_eq!(proposed(Some(9000 * GIB), MIN, MAX), 10240 * GIB); // And does not overflow on a nonsense figure. assert_eq!(proposed(Some(i64::MAX), MIN, MAX), 10240 * GIB); } #[test] fn the_named_caps_are_filtered_against_what_the_server_sells() { // A floor above a named size drops it rather than offering a cap the // route refuses. This is the case that made the constant unsafe to // trust: MIN_CAP_BYTES moved from 10 GiB to 250 on 2026-08-21. let caps: Vec = offered(600 * GIB, 3000 * GIB).collect(); assert_eq!(caps, vec![1024 * GIB, 2048 * GIB]); } #[test] fn a_pricing_range_containing_no_named_cap_still_proposes_something() { // Degenerate, and it must not panic or propose zero: the floor is always // an answer. let odd = 77 * GIB; assert_eq!(offered(odd, odd + 1).count(), 0); assert_eq!(proposed(Some(10 * GIB), odd, odd + 1), odd); } #[test] fn nearly_full_is_ninety_percent_and_does_not_overflow() { assert!(!nearly_full(89 * GIB, 100 * GIB)); assert!(nearly_full(90 * GIB, 100 * GIB)); assert!(nearly_full(200 * GIB, 100 * GIB), "past full is still full"); assert!(!nearly_full(0, 0), "an unknown cap is not a full one"); // The old form multiplied both sides by 10 and 9; at these sizes the // saturating form matters. assert!(nearly_full(i64::MAX, 100 * GIB)); } }