Skip to main content

data_beans/figure/
mod.rs

1//! Figures (feature `figure`): each one drawn once as SVG, written as a
2//! vector PDF and a PNG by [`save`], returned as PNG bytes by [`png`] for an
3//! agent, and shown as an image inside the full-screen views where the
4//! terminal has an image protocol ([`term`], feature `tui`). What is saved
5//! is what was on screen.
6//!
7//! The drawing is faba's (`src/figure/`): a [`Canvas`] in points on white,
8//! greys for context and one accent for what a threshold removes. Panels
9//! here read values on an [`Axis`] (linear, or log on `ln(shift + x)`), the
10//! axis the printed histograms and the views' histograms bin on too.
11//!
12//! Text is set in the system's Helvetica, Arial or DejaVu Sans; a host with
13//! none of them installed writes figures without their labels.
14
15pub mod qc;
16pub mod stat;
17#[cfg(feature = "tui")]
18pub mod term;
19
20use std::fmt::Write as _;
21use std::sync::{Arc, OnceLock};
22
23/// Text and axes.
24pub const INK: &str = "#1a1a1a";
25/// Bars.
26pub const BAR: &str = "#4d4d4d";
27/// Secondary text and axis labels.
28pub const MUTED: &str = "#6b6b6b";
29/// Bars behind a front subset.
30pub const FAINT: &str = "#cfcfcf";
31/// Gridlines.
32pub const GRID: &str = "#ececec";
33/// The one accent, as on screen: what a threshold removes, the cursor.
34pub const ACCENT: &str = "#d97757";
35const FONT: &str = "Helvetica, Arial, 'DejaVu Sans', sans-serif";
36
37/// Escape text for SVG content and attributes.
38pub fn esc(s: &str) -> String {
39    let mut out = String::with_capacity(s.len());
40    for c in s.chars() {
41        match c {
42            '&' => out.push_str("&"),
43            '<' => out.push_str("&lt;"),
44            '>' => out.push_str("&gt;"),
45            '"' => out.push_str("&quot;"),
46            '\'' => out.push_str("&apos;"),
47            _ => out.push(c),
48        }
49    }
50    out
51}
52
53#[derive(Debug, Clone, Copy)]
54pub enum Anchor {
55    Start,
56    Middle,
57    End,
58}
59
60impl Anchor {
61    fn svg(self) -> &'static str {
62        match self {
63            Anchor::Start => "start",
64            Anchor::Middle => "middle",
65            Anchor::End => "end",
66        }
67    }
68}
69
70/// An SVG drawing in points, white background.
71pub struct Canvas {
72    width: f64,
73    height: f64,
74    body: String,
75}
76
77impl Canvas {
78    pub fn new(width: f64, height: f64) -> Self {
79        let mut c = Self {
80            width,
81            height,
82            body: String::new(),
83        };
84        c.rect(0.0, 0.0, width, height, "#ffffff");
85        c
86    }
87
88    /// A filled rectangle; empty ones (zero or negative size) are skipped,
89    /// as SVG renderers reject them.
90    pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str) {
91        // Written to two decimals: what would read as zero is skipped too.
92        if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) {
93            return;
94        }
95        let _ = writeln!(
96            self.body,
97            r#"<rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{fill}"/>"#
98        );
99    }
100
101    /// A rectangle at `opacity`.
102    pub fn shade(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str, opacity: f64) {
103        if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) {
104            return;
105        }
106        let _ = writeln!(
107            self.body,
108            r#"<rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{fill}" fill-opacity="{opacity:.3}"/>"#
109        );
110    }
111
112    pub fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str, width: f64) {
113        let _ = writeln!(
114            self.body,
115            r#"<line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{stroke}" stroke-width="{width}"/>"#
116        );
117    }
118
119    pub fn dashed(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str, width: f64) {
120        let _ = writeln!(
121            self.body,
122            r#"<line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{stroke}" stroke-width="{width}" stroke-dasharray="3 2"/>"#
123        );
124    }
125
126    /// A filled dot.
127    pub fn dot(&mut self, x: f64, y: f64, r: f64, fill: &str, opacity: f64) {
128        let _ = writeln!(
129            self.body,
130            r#"<circle cx="{x:.2}" cy="{y:.2}" r="{r:.2}" fill="{fill}" fill-opacity="{opacity:.2}"/>"#
131        );
132    }
133
134    /// A dot with a ring around it.
135    pub fn ringed(&mut self, x: f64, y: f64, r: f64, fill: &str, ring: &str) {
136        let _ = writeln!(
137            self.body,
138            r#"<circle cx="{x:.2}" cy="{y:.2}" r="{r:.2}" fill="{fill}" stroke="{ring}" stroke-width="0.9"/>"#
139        );
140    }
141
142    /// A triangle pointing up, its tip at `(x, y)`.
143    pub fn pointer(&mut self, x: f64, y: f64, size: f64, fill: &str) {
144        let _ = writeln!(
145            self.body,
146            r#"<path d="M{:.2},{:.2} L{:.2},{:.2} L{x:.2},{y:.2} Z" fill="{fill}"/>"#,
147            x - size,
148            y + 1.6 * size,
149            x + size,
150            y + 1.6 * size
151        );
152    }
153
154    pub fn text(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
155        let _ = writeln!(
156            self.body,
157            r#"<text x="{x:.2}" y="{y:.2}" font-family="{FONT}" font-size="{size}" text-anchor="{}" fill="{fill}">{}</text>"#,
158            anchor.svg(),
159            esc(s)
160        );
161    }
162
163    pub fn bold(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
164        let _ = writeln!(
165            self.body,
166            r#"<text x="{x:.2}" y="{y:.2}" font-family="{FONT}" font-size="{size}" font-weight="bold" text-anchor="{}" fill="{fill}">{}</text>"#,
167            anchor.svg(),
168            esc(s)
169        );
170    }
171
172    /// Text turned to read bottom to top, centred on `(x, y)`.
173    pub fn vtext(&mut self, x: f64, y: f64, s: &str, size: f64, fill: &str) {
174        self.turned(x, y, s, size, -90.0, Anchor::Middle, fill);
175    }
176
177    /// Text turned by `degrees`, anchored at `(x, y)`.
178    #[allow(clippy::too_many_arguments)]
179    pub fn turned(
180        &mut self,
181        x: f64,
182        y: f64,
183        s: &str,
184        size: f64,
185        degrees: f64,
186        anchor: Anchor,
187        fill: &str,
188    ) {
189        let _ = writeln!(
190            self.body,
191            r#"<text transform="translate({x:.2} {y:.2}) rotate({degrees})" font-family="{FONT}" font-size="{size}" text-anchor="{}" fill="{fill}">{}</text>"#,
192            anchor.svg(),
193            esc(s)
194        );
195    }
196
197    /// An open line through `points`, dashed when asked.
198    pub fn polyline(&mut self, points: &[(f64, f64)], stroke: &str, width: f64, dashed: bool) {
199        let pts: Vec<String> = points
200            .iter()
201            .map(|(x, y)| format!("{x:.2},{y:.2}"))
202            .collect();
203        let dash = if dashed {
204            r#" stroke-dasharray="4 3""#
205        } else {
206            ""
207        };
208        let _ = writeln!(
209            self.body,
210            r#"<polyline points="{}" fill="none" stroke="{stroke}" stroke-width="{width}"{dash}/>"#,
211            pts.join(" ")
212        );
213    }
214
215    /// Swatches and their names in a row from `(x, y)`; the x after the last.
216    pub fn legend(&mut self, mut x: f64, y: f64, items: &[(&str, &str)]) -> f64 {
217        for (colour, name) in items {
218            self.rect(x, y - 8.0, 9.0, 9.0, colour);
219            self.text(x + 13.0, y, name, 9.5, Anchor::Start, INK);
220            x += 26.0 + text_width(name, 9.5);
221        }
222        x
223    }
224
225    pub fn finish(self) -> String {
226        format!(
227            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
228{body}</svg>
229"#,
230            w = self.width,
231            h = self.height,
232            body = self.body
233        )
234    }
235}
236
237/// About how wide `s` sets at `size` points, for laying out legends.
238pub fn text_width(s: &str, size: f64) -> f64 {
239    s.chars().count() as f64 * size * 0.55
240}
241
242/// A number as a short label: `1.2k`, `35`, `0.05`.
243pub fn short(v: f64) -> String {
244    let a = v.abs();
245    let trim = |s: String| {
246        if s.contains('.') {
247            s.trim_end_matches('0').trim_end_matches('.').to_string()
248        } else {
249            s
250        }
251    };
252    if a >= 1e6 {
253        format!("{}M", trim(format!("{:.1}", v / 1e6)))
254    } else if a >= 1e4 {
255        format!("{}k", trim(format!("{:.0}", v / 1e3)))
256    } else if a >= 1e3 {
257        format!("{}k", trim(format!("{:.1}", v / 1e3)))
258    } else if a >= 10.0 || a == 0.0 {
259        format!("{v:.0}")
260    } else if a >= 1.0 {
261        trim(format!("{v:.1}"))
262    } else {
263        trim(format!("{v:.3}"))
264    }
265}
266
267pub use crate::qc::axis::Axis;
268
269/// Bins across a panel: about one per 6 points, from 20 to 60.
270pub fn bins_for(width: f64) -> usize {
271    ((width / 6.0) as usize).clamp(20, 60)
272}
273
274/// The box a panel draws its data in, inside `(x, y, w, h)`: room on the
275/// left for y labels, above for the title and subtitle, below for ticks
276/// and the x title.
277pub fn plot_area(x: f64, y: f64, w: f64, h: f64) -> (f64, f64, f64, f64) {
278    let (left, right, top, bottom) = (44.0, 10.0, 34.0, 32.0);
279    (
280        x + left,
281        y + top,
282        (w - left - right).max(1.0),
283        (h - top - bottom).max(1.0),
284    )
285}
286
287/// A panel's title and subtitle.
288fn heading(c: &mut Canvas, x: f64, y: f64, title: &str, subtitle: &str) {
289    c.bold(x, y + 12.0, title, 11.0, Anchor::Start, INK);
290    c.text(x, y + 25.0, subtitle, 9.0, Anchor::Start, MUTED);
291}
292
293/// The x axis under a plot area: baseline, tick stubs and labels, title.
294fn x_axis(c: &mut Canvas, axis: &Axis, (px, py, pw, ph): (f64, f64, f64, f64), title: &str) {
295    c.line(px, py + ph, px + pw, py + ph, INK, 0.6);
296    for t in axis.ticks() {
297        let tx = px + axis.frac(t) * pw;
298        c.line(tx, py + ph, tx, py + ph + 3.0, INK, 0.6);
299        c.text(tx, py + ph + 12.0, &short(t), 8.0, Anchor::Middle, MUTED);
300    }
301    c.text(
302        px + pw / 2.0,
303        py + ph + 25.0,
304        title,
305        8.5,
306        Anchor::Middle,
307        MUTED,
308    );
309}
310
311/// A histogram of `values` in layers drawn back to front, each the values
312/// its mask takes (all of them for `None`) in its colour; with an edge, a
313/// dashed line and its label; with a cursor, a pointer under the axis.
314pub struct Hist<'a> {
315    pub title: String,
316    pub subtitle: String,
317    pub x_title: String,
318    pub axis: Axis,
319    pub values: &'a [f32],
320    pub layers: Vec<(Option<&'a [bool]>, &'static str)>,
321    pub edge: Option<(f64, String)>,
322    pub cursor: Option<f64>,
323}
324
325impl Hist<'_> {
326    pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
327        heading(c, x, y, &self.title, &self.subtitle);
328        let (px, py, pw, ph) = plot_area(x, y, w, h);
329        let n = bins_for(pw);
330        let layers: Vec<(Vec<usize>, &str)> = self
331            .layers
332            .iter()
333            .map(|(mask, colour)| {
334                let counts = self
335                    .axis
336                    .counts(self.values, n, |i| mask.is_none_or(|m| m[i]));
337                (counts, *colour)
338            })
339            .collect();
340        let top = layers
341            .iter()
342            .flat_map(|(c, _)| c.iter().copied())
343            .max()
344            .unwrap_or(0)
345            .max(1) as f64;
346        let y_axis = Axis::linear(0.0, top * 1.08);
347        for t in y_axis.ticks() {
348            let gy = py + ph - y_axis.frac(t) * ph;
349            if t > 0.0 {
350                c.line(px, gy, px + pw, gy, GRID, 0.6);
351            }
352            c.text(px - 4.0, gy + 3.0, &short(t), 8.0, Anchor::End, MUTED);
353        }
354        let bw = pw / n as f64;
355        let gap = if bw > 3.0 { 0.12 * bw } else { 0.0 };
356        for (counts, colour) in &layers {
357            for (i, &k) in counts.iter().enumerate() {
358                let bh = y_axis.frac(k as f64) * ph;
359                c.rect(
360                    px + i as f64 * bw + gap / 2.0,
361                    py + ph - bh,
362                    bw - gap,
363                    bh,
364                    colour,
365                );
366            }
367        }
368        x_axis(c, &self.axis, (px, py, pw, ph), &self.x_title);
369        if let Some((v, label)) = &self.edge {
370            let ex = px + self.axis.frac(*v).clamp(0.0, 1.0) * pw;
371            c.dashed(ex, py - 2.0, ex, py + ph, ACCENT, 1.1);
372            let (anchor, dx) = if ex > px + 0.8 * pw {
373                (Anchor::End, -3.0)
374            } else {
375                (Anchor::Start, 3.0)
376            };
377            c.text(ex + dx, py + 7.0, label, 8.5, anchor, ACCENT);
378        }
379        if let Some(v) = self.cursor {
380            let cx = px + self.axis.frac(v).clamp(0.0, 1.0) * pw;
381            c.pointer(cx, py + ph + 1.0, 3.5, ACCENT);
382        }
383    }
384}
385
386/// One point of a [`Scatter`]: where, its fill, and whether it is ringed.
387pub struct Point {
388    pub x: f32,
389    pub y: f32,
390    pub fill: &'static str,
391    pub ringed: bool,
392}
393
394/// Points over two axes, with an edge on either; past [`Scatter::DOTS`]
395/// points, a density grid in grey with the ringed points' cells over it in
396/// the accent.
397pub struct Scatter<'a> {
398    pub title: String,
399    pub subtitle: String,
400    pub x_title: String,
401    pub y_title: String,
402    pub x: Axis,
403    pub y: Axis,
404    pub points: &'a [Point],
405    pub x_edge: Option<f64>,
406    pub y_edge: Option<f64>,
407    /// Swatches in the corner: colour and name.
408    pub key: Vec<(&'static str, String)>,
409}
410
411impl Scatter<'_> {
412    /// More points than this draw as a density grid.
413    pub const DOTS: usize = 20_000;
414
415    pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
416        heading(c, x, y, &self.title, &self.subtitle);
417        let (px, py, pw, ph) = plot_area(x, y, w, h);
418        let at = |p: &Point| {
419            (
420                px + self.x.frac(p.x as f64) * pw,
421                py + ph - self.y.frac(p.y as f64) * ph,
422            )
423        };
424        for t in self.y.ticks() {
425            let gy = py + ph - self.y.frac(t) * ph;
426            c.line(px, gy, px + pw, gy, GRID, 0.6);
427            c.text(px - 4.0, gy + 3.0, &short(t), 8.0, Anchor::End, MUTED);
428        }
429        for t in self.x.ticks() {
430            let gx = px + self.x.frac(t) * pw;
431            c.line(gx, py, gx, py + ph, GRID, 0.6);
432        }
433        c.vtext(x + 9.0, py + ph / 2.0, &self.y_title, 8.5, MUTED);
434        if self.points.len() <= Self::DOTS {
435            for p in self.points.iter().filter(|p| !p.ringed) {
436                let (sx, sy) = at(p);
437                c.dot(sx, sy, 1.6, p.fill, 0.85);
438            }
439            for p in self.points.iter().filter(|p| p.ringed) {
440                let (sx, sy) = at(p);
441                c.ringed(sx, sy, 2.3, p.fill, ACCENT);
442            }
443        } else {
444            let (gw, gh) = (((pw / 3.0) as usize).max(1), ((ph / 3.0) as usize).max(1));
445            let mut all = vec![0usize; gw * gh];
446            let mut ringed = vec![0usize; gw * gh];
447            for p in self.points {
448                let (fx, fy) = (self.x.frac(p.x as f64), self.y.frac(p.y as f64));
449                if !(fx.is_finite() && fy.is_finite()) {
450                    continue;
451                }
452                let gx = ((fx * gw as f64) as usize).min(gw - 1);
453                let gy = ((fy * gh as f64) as usize).min(gh - 1);
454                all[gy * gw + gx] += 1;
455                if p.ringed {
456                    ringed[gy * gw + gx] += 1;
457                }
458            }
459            let most = all.iter().copied().max().unwrap_or(1).max(1) as f64;
460            let (cw, ch) = (pw / gw as f64, ph / gh as f64);
461            for (k, (&a, &r)) in all.iter().zip(&ringed).enumerate() {
462                if a == 0 {
463                    continue;
464                }
465                let (gx, gy) = ((k % gw) as f64, (k / gw) as f64);
466                let opacity = 0.2 + 0.8 * (a as f64).ln_1p() / most.ln_1p();
467                let colour = if r * 2 >= a { ACCENT } else { BAR };
468                c.shade(
469                    px + gx * cw,
470                    py + ph - (gy + 1.0) * ch,
471                    cw,
472                    ch,
473                    colour,
474                    opacity,
475                );
476            }
477        }
478        if let Some(v) = self.x_edge {
479            let ex = px + self.x.frac(v).clamp(0.0, 1.0) * pw;
480            c.dashed(ex, py, ex, py + ph, ACCENT, 1.1);
481        }
482        if let Some(v) = self.y_edge {
483            let ey = py + ph - self.y.frac(v).clamp(0.0, 1.0) * ph;
484            c.dashed(px, ey, px + pw, ey, ACCENT, 1.1);
485        }
486        x_axis(c, &self.x, (px, py, pw, ph), &self.x_title);
487        let mut ky = py + ph - 6.0 - 12.0 * self.key.len().saturating_sub(1) as f64;
488        for (colour, name) in &self.key {
489            let kx = px + pw - 8.0 - text_width(name, 8.0) - 12.0;
490            c.rect(kx, ky - 7.0, 8.0, 8.0, colour);
491            c.text(kx + 11.0, ky, name, 8.0, Anchor::Start, MUTED);
492            ky += 12.0;
493        }
494    }
495}
496
497/// The light-to-dark ramp a value from 0 to 1 takes, in five steps.
498pub fn ramp(f: f64) -> &'static str {
499    const STEPS: [&str; 5] = ["#bdcbdb", "#93a8bf", "#6984a2", "#3f5f84", "#18365c"];
500    STEPS[((f.clamp(0.0, 1.0) * 5.0) as usize).min(4)]
501}
502
503fn fontdb() -> Arc<usvg::fontdb::Database> {
504    static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
505    DB.get_or_init(|| {
506        let mut db = usvg::fontdb::Database::new();
507        db.load_system_fonts();
508        Arc::new(db)
509    })
510    .clone()
511}
512
513/// SVG parsing options, with the system fonts loaded once.
514fn options() -> usvg::Options<'static> {
515    usvg::Options {
516        fontdb: fontdb(),
517        ..usvg::Options::default()
518    }
519}
520
521/// `tree` rendered at `scale` pixels per point, on white.
522fn pixmap(tree: &usvg::Tree, scale: f32) -> anyhow::Result<resvg::tiny_skia::Pixmap> {
523    let size = tree
524        .size()
525        .to_int_size()
526        .scale_by(scale)
527        .ok_or_else(|| anyhow::anyhow!("figure has no size"))?;
528    let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height())
529        .ok_or_else(|| anyhow::anyhow!("figure too large to rasterise"))?;
530    pixmap.fill(resvg::tiny_skia::Color::WHITE);
531    resvg::render(
532        tree,
533        resvg::tiny_skia::Transform::from_scale(scale, scale),
534        &mut pixmap.as_mut(),
535    );
536    Ok(pixmap)
537}
538
539/// Pixels per point in a saved PNG.
540const PNG_SCALE: f32 = 2.0;
541
542/// `svg` as a PNG, `scale` pixels per point.
543pub fn png(svg: &str, scale: f32) -> anyhow::Result<Vec<u8>> {
544    let tree = usvg::Tree::from_str(svg, &options())?;
545    Ok(pixmap(&tree, scale)?.encode_png()?)
546}
547
548/// `svg` as an RGBA image, `scale` pixels per point.
549pub fn image(svg: &str, scale: f32) -> anyhow::Result<image::RgbaImage> {
550    let tree = usvg::Tree::from_str(svg, &options())?;
551    let pixmap = pixmap(&tree, scale)?;
552    let (w, h) = (pixmap.width(), pixmap.height());
553    image::RgbaImage::from_raw(w, h, pixmap.take())
554        .ok_or_else(|| anyhow::anyhow!("pixel buffer size mismatch"))
555}
556
557/// The files a figure saved under `name` is written as: `name` itself when
558/// it ends in `.pdf`, `.png` or `.svg`, else `name.pdf` and `name.png`.
559pub fn saved_paths(name: &str) -> Vec<String> {
560    if [".pdf", ".png", ".svg"].iter().any(|e| name.ends_with(e)) {
561        vec![name.to_string()]
562    } else {
563        vec![format!("{name}.pdf"), format!("{name}.png")]
564    }
565}
566
567/// Write `svg` under `name` (see [`saved_paths`]); the paths written. A
568/// file already there is never replaced: the save is refused instead.
569pub fn save(svg: &str, name: &str) -> anyhow::Result<Vec<String>> {
570    let paths = saved_paths(name);
571    if let Some(there) = paths.iter().find(|p| std::path::Path::new(p).exists()) {
572        anyhow::bail!("{there} is already there; not replacing it");
573    }
574    let tree = usvg::Tree::from_str(svg, &options())?;
575    for path in &paths {
576        if let Some(dir) = std::path::Path::new(path)
577            .parent()
578            .filter(|d| !d.as_os_str().is_empty())
579        {
580            std::fs::create_dir_all(dir)?;
581        }
582        if path.ends_with(".pdf") {
583            let pdf = svg2pdf::to_pdf(&tree, Default::default(), Default::default())
584                .map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?;
585            std::fs::write(path, pdf)?;
586        } else if path.ends_with(".png") {
587            std::fs::write(path, pixmap(&tree, PNG_SCALE)?.encode_png()?)?;
588        } else {
589            std::fs::write(path, svg)?;
590        }
591    }
592    Ok(paths)
593}
594
595/// Whether a save under `name` would replace a file already there.
596pub fn taken(name: &str) -> bool {
597    saved_paths(name)
598        .iter()
599        .any(|p| std::path::Path::new(p).exists())
600}
601
602/// `name`, or failing that the first of `{name}_2`, `{name}_3`, … that no
603/// save would replace a file under.
604pub fn free_name(name: &str) -> String {
605    let (stem, ext) = [".pdf", ".png", ".svg"]
606        .iter()
607        .find_map(|e| name.strip_suffix(e).map(|s| (s, *e)))
608        .unwrap_or((name, ""));
609    (1..)
610        .map(|n| match n {
611            1 => format!("{stem}{ext}"),
612            n => format!("{stem}_{n}{ext}"),
613        })
614        .find(|p| !taken(p))
615        .expect("some suffix is free")
616}
617
618#[cfg(test)]
619mod tests;