mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
The first executable form of our host requirements. Until now a machine
was qualified by a human reading a table of hard requirements — and a
requirement that nothing can check is one that is silently optional.
It also replaces a form. Everything we wanted from a prospective host is
measurable, and most of it **cannot be answered honestly by a human
anyway**: almost nobody knows their real upstream, and essentially
nobody has ever seen their own bufferbloat figure. What's left for the
questions is only what a machine cannot know — intent, and what someone
already pays.
## What it does
```
nesdoctor
```
- **Checks every hard requirement**: `/dev/kvm`, an AMD or Intel GPU
with a DRM render node, `VK_KHR_video_encode_queue` plus a codec,
`virglrenderer`, the two stores, the `io` cgroup controller,
`virtiofsd`. Pass / fail / **unknown**, and unknown is never collapsed
into fail — a machine we could not ask is not a machine that failed, and
losing a capable host to a missing `lspci` is the failure mode that
matters.
- **Measures upstream and, the point of the whole thing, added latency
under load.** Grade bands come from the frame budget rather than
convention: the network allowance is ~40 ms because render, encode,
decode, display and jitter buffer have already spent ~58 ms.
- **Reads Steam, only with an explicit yes**, for library size and shape
plus an hour-of-day histogram of launches — one sample per title, which
is a real distribution obtained without asking anybody anything.
- **Asks at most five questions**, branched on what was found, all
skippable.
## No server
Nothing is uploaded and no telemetry endpoint exists. The network test
talks to Cloudflare's public speed-test sink and to `1.1.1.1`, neither
of which is ours. The output is a line on the terminal that the person
may choose to paste.
The shareable line carries **no hostname, IP, username, game title or
path** — a size band rather than a size, hours rather than dates. The
long version, which does include titles and paths, stays in a local JSON
file the person is told the path of.
That is a property of the design and not a promise about our intentions:
there is nothing to switch on later.
```
nesdoctor 0.1.0 | linux/x86_64 | gpu=AMD Barcelo | cpu=12t ram=13G |
kvm=y venc=y zfs=n boxfs=n io=y | up=28Mbps rtt=179ms bloat=+19ms grade=B |
disk=91G | edge=KE/JNB | steam=1 titles/<100G | plays=20-03h n=74 |
role=- share=- pays=- | HOST-READY-LOCAL
```
## Five bugs found by running it, every one of which would have produced
wrong data
- **`vulkaninfo --summary` lists ZERO `VK_KHR_video` entries** where
full `vulkaninfo` lists five on the same machine. Preferring the summary
reported "not advertised" on a card that advertises it — a false
negative on the check most likely to disqualify a host.
- **btrfs subvolumes counted as separate disks**: `/`, `/home` and
`/srv` each reporting 91 GiB of one 91 GiB device. Now deduped by
backing device, which the two-stores check needs anyway since it wants
*separate devices*.
- **Proton and the Steam Linux Runtimes are installed like games and are
not games.** Five of eight entries on the test machine, so the title
count was 5× too high and the library-shape question was corrupted.
- **`--quiet` printed the whole questionnaire** before its summary line,
breaking the one thing `--quiet` promises. Prompts are now skipped when
output is quiet or stdin is not a terminal — and a pipe is explicitly
*not* treated as consent to read a Steam library, unlike `--yes`.
- Boot history was reporting `13.2 h/day` off **two days** of history.
Under a three-day span it now reports the span and no rate.
## One finding, now encoded as a verdict
The development connection measures **179 ms idle RTT, served from
Johannesburg**. That machine passes every other check and cannot host
for a European player, because it is distance and no upgrade shortens
it.
`HOST-READY-LOCAL` exists for exactly that case, and the wording is
deliberate:
> Every requirement passes and your uplink queues cleanly. But the idle
round trip to the nearest major network is already most of the latency
budget, and that is distance rather than a fault: no upgrade shortens
it. So this machine is a good host for people on your side of the world
and cannot be one for anybody else. **If you are somewhere without a
cloud gaming edge, that is not a consolation prize — it is the only way
anyone there gets a playable stream.**
## CI
- **`ci.yml` gains a `nesdoctor` job** — fmt, `clippy -D warnings`,
test, one real run. Scoped to this member deliberately: the rest of the
Rust half has never been under CI, so `--workspace` would turn every PR
red for unrelated reasons. Widen it one member at a time.
- **`release-nesdoctor.yml`** builds four targets on tag `nesdoctor-v*`
— x86_64 linux-musl, x86_64 windows-msvc, aarch64 and x86_64 macOS —
with `SHA256SUMS`. musl rather than glibc so one Linux binary runs on
every distro.
The step that justifies the workflow **runs the binary it just built,
network included**. `ring` under rustls resolves root certificates
through the host trust store, so a static musl build can compile cleanly
and then fail TLS on the machine it ships to — breaking the network
test, silently, and only for other people. The step fails the build if
the summary line comes back `net=unmeasured`.
## Dependencies
Four: `anyhow`, `clap`, `serde`, `ureq`. The VDF parser, every platform
probe and the text wrapping are in-tree. A binary handed to strangers
has a dependency tree that is part of its interface, so anything that
could be done with `std` is.
4 MB release binary.
## What it deliberately does not claim
- **A pass is not a promise.** Every check is a *necessary* condition,
and nothing here runs under load — a machine that passes can still fail
on block I/O.
- **The encode extension being advertised is not proof the path works.**
We have had a correct extension list over a broken path before, so that
row says so.
- **Whether `libvirglrenderer` carries the native-context patches cannot
be determined from outside**, so that row reports presence only and
stays `unknown` rather than `pass`.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
The PR adds the nesdoctor host-readiness executable, local Steam
analysis, network measurement, installers, CI validation, and
multi-platform release packaging. Two attempted correctness fixes remain
incomplete:
- physical disk deduplication does not resolve common device-mapper
source names before comparing backing devices
- unknown historical Steam appids can still be counted as game launches
without passing runtime filtering
<h3>Confidence Score: 3/5</h3>
The PR is not yet safe to merge because shared LVM-backed stores can be
reported as physically independent and unknown Steam tools can still be
reported as game launches.
The new disk resolver fails open for common device-mapper names,
preserving a false host-readiness verdict, while Steam history still
counts absent appids without determining whether they are games or
runtime tools.
**Files Needing Attention:** apps/nesdoctor/src/sys.rs,
apps/nesdoctor/src/hostreq.rs, apps/nesdoctor/src/steam.rs
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/nesdoctor/src/sys.rs | Adds system and disk discovery, but
unresolved device-mapper names undermine physical-backing comparisons. |
| apps/nesdoctor/src/hostreq.rs | Implements host requirement verdicts
and uses physical-device sets that can falsely classify shared LVM
backing as independent. |
| apps/nesdoctor/src/steam.rs | Adds manifest and launch-history
analysis, but unknown appids bypass runtime classification and
contaminate launch metrics. |
| apps/nesdoctor/src/net.rs | Adds bounded upload-based upstream and
bufferbloat measurement; the previously reported unbounded request path
is addressed. |
| .github/workflows/release-nesdoctor.yml | Builds, smoke-tests,
packages, checksums, and publishes the four release targets. |
| .github/workflows/ci.yml | Adds focused formatting, linting, testing,
and offline execution checks for nesdoctor. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Run[nesdoctor] --> Host[Host requirement probes]
Run --> Net[Upload and latency measurement]
Run --> Consent{Steam consent}
Consent -->|yes| Steam[Installed manifests and LastPlayed records]
Host --> Physical[Resolve filesystem sources to physical devices]
Physical --> Verdict[Host readiness verdict]
Net --> Report[Detailed JSON and shareable summary]
Steam --> Report
Verdict --> Report
```
<details><summary>Prompt To Fix All With AI</summary>
`````markdown
### Issue 1
apps/nesdoctor/src/sys.rs:369-374
**Mapper devices remain unresolved**
When root and box-store filesystems are separate LVM or dm-crypt mappings on the same physical disk, `df` supplies `/dev/mapper/...` names that do not exist under `/sys/class/block`. This branch returns those unrelated logical names unchanged, so the overlap check passes stores that still share one physical I/O queue.
### Issue 2
apps/nesdoctor/src/steam.rs:247-250
**Unknown appids bypass runtime filtering**
If `localconfig.vdf` retains `LastPlayed` data for an uninstalled Proton build, Steam runtime, or other non-game tool, its appid is absent from the installed-manifest map and this branch treats it as an uninstalled game. The tool activity then changes the launch histogram, peak window, and shareable `n` value.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
`````
</details>
<sub>Reviews (5): Last reviewed commit: ["fix(nesdoctor): three valid P1
findings
..."](7afc8929a6)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=59231233)</sub>
> Greptile also left **2 inline comments** on this PR.
<!-- /greptile_comment -->
369 lines
14 KiB
Rust
369 lines
14 KiB
Rust
//! What Steam already knows, read locally and only with permission.
|
||
//!
|
||
//! Three things we would otherwise have to ask about are answerable from files
|
||
//! on disk, and all three are ones people answer badly when asked:
|
||
//!
|
||
//! | question | what we read |
|
||
//! |---|---|
|
||
//! | *what do you play?* | `appmanifest_*.acf` — title and size on disk |
|
||
//! | *how big is a library?* | the sum of those sizes, which is the content store's cost |
|
||
//! | *when do you play?* | `localconfig.vdf` — `LastPlayed` per title, as an hour-of-day histogram |
|
||
//!
|
||
//! The third is the interesting one. Steam keeps one `LastPlayed` timestamp per
|
||
//! title, so a library of eighty games is **eighty samples of what hour this
|
||
//! person launches a game at** — a real distribution, taken without asking, and
|
||
//! the thing a demand trough is made of. It is biased toward whatever
|
||
//! they played most recently and it is not a schedule; it is a sample, and it is
|
||
//! reported as one.
|
||
//!
|
||
//! # This is somebody's private library
|
||
//!
|
||
//! Nothing here runs without an explicit yes, nothing leaves the machine, and
|
||
//! the summary line carries **counts and hours, never titles**. The full JSON
|
||
//! stays in a local file the caller is told the path of. Reading a game library
|
||
//! is not a neutral act and the code is arranged so that a reader can confirm
|
||
//! that in one pass.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::vdf;
|
||
|
||
#[derive(Debug, Serialize, Default)]
|
||
pub struct SteamReport {
|
||
pub found: bool,
|
||
pub roots: Vec<String>,
|
||
pub titles: usize,
|
||
pub bytes_on_disk: u64,
|
||
/// The five largest, by size on disk. Included because library *shape* is
|
||
/// what decides whether a depot cache has a head to cache.
|
||
pub largest: Vec<(String, u64)>,
|
||
/// Count of `LastPlayed` timestamps falling in each local hour, 0–23.
|
||
pub launch_hours: [u32; 24],
|
||
pub launch_samples: usize,
|
||
/// How many Steam user profiles were found on this machine. More than one
|
||
/// means more than one person may use it, and the histogram is taken from
|
||
/// the busiest profile rather than summed across strangers.
|
||
pub profiles: usize,
|
||
/// Launch records whose title is not installed any more. Kept in the
|
||
/// histogram on purpose — a game someone uninstalled is still a real
|
||
/// record of when they play — and reported so `n` cannot be mistaken for
|
||
/// the installed-title count.
|
||
pub launches_uninstalled: usize,
|
||
/// Hours covering half of all launches, contiguous and wrapping — the
|
||
/// "evening peak" if there is one.
|
||
pub peak_window: Option<(u32, u32)>,
|
||
}
|
||
|
||
/// Proton builds, runtimes and redistributables are installed like games and
|
||
/// are not games.
|
||
///
|
||
/// Measured 2026-09-02: on a machine with one real title, five of the eight
|
||
/// entries were runtimes — so counting them inflates the title count by 5x and
|
||
/// corrupts the library-shape question this is here to answer. Matching on the
|
||
/// name is imperfect and is the honest trade: a title genuinely called
|
||
/// "Proton …" would be dropped, and no real title is.
|
||
fn is_runtime(title: &str) -> bool {
|
||
const PREFIXES: [&str; 5] = [
|
||
"Proton",
|
||
"Steam Linux Runtime",
|
||
"Steamworks Common Redistributables",
|
||
"Steam Deck",
|
||
"SteamVR",
|
||
];
|
||
PREFIXES.iter().any(|p| title.starts_with(p))
|
||
}
|
||
|
||
/// Where Steam might be. Checked in order; all hits are used, because a library
|
||
/// is routinely split across drives.
|
||
fn candidate_roots() -> Vec<PathBuf> {
|
||
let home = std::env::var_os("HOME")
|
||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||
.map(PathBuf::from);
|
||
let mut v = Vec::new();
|
||
if let Some(h) = home {
|
||
v.push(h.join(".steam/steam"));
|
||
v.push(h.join(".local/share/Steam"));
|
||
// Flatpak keeps its own home.
|
||
v.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"));
|
||
v.push(h.join("Library/Application Support/Steam")); // macOS
|
||
}
|
||
v.push(PathBuf::from(r"C:\Program Files (x86)\Steam"));
|
||
v.push(PathBuf::from("/usr/lib/steam"));
|
||
|
||
// Canonicalise before deduplicating. `~/.steam/steam` is conventionally a
|
||
// symlink to `~/.local/share/Steam`, so both candidates hit and every
|
||
// profile is found twice -- measured on the development machine, which
|
||
// reported four Steam profiles for two real ones and would have claimed a
|
||
// shared machine where there is not one.
|
||
let mut out: Vec<PathBuf> = v
|
||
.into_iter()
|
||
.filter(|p| p.join("steamapps").is_dir())
|
||
.map(|p| fs::canonicalize(&p).unwrap_or(p))
|
||
.collect();
|
||
out.sort();
|
||
out.dedup();
|
||
out
|
||
}
|
||
|
||
/// True when there is anything to ask about. Called *before* consent so the
|
||
/// question is not asked of someone with no Steam install.
|
||
pub fn present() -> bool {
|
||
!candidate_roots().is_empty()
|
||
}
|
||
|
||
pub fn read() -> SteamReport {
|
||
let roots = candidate_roots();
|
||
if roots.is_empty() {
|
||
return SteamReport::default();
|
||
}
|
||
|
||
let mut r = SteamReport {
|
||
found: true,
|
||
roots: roots.iter().map(|p| p.display().to_string()).collect(),
|
||
..Default::default()
|
||
};
|
||
|
||
// Library folders can live on other drives; `libraryfolders.vdf` lists
|
||
// them, and skipping it undercounts a split library badly.
|
||
let mut app_dirs: Vec<PathBuf> = roots.iter().map(|p| p.join("steamapps")).collect();
|
||
for root in &roots {
|
||
let lf = root.join("steamapps/libraryfolders.vdf");
|
||
if let Ok(txt) = fs::read_to_string(&lf) {
|
||
let doc = vdf::parse(&txt);
|
||
if let Some(folders) = doc.get(&["libraryfolders"]).and_then(vdf::Value::as_node) {
|
||
for entry in folders.values() {
|
||
if let Some(p) = entry.get(&["path"]).and_then(vdf::Value::as_str) {
|
||
let d = PathBuf::from(p).join("steamapps");
|
||
if d.is_dir() {
|
||
app_dirs.push(d);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
app_dirs.sort();
|
||
app_dirs.dedup();
|
||
|
||
let mut by_size: Vec<(String, u64)> = Vec::new();
|
||
// appid -> (title, is_runtime). Needed by the launch histogram below, which
|
||
// sees appids and nothing else.
|
||
let mut known: BTreeMap<String, (String, bool)> = BTreeMap::new();
|
||
for dir in &app_dirs {
|
||
let Ok(entries) = fs::read_dir(dir) else {
|
||
continue;
|
||
};
|
||
for e in entries.flatten() {
|
||
let name = e.file_name().to_string_lossy().into_owned();
|
||
if !(name.starts_with("appmanifest_") && name.ends_with(".acf")) {
|
||
continue;
|
||
}
|
||
let Ok(txt) = fs::read_to_string(e.path()) else {
|
||
continue;
|
||
};
|
||
let doc = vdf::parse(&txt);
|
||
let title = doc
|
||
.get(&["AppState", "name"])
|
||
.and_then(vdf::Value::as_str)
|
||
.unwrap_or("unknown")
|
||
.to_string();
|
||
if let Some(id) = doc
|
||
.get(&["AppState", "appid"])
|
||
.and_then(vdf::Value::as_str)
|
||
.map(str::to_string)
|
||
{
|
||
known.insert(id, (title.clone(), is_runtime(&title)));
|
||
}
|
||
let size = doc
|
||
.get(&["AppState", "SizeOnDisk"])
|
||
.and_then(vdf::Value::as_u64)
|
||
.unwrap_or(0);
|
||
if is_runtime(&title) {
|
||
continue;
|
||
}
|
||
by_size.push((title, size));
|
||
}
|
||
}
|
||
// A split library can list the same appid twice; dedupe by title.
|
||
by_size.sort_by(|a, b| a.0.cmp(&b.0));
|
||
by_size.dedup_by(|a, b| a.0 == b.0);
|
||
|
||
r.titles = by_size.len();
|
||
r.bytes_on_disk = by_size.iter().map(|(_, s)| s).sum();
|
||
by_size.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
|
||
r.largest = by_size.into_iter().take(5).collect();
|
||
|
||
// --- when do they play -----------------------------------------------
|
||
//
|
||
// `localconfig.vdf` keeps a `LastPlayed` per app, which makes a library of
|
||
// eighty games eighty samples of what hour this person starts playing.
|
||
// Two things have to be handled or the number is wrong:
|
||
//
|
||
// **Runtimes are not launches.** Proton and the Steam Linux Runtimes carry
|
||
// `LastPlayed` like any app, and Steam starts them itself, at whatever hour
|
||
// it happens to update them. They are filtered here by joining the appid
|
||
// against the installed-title names — the same filter the title count uses,
|
||
// so the two cannot disagree about what a game is.
|
||
//
|
||
// **Profiles are not one person.** A shared machine has several, and
|
||
// summing them produces a histogram of nobody. The busiest profile is used
|
||
// and the count is reported, so a two-profile machine is visible as one.
|
||
let mut per_profile: Vec<([u32; 24], usize, usize)> = Vec::new();
|
||
for root in &roots {
|
||
let Ok(users) = fs::read_dir(root.join("userdata")) else {
|
||
continue;
|
||
};
|
||
for u in users.flatten() {
|
||
let cfg = u.path().join("config/localconfig.vdf");
|
||
let Ok(txt) = fs::read_to_string(&cfg) else {
|
||
continue;
|
||
};
|
||
let doc = vdf::parse(&txt);
|
||
let Some(apps) = doc
|
||
.get(&["UserLocalConfigStore", "Software", "Valve", "Steam", "apps"])
|
||
.and_then(vdf::Value::as_node)
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
let mut hours = [0u32; 24];
|
||
let mut n = 0usize;
|
||
let mut gone = 0usize;
|
||
for (appid, app) in apps {
|
||
let Some(ts) = app.get(&["LastPlayed"]).and_then(vdf::Value::as_u64) else {
|
||
continue;
|
||
};
|
||
if ts == 0 {
|
||
continue;
|
||
}
|
||
match known.get(appid) {
|
||
// A tool Steam launched, not a person playing.
|
||
Some((_, true)) => continue,
|
||
Some((_, false)) => {}
|
||
// Not installed now. A real launch by a real person, and
|
||
// counted — but counted separately so `n` is never read as
|
||
// the installed-title count.
|
||
None => gone += 1,
|
||
}
|
||
if let Some(h) = local_hour(ts) {
|
||
hours[h as usize] += 1;
|
||
n += 1;
|
||
}
|
||
}
|
||
if n > 0 {
|
||
per_profile.push((hours, n, gone));
|
||
}
|
||
}
|
||
}
|
||
|
||
r.profiles = per_profile.len();
|
||
if let Some((hours, n, gone)) = per_profile.into_iter().max_by_key(|(_, n, _)| *n) {
|
||
r.launch_hours = hours;
|
||
r.launch_samples = n;
|
||
r.launches_uninstalled = gone;
|
||
}
|
||
|
||
r.peak_window = peak_window(&r.launch_hours, r.launch_samples);
|
||
r
|
||
}
|
||
|
||
/// Hour of day, in the machine's local time, for a Unix timestamp.
|
||
///
|
||
/// Done with the offset the OS reports rather than a timezone crate: we need
|
||
/// the hour a person launched a game in their own reckoning, and one integer
|
||
/// offset is enough for that. DST at the boundary shifts a sample by an hour,
|
||
/// which is inside the resolution this is reported at.
|
||
fn local_hour(unix: u64) -> Option<u32> {
|
||
let offset = utc_offset_seconds()?;
|
||
let local = unix as i64 + offset;
|
||
Some((local.rem_euclid(86_400) / 3600) as u32)
|
||
}
|
||
|
||
fn utc_offset_seconds() -> Option<i64> {
|
||
// `date +%z` gives `+0200`. Present on every unix; PowerShell for Windows.
|
||
let z = crate::sys::sh("date", &["+%z"]).or_else(|| {
|
||
crate::sys::ps("(Get-TimeZone).BaseUtcOffset.TotalSeconds")
|
||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||
.map(|s| format!("{:+05}", (s as i64 / 3600) * 100))
|
||
})?;
|
||
let z = z.trim();
|
||
let sign = if z.starts_with('-') { -1 } else { 1 };
|
||
let digits: String = z.chars().filter(char::is_ascii_digit).collect();
|
||
if digits.len() < 4 {
|
||
return None;
|
||
}
|
||
let h: i64 = digits[0..2].parse().ok()?;
|
||
let m: i64 = digits[2..4].parse().ok()?;
|
||
Some(sign * (h * 3600 + m * 60))
|
||
}
|
||
|
||
/// The shortest contiguous, wrapping run of hours holding at least half the
|
||
/// launches. That is the honest form of "when do you play": if it is four hours
|
||
/// wide there is an evening peak, and if it takes fourteen there is not.
|
||
fn peak_window(hours: &[u32; 24], total: usize) -> Option<(u32, u32)> {
|
||
if total < 8 {
|
||
return None; // too few samples to claim a shape
|
||
}
|
||
let half = (total as f64 / 2.0).ceil() as u32;
|
||
let mut best: Option<(u32, u32, u32)> = None; // width, start, end
|
||
for start in 0..24u32 {
|
||
let mut sum = 0;
|
||
for w in 1..=24u32 {
|
||
sum += hours[((start + w - 1) % 24) as usize];
|
||
if sum >= half {
|
||
let cand = (w, start, (start + w - 1) % 24);
|
||
if best.is_none_or(|b| w < b.0) {
|
||
best = Some(cand);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
best.map(|(_, s, e)| (s, e))
|
||
}
|
||
|
||
/// GiB, for display.
|
||
pub fn gib(bytes: u64) -> f64 {
|
||
bytes as f64 / 1_073_741_824.0
|
||
}
|
||
|
||
/// A one-line sparkline of the launch-hour histogram.
|
||
///
|
||
/// Worth the twenty lines: it is the part of the output people screenshot, and
|
||
/// it is the only place someone sees their own play schedule as a shape.
|
||
pub fn sparkline(hours: &[u32; 24]) -> String {
|
||
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||
let max = *hours.iter().max().unwrap_or(&0);
|
||
if max == 0 {
|
||
return String::new();
|
||
}
|
||
hours
|
||
.iter()
|
||
.map(|&h| {
|
||
if h == 0 {
|
||
' '
|
||
} else {
|
||
BARS[((h as f64 / max as f64) * 7.0).round() as usize]
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Titles are never put in the paste line, so this is what goes instead:
|
||
/// a coarse size band, which is all a content-store estimate needs.
|
||
pub fn size_band(bytes: u64) -> &'static str {
|
||
match gib(bytes) {
|
||
g if g < 100.0 => "<100G",
|
||
g if g < 500.0 => "100-500G",
|
||
g if g < 1500.0 => "0.5-1.5T",
|
||
_ => ">1.5T",
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub fn debug_map(_m: &BTreeMap<String, vdf::Value>) {}
|