Skip to main content

overbrainer/runpod/
catalog.rs

1//! Views of the Runpod catalog: GPU types filtered and ordered cheapest first,
2//! data centers with the stock of chosen GPU types, and the resolution of a
3//! target's `auto` choices. Pure functions over what
4//! [`RunpodClient`](super::RunpodClient) lists.
5
6use std::cmp::Ordering;
7
8use crate::config::ListOrAuto;
9
10use super::RunpodTarget;
11use super::types::{Availability, DataCenter, GpuType, NetworkVolume, Stock, Template};
12
13/// What [`select_gpus`] keeps. The default keeps every Secure Cloud GPU type.
14#[derive(Debug, Clone, Default, PartialEq)]
15pub struct GpuFilter {
16    /// Least VRAM per GPU, in GB.
17    pub min_vram_gb: Option<u32>,
18    /// Highest Secure Cloud price of one GPU, in USD per hour; a GPU without a
19    /// price is dropped when set.
20    pub max_price: Option<f64>,
21    /// Only GPU types offered in this data center; with `in_stock`, stock is
22    /// judged there instead of overall.
23    pub data_center: Option<String>,
24    /// Only GPU types with some stock.
25    pub in_stock: bool,
26    /// Only GPU types whose Secure Cloud pod maximum covers this many GPUs.
27    pub gpu_count: Option<u32>,
28}
29
30impl GpuFilter {
31    /// Whether `gpu` passes every constraint.
32    #[must_use]
33    pub fn keeps(&self, gpu: &GpuType) -> bool {
34        gpu.on_secure_cloud()
35            && self.min_vram_gb.is_none_or(|min| gpu.memory >= min)
36            && self
37                .max_price
38                .is_none_or(|max| gpu.secure_price().is_some_and(|price| price <= max))
39            && self
40                .gpu_count
41                .is_none_or(|count| gpu.max_count.secure >= count)
42            && self.stock_ok(gpu)
43    }
44
45    fn stock_ok(&self, gpu: &GpuType) -> bool {
46        let stock = match &self.data_center {
47            Some(center) => {
48                if !gpu.data_centers.iter().any(|entry| &entry.id == center) {
49                    return false;
50                }
51                gpu.stock_in(center)
52            },
53            None => gpu.availability,
54        };
55        !self.in_stock || stock.is_in_stock()
56    }
57}
58
59/// Cheapest Secure Cloud price first (no price last), ties by more VRAM, then
60/// by ID.
61#[must_use]
62pub fn by_price(a: &GpuType, b: &GpuType) -> Ordering {
63    let price = match (a.secure_price(), b.secure_price()) {
64        (Some(x), Some(y)) => x.total_cmp(&y),
65        (Some(_), None) => Ordering::Less,
66        (None, Some(_)) => Ordering::Greater,
67        (None, None) => Ordering::Equal,
68    };
69    price
70        .then_with(|| b.memory.cmp(&a.memory))
71        .then_with(|| a.id.cmp(&b.id))
72}
73
74/// The GPU types `filter` keeps, cheapest first (see [`by_price`]).
75#[must_use]
76pub fn select_gpus(gpus: &[GpuType], filter: &GpuFilter) -> Vec<GpuType> {
77    let mut kept: Vec<GpuType> = gpus
78        .iter()
79        .filter(|gpu| filter.keeps(gpu))
80        .cloned()
81        .collect();
82    kept.sort_by(by_price);
83    kept
84}
85
86/// The rows of `overbrainer pod gpus`, header first, for `gpus` already
87/// filtered and sorted (see [`select_gpus`]); nothing when `gpus` is empty.
88/// `data_center`, when given, is shown as the stock column instead of the
89/// overall band (see [`GpuType::stock_in`]).
90#[must_use]
91pub fn gpu_table(gpus: &[GpuType], data_center: Option<&str>) -> Vec<String> {
92    if gpus.is_empty() {
93        return Vec::new();
94    }
95    let rows: Vec<Vec<String>> = gpus
96        .iter()
97        .map(|gpu| {
98            let price = gpu
99                .secure_price()
100                .map_or_else(|| "-".to_string(), |price| format!("{price:.2}"));
101            let stock = data_center.map_or(gpu.availability, |center| gpu.stock_in(center));
102            vec![
103                gpu.id.clone(),
104                gpu.memory.to_string(),
105                price,
106                gpu.max_count.secure.to_string(),
107                stock.name().to_string(),
108            ]
109        })
110        .collect();
111    columns(
112        &["ID", "VRAM GB", "$/H", "MAX COUNT", "STOCK"],
113        &[
114            Align::Left,
115            Align::Right,
116            Align::Right,
117            Align::Right,
118            Align::Left,
119        ],
120        &rows,
121    )
122}
123
124/// `text`, from the Runpod API, without what could move the cursor or change
125/// the terminal's state: ANSI escape sequences (CSI, string controls such as
126/// OSC or DCS through their terminator, and two-character ones) are dropped, a control character that is whitespace (newline, tab,
127/// CR...) becomes a space, and every other one (C0, DEL, C1) is dropped.
128#[must_use]
129pub fn printable(text: &str) -> String {
130    let mut kept = String::with_capacity(text.len());
131    let mut chars = text.chars().peekable();
132    while let Some(c) = chars.next() {
133        match c {
134            '\u{1b}' => match chars.next() {
135                Some('[') => skip_csi(&mut chars),
136                Some(']' | 'P' | 'X' | '^' | '_') => skip_string(&mut chars),
137                _ => {},
138            },
139            '\u{9b}' => skip_csi(&mut chars),
140            '\u{90}' | '\u{98}' | '\u{9d}' | '\u{9e}' | '\u{9f}' => skip_string(&mut chars),
141            c if c.is_control() && c.is_whitespace() => kept.push(' '),
142            c if c.is_control() => {},
143            c => kept.push(c),
144        }
145    }
146    kept
147}
148
149/// Skips a CSI sequence's parameters and intermediates, then its final byte.
150fn skip_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
151    while chars.next_if(|c| matches!(c, ' '..='?')).is_some() {}
152    chars.next_if(|c| matches!(c, '@'..='~'));
153}
154
155/// Skips a string control (OSC, DCS, SOS, PM or APC) up to its terminator:
156/// BEL, ST or `ESC \`; an unterminated one takes the rest of the text.
157fn skip_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
158    while let Some(c) = chars.next() {
159        match c {
160            '\u{7}' | '\u{9c}' => return,
161            '\u{1b}' => {
162                chars.next_if_eq(&'\\');
163                return;
164            },
165            _ => {},
166        }
167    }
168}
169
170/// Whether a [`columns`] column is aligned to the left or the right.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum Align {
173    Left,
174    Right,
175}
176
177/// `header` and `rows` laid out in columns two spaces apart, each as wide as
178/// its longest value, `align` giving each column's side; the last column is
179/// never padded. Each cell is [`printable`].
180fn columns(header: &[&str], align: &[Align], rows: &[Vec<String>]) -> Vec<String> {
181    let rows: Vec<Vec<String>> = rows
182        .iter()
183        .map(|row| row.iter().map(|cell| printable(cell)).collect())
184        .collect();
185    let mut widths: Vec<usize> = header.iter().map(|title| title.chars().count()).collect();
186    for row in &rows {
187        for (width, cell) in widths.iter_mut().zip(row) {
188            *width = (*width).max(cell.chars().count());
189        }
190    }
191    let line = |cells: Vec<&str>| {
192        let last = cells.len().saturating_sub(1);
193        let padded: Vec<String> = cells
194            .iter()
195            .zip(&widths)
196            .zip(align)
197            .enumerate()
198            .map(|(index, ((cell, width), side))| match side {
199                _ if index == last => (*cell).to_string(),
200                Align::Left => format!("{cell:<width$}"),
201                Align::Right => format!("{cell:>width$}"),
202            })
203            .collect();
204        padded.join("  ")
205    };
206    let mut lines = vec![line(header.to_vec())];
207    lines.extend(
208        rows.iter()
209            .map(|row| line(row.iter().map(String::as_str).collect())),
210    );
211    lines
212}
213
214/// A data center with the stock of chosen GPU types.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct DataCenterStock {
217    /// Its ID.
218    pub id: String,
219    /// Display name.
220    pub name: String,
221    /// Continental region.
222    pub region: String,
223    /// One entry per chosen GPU type, in the chosen order; `NONE` when the data
224    /// center does not offer it.
225    pub stock: Vec<Stock>,
226}
227
228impl DataCenterStock {
229    /// Whether at least one chosen GPU type is in stock here.
230    #[must_use]
231    pub fn in_stock(&self) -> bool {
232        self.stock
233            .iter()
234            .any(|entry| entry.availability.is_in_stock())
235    }
236}
237
238/// Every data center with the stock of each `chosen` GPU type, ordered by ID.
239#[must_use]
240pub fn data_center_stock<S: AsRef<str>>(
241    data_centers: &[DataCenter],
242    chosen: &[S],
243) -> Vec<DataCenterStock> {
244    let mut rows: Vec<DataCenterStock> = data_centers
245        .iter()
246        .map(|center| DataCenterStock {
247            id: center.id.clone(),
248            name: center.name.clone(),
249            region: center.region.clone(),
250            stock: chosen
251                .iter()
252                .map(|gpu| {
253                    let gpu = gpu.as_ref();
254                    center
255                        .gpu_availability
256                        .iter()
257                        .find(|entry| entry.id == gpu)
258                        .cloned()
259                        .unwrap_or_else(|| Stock {
260                            id: gpu.to_string(),
261                            name: String::new(),
262                            availability: Availability::None,
263                        })
264                })
265                .collect(),
266        })
267        .collect();
268    rows.sort_by(|a, b| a.id.cmp(&b.id));
269    rows
270}
271
272/// The rows of `overbrainer pod datacenters`, header first, sorted by ID;
273/// nothing when `data_centers` is empty. The stock column counts the GPU
274/// types `data_centers` reports in stock there, not a chosen set.
275#[must_use]
276pub fn data_center_table(data_centers: &[DataCenter]) -> Vec<String> {
277    if data_centers.is_empty() {
278        return Vec::new();
279    }
280    let mut rows: Vec<&DataCenter> = data_centers.iter().collect();
281    rows.sort_by(|a, b| a.id.cmp(&b.id));
282    let rows: Vec<Vec<String>> = rows
283        .into_iter()
284        .map(|center| {
285            let in_stock = center
286                .gpu_availability
287                .iter()
288                .filter(|entry| entry.availability.is_in_stock())
289                .count();
290            vec![
291                center.id.clone(),
292                center.name.clone(),
293                center.region.clone(),
294                in_stock.to_string(),
295            ]
296        })
297        .collect();
298    columns(
299        &["ID", "NAME", "REGION", "GPU TYPES IN STOCK"],
300        &[Align::Left; 4],
301        &rows,
302    )
303}
304
305/// The data centers with at least one `chosen` GPU type in stock, ordered by
306/// the cheapest such GPU type they have (see [`by_price`]), ties by ID.
307///
308/// Built only from each chosen GPU's own `data_centers` (as
309/// [`RunpodClient::list_gpu_types`](super::RunpodClient::list_gpu_types)
310/// scopes it to a GPU count), never from the unscoped `catalog/datacenters`
311/// stock: a data center is picked only when the GPU itself reports stock
312/// there for that count.
313#[must_use]
314pub fn stocked_data_centers<S: AsRef<str>>(gpus: &[GpuType], chosen: &[S]) -> Vec<DataCenterStock> {
315    let chosen_gpus: Vec<&GpuType> = chosen
316        .iter()
317        .filter_map(|id| gpus.iter().find(|gpu| gpu.id == id.as_ref()))
318        .collect();
319    let mut ids: Vec<(String, String)> = Vec::new();
320    for gpu in &chosen_gpus {
321        for entry in &gpu.data_centers {
322            if entry.availability.is_in_stock() && !ids.iter().any(|(id, _)| *id == entry.id) {
323                ids.push((entry.id.clone(), entry.name.clone()));
324            }
325        }
326    }
327    let cheapest = |row: &DataCenterStock| -> Option<&GpuType> {
328        row.stock
329            .iter()
330            .filter(|entry| entry.availability.is_in_stock())
331            .filter_map(|entry| gpus.iter().find(|gpu| gpu.id == entry.id))
332            .min_by(|a, b| by_price(a, b))
333    };
334    let mut rows: Vec<DataCenterStock> = ids
335        .into_iter()
336        .map(|(id, name)| DataCenterStock {
337            stock: chosen
338                .iter()
339                .map(|gpu_id| gpu_stock_at(&chosen_gpus, gpu_id.as_ref(), &id))
340                .collect(),
341            id,
342            name,
343            region: String::new(),
344        })
345        .collect();
346    rows.sort_by(|a, b| {
347        let order = match (cheapest(a), cheapest(b)) {
348            (Some(x), Some(y)) => by_price(x, y),
349            (Some(_), None) => Ordering::Less,
350            (None, Some(_)) => Ordering::Greater,
351            (None, None) => Ordering::Equal,
352        };
353        order.then_with(|| a.id.cmp(&b.id))
354    });
355    rows
356}
357
358/// `gpu_id`'s own stock at the data center `center_id`, `NONE` when that GPU
359/// (among `chosen_gpus`) does not report it. `id` names the GPU, not the data
360/// center, to match [`DataCenterStock::stock`]'s "one entry per chosen GPU".
361fn gpu_stock_at(chosen_gpus: &[&GpuType], gpu_id: &str, center_id: &str) -> Stock {
362    let availability = chosen_gpus
363        .iter()
364        .find(|gpu| gpu.id == gpu_id)
365        .and_then(|gpu| gpu.data_centers.iter().find(|entry| entry.id == center_id))
366        .map_or(Availability::None, |entry| entry.availability);
367    Stock {
368        id: gpu_id.to_string(),
369        name: String::new(),
370        availability,
371    }
372}
373
374/// Why an `auto` choice of a Runpod target found nothing: each message says
375/// what was asked.
376#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
377pub enum ResolveError {
378    /// No GPU type in stock matches `gpu_types = "auto"`.
379    #[error("no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" ({asked})")]
380    NoGpuType {
381        /// The target's fields that narrowed the choice, as `name = value`.
382        asked: String,
383    },
384    /// No data center has a chosen GPU type in stock for
385    /// `data_center_ids = "auto"`.
386    #[error(
387        "no data center has {gpu_types} in stock for data_center_ids = \"auto\" \
388         (gpu_count = {gpu_count})"
389    )]
390    NoDataCenter {
391        /// The chosen GPU types, as `a, b or c`.
392        gpu_types: String,
393        /// GPUs per pod.
394        gpu_count: u32,
395    },
396}
397
398/// `target` with its `auto` choices replaced by lists, from the catalog's
399/// `gpus`, listed for the target's `gpu_count`; listed choices are kept as
400/// they are.
401///
402/// `auto` GPU types are those in stock whose Secure Cloud pod maximum covers
403/// `gpu_count`, within `min_vram_gb` and `max_price_per_hour`, in stock in one
404/// of the listed data centers when there are any, cheapest first (see
405/// [`by_price`]). `auto` data centers are those with a chosen GPU type in
406/// stock for that count, ordered by the cheapest one they have (see
407/// [`stocked_data_centers`]).
408///
409/// # Errors
410///
411/// Returns a [`ResolveError`] saying what was asked when nothing in stock
412/// matches.
413pub fn resolve(target: &RunpodTarget, gpus: &[GpuType]) -> Result<RunpodTarget, ResolveError> {
414    resolve_with_floor(target, gpus, None)
415}
416
417/// [`resolve`], `auto` GPU types keeping only those with at least
418/// `floor_gb` of VRAM (the estimate of what the run needs, see
419/// [`crate::train::sizing`]) when the target sets no `min_vram_gb`: an
420/// explicit `min_vram_gb` wins.
421///
422/// # Errors
423///
424/// Returns a [`ResolveError`] saying what was asked when nothing in stock
425/// matches.
426pub fn resolve_with_floor(
427    target: &RunpodTarget,
428    gpus: &[GpuType],
429    floor_gb: Option<u32>,
430) -> Result<RunpodTarget, ResolveError> {
431    let mut resolved = target.clone();
432    let floor = floor_gb.filter(|_| target.min_vram_gb.is_none());
433    if target.gpu_types.is_auto() {
434        let filter = GpuFilter {
435            min_vram_gb: target.min_vram_gb.or(floor),
436            max_price: target.max_price_per_hour,
437            data_center: None,
438            in_stock: true,
439            gpu_count: Some(target.gpu_count),
440        };
441        let listed = target.data_center_ids.list();
442        let chosen: Vec<String> = select_gpus(gpus, &filter)
443            .into_iter()
444            .filter(|gpu| {
445                listed.is_empty()
446                    || listed
447                        .iter()
448                        .any(|center| gpu.stock_in(center).is_in_stock())
449            })
450            .map(|gpu| gpu.id)
451            .collect();
452        if chosen.is_empty() {
453            return Err(ResolveError::NoGpuType {
454                asked: asked(target, floor),
455            });
456        }
457        resolved.gpu_types = ListOrAuto::List(chosen);
458    }
459    if target.data_center_ids.is_auto() {
460        let chosen = resolved.gpu_types.list();
461        let centers: Vec<String> = stocked_data_centers(gpus, chosen)
462            .into_iter()
463            .map(|row| row.id)
464            .collect();
465        if centers.is_empty() {
466            return Err(ResolveError::NoDataCenter {
467                gpu_types: or_list(chosen),
468                gpu_count: target.gpu_count,
469            });
470        }
471        resolved.data_center_ids = ListOrAuto::List(centers);
472    }
473    Ok(resolved)
474}
475
476/// A GPU type in stock and what a pod of it costs.
477#[derive(Debug, Clone, PartialEq)]
478pub struct GpuOffer {
479    /// The GPU type's ID.
480    pub gpu_type: String,
481    /// Secure Cloud price of the pod, all its GPUs, in USD per hour.
482    pub per_hour: f64,
483}
484
485/// The cheapest GPU type in stock a pod of `target` would get, from the
486/// catalog `gpus`, priced for `target.gpu_count` GPUs, as `overbrainer pod
487/// gpus` lists prices: its `auto` GPU types as [`resolve_with_floor`] picks
488/// them (with `floor_gb`), else its listed ones. `None` when none is in stock
489/// with a price.
490#[must_use]
491pub fn cheapest_in_stock(
492    target: &RunpodTarget,
493    gpus: &[GpuType],
494    floor_gb: Option<u32>,
495) -> Option<GpuOffer> {
496    let resolved = resolve_with_floor(target, gpus, floor_gb).ok()?;
497    let wanted = resolved.gpu_types.list();
498    let filter = GpuFilter {
499        in_stock: true,
500        gpu_count: Some(target.gpu_count),
501        ..GpuFilter::default()
502    };
503    select_gpus(gpus, &filter)
504        .into_iter()
505        .filter(|gpu| wanted.contains(&gpu.id))
506        .find_map(|gpu| {
507            gpu.secure_price().map(|price| GpuOffer {
508                per_hour: price * f64::from(target.gpu_count),
509                gpu_type: gpu.id,
510            })
511        })
512}
513
514/// Most GPU types reported out of stock that one walk over `auto` GPU types
515/// tries (see [`walk_order`]).
516pub(crate) const MAX_OUT_OF_STOCK: usize = 3;
517
518/// The order in which a walk tries the `auto` GPU types of a target.
519#[derive(Debug, Clone, PartialEq)]
520pub(crate) struct WalkOrder {
521    /// The target with its `auto` choices resolved: the GPU types reported out
522    /// of stock first, then those in stock not tried yet.
523    pub target: RunpodTarget,
524    /// The GPU types of `target` reported out of stock.
525    pub out_of_stock: Vec<String>,
526}
527
528/// [`resolve_with_floor`] for a walk that already tried the GPU types `tried`:
529/// with `auto` GPU types, those in stock not tried yet, cheapest first, come
530/// after at most `cap` GPU types reported out of stock that are cheaper than
531/// the cheapest of them and meet every other limit (`max_price_per_hour`,
532/// `min_vram_gb` or `floor_gb`, `gpu_count`), cheapest first. Stock reports
533/// change within seconds, and a type reported out of stock can still be
534/// placed: trying one costs a single refused create call.
535///
536/// With listed data centers, a type out of stock is kept when it lists one of
537/// them or lists none (Runpod gives no data center for a type out of stock
538/// everywhere); the create call still names the listed ones. `auto` data
539/// centers are those with an untried type in stock. A target with listed GPU
540/// types is returned as [`resolve_with_floor`] gives it.
541///
542/// # Errors
543///
544/// Returns a [`ResolveError`] when nothing in stock matches, or when every
545/// type in stock was tried already.
546pub(crate) fn walk_order(
547    target: &RunpodTarget,
548    gpus: &[GpuType],
549    floor_gb: Option<u32>,
550    tried: &[String],
551    cap: usize,
552) -> Result<WalkOrder, ResolveError> {
553    let mut resolved = resolve_with_floor(target, gpus, floor_gb)?;
554    if !target.gpu_types.is_auto() {
555        return Ok(WalkOrder {
556            target: resolved,
557            out_of_stock: Vec::new(),
558        });
559    }
560    let in_stock = resolved.gpu_types.list().to_vec();
561    let untried: Vec<String> = in_stock
562        .iter()
563        .filter(|id| !tried.contains(id))
564        .cloned()
565        .collect();
566    let floor = floor_gb.filter(|_| target.min_vram_gb.is_none());
567    let Some(cheapest) = untried.first() else {
568        return Err(ResolveError::NoGpuType {
569            asked: asked(target, floor),
570        });
571    };
572    let below = gpus
573        .iter()
574        .find(|gpu| &gpu.id == cheapest)
575        .and_then(GpuType::secure_price);
576    let out_of_stock: Vec<String> = match below {
577        None => Vec::new(),
578        Some(below) => {
579            let filter = GpuFilter {
580                min_vram_gb: target.min_vram_gb.or(floor),
581                max_price: target.max_price_per_hour,
582                data_center: None,
583                in_stock: false,
584                gpu_count: Some(target.gpu_count),
585            };
586            let listed = target.data_center_ids.list();
587            select_gpus(gpus, &filter)
588                .into_iter()
589                .filter(|gpu| !in_stock.contains(&gpu.id) && !tried.contains(&gpu.id))
590                .filter(|gpu| gpu.secure_price().is_some_and(|price| price < below))
591                .filter(|gpu| {
592                    listed.is_empty()
593                        || gpu.data_centers.is_empty()
594                        || gpu
595                            .data_centers
596                            .iter()
597                            .any(|entry| listed.contains(&entry.id))
598                })
599                .take(cap)
600                .map(|gpu| gpu.id)
601                .collect()
602        },
603    };
604    if target.data_center_ids.is_auto() {
605        let centers: Vec<String> = stocked_data_centers(gpus, &untried)
606            .into_iter()
607            .map(|row| row.id)
608            .collect();
609        if centers.is_empty() {
610            return Err(ResolveError::NoDataCenter {
611                gpu_types: or_list(&untried),
612                gpu_count: target.gpu_count,
613            });
614        }
615        resolved.data_center_ids = ListOrAuto::List(centers);
616    }
617    resolved.gpu_types = ListOrAuto::List(out_of_stock.iter().chain(&untried).cloned().collect());
618    Ok(WalkOrder {
619        target: resolved,
620        out_of_stock,
621    })
622}
623
624/// What an `auto` choice of GPU types asked for, as the target's fields,
625/// with the estimated VRAM `floor` when it applied.
626fn asked(target: &RunpodTarget, floor: Option<u32>) -> String {
627    let mut asked = vec![format!("gpu_count = {}", target.gpu_count)];
628    if let Some(gb) = target.min_vram_gb {
629        asked.push(format!("min_vram_gb = {gb}"));
630    }
631    if let Some(gb) = floor {
632        asked.push(format!("at least {gb} GB of VRAM estimated for the model"));
633    }
634    if let Some(price) = target.max_price_per_hour {
635        asked.push(format!("max_price_per_hour = {price}"));
636    }
637    if let ListOrAuto::List(centers) = &target.data_center_ids
638        && !centers.is_empty()
639    {
640        asked.push(format!("data_center_ids = {}", centers.join(", ")));
641    }
642    asked.join(", ")
643}
644
645/// `a`, `a or b`, `a, b or c`.
646fn or_list(items: &[String]) -> String {
647    match items.split_last() {
648        None => String::new(),
649        Some((last, [])) => last.clone(),
650        Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
651    }
652}
653
654/// The rows of `overbrainer pod volumes`, header first, sorted by name;
655/// nothing when `volumes` is empty.
656#[must_use]
657pub fn volume_table(volumes: &[NetworkVolume]) -> Vec<String> {
658    if volumes.is_empty() {
659        return Vec::new();
660    }
661    let mut rows: Vec<&NetworkVolume> = volumes.iter().collect();
662    rows.sort_by(|a, b| a.name.cmp(&b.name));
663    let rows: Vec<Vec<String>> = rows
664        .into_iter()
665        .map(|volume| {
666            vec![
667                volume.id.clone(),
668                volume.name.clone(),
669                volume.size.to_string(),
670                volume.data_center.clone(),
671            ]
672        })
673        .collect();
674    columns(
675        &["ID", "NAME", "SIZE GB", "DATA CENTER"],
676        &[Align::Left, Align::Left, Align::Right, Align::Left],
677        &rows,
678    )
679}
680
681/// The rows of `overbrainer pod templates`, header first, sorted by name;
682/// nothing when `templates` is empty.
683#[must_use]
684pub fn template_table(templates: &[Template]) -> Vec<String> {
685    if templates.is_empty() {
686        return Vec::new();
687    }
688    let mut rows: Vec<&Template> = templates.iter().collect();
689    rows.sort_by(|a, b| a.name.cmp(&b.name));
690    let rows: Vec<Vec<String>> = rows
691        .into_iter()
692        .map(|template| {
693            vec![
694                template.id.clone(),
695                template.name.clone(),
696                template.image.clone(),
697            ]
698        })
699        .collect();
700    columns(&["ID", "NAME", "IMAGE"], &[Align::Left; 3], &rows)
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::config::ListOrAuto;
707    use crate::runpod::types::{GpuMaxCount, GpuPrice};
708
709    fn stock(id: &str, availability: Availability) -> Stock {
710        Stock {
711            id: id.to_string(),
712            name: String::new(),
713            availability,
714        }
715    }
716
717    fn gpu(id: &str, memory: u32, price: Option<f64>) -> GpuType {
718        GpuType {
719            id: id.to_string(),
720            memory,
721            price: GpuPrice {
722                secure: price,
723                community: None,
724            },
725            max_count: GpuMaxCount {
726                secure: 8,
727                community: 0,
728            },
729            availability: Availability::High,
730            ..GpuType::default()
731        }
732    }
733
734    /// A Runpod target with the given GPU types and data centers and no other limit.
735    fn target(gpu_types: ListOrAuto, data_center_ids: ListOrAuto) -> RunpodTarget {
736        RunpodTarget {
737            gpu_types,
738            min_vram_gb: None,
739            max_price_per_hour: None,
740            gpu_count: 1,
741            image: "img".to_string(),
742            venv: Some("/venv".to_string()),
743            container_disk_gb: 50,
744            max_hours: 1.0,
745            max_cost_usd: None,
746            boot_grace: std::time::Duration::from_mins(30),
747            retrieve_grace: std::time::Duration::from_secs(3600),
748            data_center_ids,
749            network_volume_id: None,
750            max_volume_gb: None,
751            ssh_client: crate::config::SshClient::Openssh,
752        }
753    }
754
755    fn list(items: &[&str]) -> ListOrAuto {
756        ListOrAuto::List(items.iter().map(|item| (*item).to_string()).collect())
757    }
758
759    fn ids(gpus: &[GpuType]) -> Vec<&str> {
760        gpus.iter().map(|gpu| gpu.id.as_str()).collect()
761    }
762
763    fn center(id: &str, gpus: Vec<Stock>) -> DataCenter {
764        DataCenter {
765            id: id.to_string(),
766            name: format!("{id} name"),
767            region: "EUROPE".to_string(),
768            gpu_availability: gpus,
769        }
770    }
771
772    #[test]
773    fn gpus_are_cheapest_first_ties_by_more_vram_then_id() {
774        let gpus = [
775            gpu("none", 80, None),
776            gpu("b", 24, Some(0.5)),
777            gpu("big", 48, Some(0.5)),
778            gpu("a", 24, Some(0.5)),
779            gpu("cheap", 16, Some(0.2)),
780        ];
781        let sorted = select_gpus(&gpus, &GpuFilter::default());
782        assert_eq!(ids(&sorted), vec!["cheap", "big", "a", "b", "none"]);
783    }
784
785    #[test]
786    fn a_zero_price_is_unknown_neither_first_nor_under_a_limit() {
787        let gpus = [gpu("free", 24, Some(0.0)), gpu("paid", 24, Some(0.5))];
788        let sorted = select_gpus(&gpus, &GpuFilter::default());
789        assert_eq!(ids(&sorted), vec!["paid", "free"]);
790        let capped = GpuFilter {
791            max_price: Some(1.0),
792            ..GpuFilter::default()
793        };
794        assert_eq!(ids(&select_gpus(&gpus, &capped)), vec!["paid"]);
795    }
796
797    #[test]
798    fn vram_and_price_limits_filter() {
799        let gpus = [
800            gpu("small", 16, Some(0.2)),
801            gpu("mid", 48, Some(0.8)),
802            gpu("dear", 80, Some(2.5)),
803            gpu("unpriced", 80, None),
804        ];
805        let filter = GpuFilter {
806            min_vram_gb: Some(40),
807            max_price: Some(1.0),
808            ..GpuFilter::default()
809        };
810        assert_eq!(ids(&select_gpus(&gpus, &filter)), vec!["mid"]);
811        let vram_only = GpuFilter {
812            min_vram_gb: Some(48),
813            ..GpuFilter::default()
814        };
815        assert_eq!(
816            ids(&select_gpus(&gpus, &vram_only)),
817            vec!["mid", "dear", "unpriced"]
818        );
819    }
820
821    #[test]
822    fn stock_and_gpu_count_filter() {
823        let mut out = gpu("out", 24, Some(0.1));
824        out.availability = Availability::None;
825        let mut unknown = gpu("unknown", 24, Some(0.2));
826        unknown.availability = Availability::Unknown;
827        let mut single = gpu("single", 24, Some(0.3));
828        single.max_count.secure = 1;
829        let gpus = [out, unknown, single, gpu("ok", 24, Some(0.4))];
830        let in_stock = GpuFilter {
831            in_stock: true,
832            ..GpuFilter::default()
833        };
834        assert_eq!(ids(&select_gpus(&gpus, &in_stock)), vec!["single", "ok"]);
835        let two = GpuFilter {
836            in_stock: true,
837            gpu_count: Some(2),
838            ..GpuFilter::default()
839        };
840        assert_eq!(ids(&select_gpus(&gpus, &two)), vec!["ok"]);
841    }
842
843    #[test]
844    fn community_only_gpus_are_dropped() {
845        let mut community = gpu("community", 24, Some(0.1));
846        community.secure = Some(false);
847        let gpus = [community, gpu("secure", 24, Some(0.2))];
848        assert_eq!(
849            ids(&select_gpus(&gpus, &GpuFilter::default())),
850            vec!["secure"]
851        );
852    }
853
854    #[test]
855    fn a_data_center_filter_judges_stock_there() {
856        let mut here = gpu("here", 24, Some(0.3));
857        here.data_centers = vec![stock("EU-RO-1", Availability::Low)];
858        let mut empty_here = gpu("empty-here", 24, Some(0.2));
859        empty_here.data_centers = vec![stock("EU-RO-1", Availability::None)];
860        let mut elsewhere = gpu("elsewhere", 24, Some(0.1));
861        elsewhere.data_centers = vec![stock("US-KS-2", Availability::High)];
862        let gpus = [here, empty_here, elsewhere];
863        let offered = GpuFilter {
864            data_center: Some("EU-RO-1".to_string()),
865            ..GpuFilter::default()
866        };
867        assert_eq!(
868            ids(&select_gpus(&gpus, &offered)),
869            vec!["empty-here", "here"]
870        );
871        let stocked = GpuFilter {
872            in_stock: true,
873            ..offered
874        };
875        assert_eq!(ids(&select_gpus(&gpus, &stocked)), vec!["here"]);
876    }
877
878    #[test]
879    fn data_centers_show_the_chosen_gpus_stock_in_order() {
880        let centers = [
881            center("US-KS-2", vec![stock("A", Availability::High)]),
882            center(
883                "EU-RO-1",
884                vec![
885                    stock("B", Availability::Medium),
886                    stock("A", Availability::None),
887                ],
888            ),
889        ];
890        let rows = data_center_stock(&centers, &["B", "A", "C"]);
891        let row_ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
892        assert_eq!(row_ids, vec!["EU-RO-1", "US-KS-2"]);
893        let bands: Vec<Availability> = rows[0].stock.iter().map(|s| s.availability).collect();
894        assert_eq!(
895            bands,
896            vec![Availability::Medium, Availability::None, Availability::None]
897        );
898        assert_eq!(rows[1].stock[0].id, "B");
899        assert!(rows[0].in_stock() && rows[1].in_stock());
900        assert!(!data_center_stock(&centers, &["C"])[0].in_stock());
901        assert_eq!(rows[0].name, "EU-RO-1 name");
902        assert_eq!(rows[0].region, "EUROPE");
903    }
904
905    #[test]
906    fn stocked_data_centers_are_ordered_by_their_cheapest_chosen_gpu() {
907        let mut cheap = gpu("cheap", 24, Some(0.2));
908        cheap.data_centers = vec![
909            stock("B-EMPTY", Availability::None),
910            stock("C-CHEAP", Availability::Low),
911            stock("D-CHEAP", Availability::High),
912        ];
913        let mut dear = gpu("dear", 80, Some(1.5));
914        dear.data_centers = vec![
915            stock("A-DEAR", Availability::High),
916            stock("B-EMPTY", Availability::None),
917            stock("C-CHEAP", Availability::High),
918        ];
919        // "E-OTHER" is not in either chosen GPU's own list, unlike an unscoped
920        // catalog/datacenters listing might show: it must never appear.
921        let gpus = [cheap, dear];
922        let rows = stocked_data_centers(&gpus, &["dear", "cheap"]);
923        let row_ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
924        assert_eq!(row_ids, vec!["C-CHEAP", "D-CHEAP", "A-DEAR"]);
925    }
926
927    #[test]
928    fn listed_choices_are_kept_as_they_are() {
929        let listed = target(list(&["B", "A"]), list(&["EU-RO-1"]));
930        assert_eq!(resolve(&listed, &[]), Ok(listed.clone()));
931        let any = target(list(&["A"]), ListOrAuto::default());
932        assert_eq!(resolve(&any, &[]), Ok(any.clone()));
933    }
934
935    #[test]
936    fn auto_gpus_are_those_in_stock_cheapest_first_ties_by_more_vram() {
937        let mut out = gpu("out", 80, Some(0.1));
938        out.availability = Availability::None;
939        let mut single = gpu("single", 80, Some(0.2));
940        single.max_count.secure = 1;
941        let mut community = gpu("community", 80, Some(0.2));
942        community.secure = Some(false);
943        let gpus = [
944            out,
945            single,
946            community,
947            gpu("dear", 80, Some(1.0)),
948            gpu("a", 24, Some(0.5)),
949            gpu("big", 48, Some(0.5)),
950        ];
951        let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
952        auto.gpu_count = 2;
953        let resolved = resolve(&auto, &gpus);
954        assert_eq!(
955            resolved.map(|target| target.gpu_types),
956            Ok(list(&["big", "a", "dear"]))
957        );
958    }
959
960    /// The cheapest GPU type in stock a target would get prices its pod, for
961    /// its GPU count: `auto` within its limits and the VRAM floor, a list
962    /// among its own types.
963    #[test]
964    fn the_cheapest_gpu_in_stock_prices_the_pod() {
965        let mut out = gpu("out", 80, Some(0.1));
966        out.availability = Availability::None;
967        let gpus = [
968            out,
969            gpu("small", 16, Some(0.2)),
970            gpu("mid", 48, Some(0.8)),
971            gpu("unpriced", 80, None),
972        ];
973        let offer = |gpu_type: &str, per_hour| {
974            Some(GpuOffer {
975                gpu_type: gpu_type.to_string(),
976                per_hour,
977            })
978        };
979        let auto = target(ListOrAuto::Auto, ListOrAuto::default());
980        assert_eq!(cheapest_in_stock(&auto, &gpus, None), offer("small", 0.2));
981        assert_eq!(cheapest_in_stock(&auto, &gpus, Some(20)), offer("mid", 0.8));
982        let mut listed = target(list(&["out", "unpriced", "mid"]), ListOrAuto::default());
983        listed.gpu_count = 2;
984        assert_eq!(cheapest_in_stock(&listed, &gpus, None), offer("mid", 1.6));
985        let sold_out = target(list(&["out", "unpriced"]), ListOrAuto::default());
986        assert_eq!(cheapest_in_stock(&sold_out, &gpus, None), None);
987    }
988
989    #[test]
990    fn auto_gpus_meet_the_vram_and_price_limits() {
991        let gpus = [
992            gpu("small", 16, Some(0.2)),
993            gpu("mid", 48, Some(0.8)),
994            gpu("dear", 80, Some(2.5)),
995            gpu("unpriced", 80, None),
996        ];
997        let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
998        auto.min_vram_gb = Some(40);
999        assert_eq!(
1000            resolve(&auto, &gpus).map(|target| target.gpu_types),
1001            Ok(list(&["mid", "dear", "unpriced"]))
1002        );
1003        auto.max_price_per_hour = Some(1.0);
1004        assert_eq!(
1005            resolve(&auto, &gpus).map(|target| target.gpu_types),
1006            Ok(list(&["mid"]))
1007        );
1008    }
1009
1010    #[test]
1011    fn the_estimated_floor_applies_only_without_min_vram_gb() {
1012        let gpus = [
1013            gpu("small", 16, Some(0.2)),
1014            gpu("mid", 48, Some(0.8)),
1015            gpu("dear", 80, Some(2.5)),
1016        ];
1017        let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
1018        let picked = |target: &RunpodTarget, floor| {
1019            resolve_with_floor(target, &gpus, floor).map(|target| target.gpu_types)
1020        };
1021        assert_eq!(picked(&auto, Some(20)), Ok(list(&["mid", "dear"])));
1022        assert_eq!(picked(&auto, None), Ok(list(&["small", "mid", "dear"])));
1023        // The floor is used, never saved into the resolved target.
1024        assert_eq!(
1025            resolve_with_floor(&auto, &gpus, Some(20)).map(|target| target.min_vram_gb),
1026            Ok(None)
1027        );
1028        auto.min_vram_gb = Some(10);
1029        assert_eq!(picked(&auto, Some(60)), Ok(list(&["small", "mid", "dear"])));
1030        auto.min_vram_gb = None;
1031        assert_eq!(
1032            picked(&auto, Some(100)),
1033            Err(ResolveError::NoGpuType {
1034                asked: "gpu_count = 1, at least 100 GB of VRAM estimated for the model".into()
1035            })
1036        );
1037        let listed = target(list(&["small"]), ListOrAuto::default());
1038        assert_eq!(picked(&listed, Some(60)), Ok(list(&["small"])));
1039    }
1040
1041    #[test]
1042    fn auto_gpus_are_in_stock_in_a_listed_data_center() {
1043        let mut here = gpu("here", 24, Some(0.3));
1044        here.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1045        let mut empty_here = gpu("empty-here", 24, Some(0.2));
1046        empty_here.data_centers = vec![stock("EU-RO-1", Availability::None)];
1047        let mut elsewhere = gpu("elsewhere", 24, Some(0.1));
1048        elsewhere.data_centers = vec![stock("US-KS-2", Availability::High)];
1049        let gpus = [here, empty_here, elsewhere];
1050        let auto = target(ListOrAuto::Auto, list(&["EU-RO-1"]));
1051        let resolved = resolve(&auto, &gpus);
1052        assert_eq!(
1053            resolved.clone().map(|target| target.gpu_types),
1054            Ok(list(&["here"]))
1055        );
1056        assert_eq!(
1057            resolved.map(|target| target.data_center_ids),
1058            Ok(list(&["EU-RO-1"]))
1059        );
1060    }
1061
1062    #[test]
1063    fn auto_data_centers_have_a_chosen_gpu_in_stock_cheapest_first() {
1064        let mut cheap = gpu("cheap", 24, Some(0.2));
1065        cheap.data_centers = vec![
1066            stock("B-EMPTY", Availability::None),
1067            stock("C-CHEAP", Availability::Low),
1068        ];
1069        let mut dear = gpu("dear", 80, Some(1.5));
1070        dear.data_centers = vec![stock("A-DEAR", Availability::High)];
1071        let gpus = [cheap, dear];
1072        let listed = target(list(&["dear", "cheap"]), ListOrAuto::Auto);
1073        let resolved = resolve(&listed, &gpus);
1074        assert_eq!(
1075            resolved.clone().map(|target| target.data_center_ids),
1076            Ok(list(&["C-CHEAP", "A-DEAR"]))
1077        );
1078        assert_eq!(
1079            resolved.map(|target| target.gpu_types),
1080            Ok(list(&["dear", "cheap"]))
1081        );
1082        let both = target(ListOrAuto::Auto, ListOrAuto::Auto);
1083        let resolved = resolve(&both, &gpus);
1084        assert_eq!(
1085            resolved.clone().map(|target| target.gpu_types),
1086            Ok(list(&["cheap", "dear"]))
1087        );
1088        assert_eq!(
1089            resolved.map(|target| target.data_center_ids),
1090            Ok(list(&["C-CHEAP", "A-DEAR"]))
1091        );
1092    }
1093
1094    #[test]
1095    fn nothing_in_stock_says_what_was_asked() {
1096        let mut out = gpu("out", 80, Some(0.1));
1097        out.availability = Availability::None;
1098        let gpus = [out, gpu("small", 16, Some(0.2))];
1099        let mut auto = target(ListOrAuto::Auto, list(&["EU-RO-1", "US-KS-2"]));
1100        auto.gpu_count = 2;
1101        auto.min_vram_gb = Some(48);
1102        auto.max_price_per_hour = Some(0.5);
1103        assert_eq!(
1104            resolve(&auto, &gpus).map_err(|error| error.to_string()),
1105            Err(
1106                "no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" \
1107                 (gpu_count = 2, min_vram_gb = 48, max_price_per_hour = 0.5, \
1108                 data_center_ids = EU-RO-1, US-KS-2)"
1109                    .to_string()
1110            )
1111        );
1112        let plain = target(ListOrAuto::Auto, ListOrAuto::default());
1113        assert_eq!(
1114            resolve(&plain, &[]).map_err(|error| error.to_string()),
1115            Err(
1116                "no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" \
1117                 (gpu_count = 1)"
1118                    .to_string()
1119            )
1120        );
1121        let mut a = gpu("A", 24, Some(0.3));
1122        a.data_centers = vec![stock("EU-RO-1", Availability::None)];
1123        let listed = target(list(&["A", "B"]), ListOrAuto::Auto);
1124        assert_eq!(
1125            resolve(&listed, &[a]).map_err(|error| error.to_string()),
1126            Err(
1127                "no data center has A or B in stock for data_center_ids = \"auto\" \
1128                 (gpu_count = 1)"
1129                    .to_string()
1130            )
1131        );
1132    }
1133
1134    #[test]
1135    fn gpu_table_is_empty_without_rows() {
1136        assert_eq!(gpu_table(&[], None), [] as [String; 0]);
1137    }
1138
1139    #[test]
1140    fn gpu_table_lists_price_vram_count_and_overall_stock() {
1141        let mut a40 = gpu("NVIDIA A40", 48, Some(0.4));
1142        a40.availability = Availability::High;
1143        let mut cheap = gpu("cheap", 16, None);
1144        cheap.availability = Availability::Low;
1145        let lines = gpu_table(&[a40, cheap], None);
1146        assert_eq!(lines.len(), 3, "{lines:?}");
1147        assert!(
1148            lines[0].starts_with("ID") && lines[0].ends_with("STOCK"),
1149            "{}",
1150            lines[0]
1151        );
1152        assert!(
1153            lines[1].starts_with("NVIDIA A40")
1154                && lines[1].contains("48")
1155                && lines[1].contains("0.40")
1156                && lines[1].ends_with("HIGH"),
1157            "{}",
1158            lines[1]
1159        );
1160        assert!(
1161            lines[2].starts_with("cheap") && lines[2].ends_with("LOW"),
1162            "{}",
1163            lines[2]
1164        );
1165    }
1166
1167    #[test]
1168    fn gpu_table_shows_stock_at_a_data_center_when_given() {
1169        let mut a40 = gpu("NVIDIA A40", 48, Some(0.4));
1170        a40.availability = Availability::High;
1171        a40.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1172        let lines = gpu_table(&[a40], Some("EU-RO-1"));
1173        assert_eq!(lines.len(), 2, "{lines:?}");
1174        assert!(lines[1].ends_with("LOW"), "{}", lines[1]);
1175    }
1176
1177    #[test]
1178    fn data_center_table_is_empty_without_rows() {
1179        assert_eq!(data_center_table(&[]), [] as [String; 0]);
1180    }
1181
1182    #[test]
1183    fn data_center_table_counts_gpu_types_in_stock_sorted_by_id() {
1184        let centers = [
1185            center(
1186                "B-ID",
1187                vec![
1188                    stock("x", Availability::High),
1189                    stock("y", Availability::None),
1190                ],
1191            ),
1192            center("A-ID", vec![stock("x", Availability::Low)]),
1193        ];
1194        let lines = data_center_table(&centers);
1195        assert_eq!(lines.len(), 3, "{lines:?}");
1196        assert!(
1197            lines[0].starts_with("ID") && lines[0].ends_with("GPU TYPES IN STOCK"),
1198            "{}",
1199            lines[0]
1200        );
1201        assert!(
1202            lines[1].starts_with("A-ID") && lines[1].ends_with('1'),
1203            "{}",
1204            lines[1]
1205        );
1206        assert!(
1207            lines[2].starts_with("B-ID") && lines[2].ends_with('1'),
1208            "{}",
1209            lines[2]
1210        );
1211    }
1212
1213    #[test]
1214    fn volume_table_is_empty_without_rows() {
1215        assert_eq!(volume_table(&[]), [] as [String; 0]);
1216    }
1217
1218    #[test]
1219    fn volume_table_is_sorted_by_name() {
1220        let volumes = [
1221            NetworkVolume {
1222                id: "v2".to_string(),
1223                name: "zeta".to_string(),
1224                size: 50,
1225                data_center: "EU-RO-1".to_string(),
1226            },
1227            NetworkVolume {
1228                id: "v1".to_string(),
1229                name: "alpha".to_string(),
1230                size: 100,
1231                data_center: "US-KS-2".to_string(),
1232            },
1233        ];
1234        let lines = volume_table(&volumes);
1235        assert_eq!(lines.len(), 3, "{lines:?}");
1236        assert!(
1237            lines[0].starts_with("ID") && lines[0].ends_with("DATA CENTER"),
1238            "{}",
1239            lines[0]
1240        );
1241        assert!(
1242            lines[1].contains("v1") && lines[1].contains("alpha") && lines[1].ends_with("US-KS-2"),
1243            "{}",
1244            lines[1]
1245        );
1246        assert!(
1247            lines[2].contains("v2") && lines[2].contains("zeta") && lines[2].ends_with("EU-RO-1"),
1248            "{}",
1249            lines[2]
1250        );
1251    }
1252
1253    #[test]
1254    fn template_table_is_empty_without_rows() {
1255        assert_eq!(template_table(&[]), [] as [String; 0]);
1256    }
1257
1258    #[test]
1259    fn template_table_is_sorted_by_name() {
1260        let templates = [
1261            Template {
1262                id: "t2".to_string(),
1263                name: "zeta".to_string(),
1264                image: "img/z:1".to_string(),
1265                serverless: false,
1266            },
1267            Template {
1268                id: "t1".to_string(),
1269                name: "alpha".to_string(),
1270                image: "img/a:1".to_string(),
1271                serverless: false,
1272            },
1273        ];
1274        let lines = template_table(&templates);
1275        assert_eq!(lines.len(), 3, "{lines:?}");
1276        assert!(
1277            lines[0].starts_with("ID") && lines[0].ends_with("IMAGE"),
1278            "{}",
1279            lines[0]
1280        );
1281        assert!(
1282            lines[1].contains("t1") && lines[1].ends_with("img/a:1"),
1283            "{}",
1284            lines[1]
1285        );
1286        assert!(
1287            lines[2].contains("t2") && lines[2].ends_with("img/z:1"),
1288            "{}",
1289            lines[2]
1290        );
1291    }
1292
1293    /// A live GPU ID, far longer than the old fixed ID column of 28.
1294    const LONG: &str = "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition";
1295
1296    /// Where `word` starts in `line`, for checking that a row lines up with
1297    /// its header.
1298    fn at(line: &str, word: &str) -> Option<usize> {
1299        line.find(word)
1300    }
1301
1302    #[test]
1303    fn gpu_table_widens_its_id_column_to_the_longest_id() {
1304        let lines = gpu_table(&[gpu(LONG, 32, Some(0.9)), gpu("a", 8, Some(0.1))], None);
1305        assert!(lines[1].starts_with(&format!("{LONG}  ")), "{}", lines[1]);
1306        for line in &lines[1..] {
1307            assert_eq!(at(line, "HIGH"), at(&lines[0], "STOCK"), "{lines:#?}");
1308        }
1309        assert_eq!(
1310            at(&lines[2], "0.10").map(|at| at + 4),
1311            at(&lines[0], "$/H").map(|at| at + 3),
1312            "{lines:#?}"
1313        );
1314    }
1315
1316    #[test]
1317    fn data_center_table_widens_its_columns_to_the_longest_values() {
1318        let mut long = center("A", vec![stock("x", Availability::High)]);
1319        long.name = LONG.to_string();
1320        let lines = data_center_table(&[long, center("B", Vec::new())]);
1321        for line in &lines[1..] {
1322            assert_eq!(
1323                line.rfind("  ").map(|at| at + 2),
1324                at(&lines[0], "GPU TYPES"),
1325                "{lines:#?}"
1326            );
1327        }
1328        assert_eq!(
1329            at(&lines[2], "EUROPE"),
1330            at(&lines[0], "REGION"),
1331            "{lines:#?}"
1332        );
1333    }
1334
1335    #[test]
1336    fn volume_and_template_tables_widen_their_name_columns() {
1337        let volume = |id: &str, name: &str| NetworkVolume {
1338            id: id.to_string(),
1339            name: name.to_string(),
1340            size: 50,
1341            data_center: "EU-RO-1".to_string(),
1342        };
1343        let lines = volume_table(&[volume("v1", LONG), volume("v2", "z")]);
1344        for line in &lines[1..] {
1345            assert_eq!(
1346                at(line, "EU-RO-1"),
1347                at(&lines[0], "DATA CENTER"),
1348                "{lines:#?}"
1349            );
1350        }
1351        let template = |id: &str, name: &str| Template {
1352            id: id.to_string(),
1353            name: name.to_string(),
1354            image: "img/a:1".to_string(),
1355            serverless: false,
1356        };
1357        let lines = template_table(&[template("t1", LONG), template("t2", "z")]);
1358        for line in &lines[1..] {
1359            assert_eq!(at(line, "img/"), at(&lines[0], "IMAGE"), "{lines:#?}");
1360        }
1361    }
1362
1363    #[test]
1364    fn printable_strips_control_characters_and_escape_sequences() {
1365        assert_eq!(printable("A40\u{1b}[2J\nname"), "A40 name");
1366        assert_eq!(printable("a\nb\tc\r\nd  e"), "a b c  d  e", "no collapsing");
1367        assert_eq!(
1368            printable("a\u{1b}]0;title\u{7}b\u{1b}]8;;x\u{1b}\\c"),
1369            "abc"
1370        );
1371        assert_eq!(printable("a\u{1b}Mb\u{7f}c\u{9b}31md\te"), "abcd e");
1372        assert_eq!(
1373            printable("RTX 4090 é"),
1374            "RTX 4090 é",
1375            "printable text stays"
1376        );
1377    }
1378
1379    #[test]
1380    fn string_controls_are_skipped_through_their_terminator() {
1381        for introducer in ['P', 'X', '^', '_'] {
1382            let st = format!("a\u{1b}{introducer}bad\u{1b}\\b");
1383            assert_eq!(printable(&st), "ab", "ESC {introducer}");
1384            let bel = format!("a\u{1b}{introducer}bad\u{7}b");
1385            assert_eq!(printable(&bel), "ab", "ESC {introducer} ended by BEL");
1386            let open = format!("a\u{1b}{introducer}bad and more");
1387            assert_eq!(printable(&open), "a", "ESC {introducer} unterminated");
1388        }
1389        for introducer in ['\u{90}', '\u{98}', '\u{9e}', '\u{9f}'] {
1390            let st = format!("a{introducer}bad\u{9c}b");
1391            assert_eq!(printable(&st), "ab", "{introducer:?}");
1392            let open = format!("a{introducer}bad and more");
1393            assert_eq!(printable(&open), "a", "{introducer:?} unterminated");
1394        }
1395    }
1396
1397    #[test]
1398    fn every_table_prints_api_strings_without_control_characters() {
1399        let odd = "odd\u{1b}[2J\nname";
1400        let mut named = center("EU-RO-1", Vec::new());
1401        named.name = odd.to_string();
1402        let volume = NetworkVolume {
1403            id: "v1".to_string(),
1404            name: odd.to_string(),
1405            size: 50,
1406            data_center: "EU-RO-1".to_string(),
1407        };
1408        let template = Template {
1409            id: "t1".to_string(),
1410            name: odd.to_string(),
1411            image: "img\u{1b}[31m:1".to_string(),
1412            serverless: false,
1413        };
1414        let tables = [
1415            gpu_table(&[gpu(odd, 48, Some(0.4))], None),
1416            data_center_table(&[named]),
1417            volume_table(&[volume]),
1418            template_table(&[template]),
1419        ];
1420        for lines in tables {
1421            let text = lines.join("\n");
1422            assert!(text.contains("odd name"), "{text:?}");
1423            assert_eq!(lines.len(), 2, "{lines:?}");
1424            assert!(
1425                !text.chars().any(|c| c.is_control() && c != '\n'),
1426                "{text:?}"
1427            );
1428        }
1429    }
1430
1431    #[test]
1432    fn a_gpu_id_from_the_api_reaches_a_resolve_error_clean() -> Result<(), serde_json::Error> {
1433        let gpus: Vec<GpuType> = serde_json::from_value(serde_json::json!([
1434            {"id": "odd\u{1b}[2J\ngpu", "memory": 48, "price": {"secure": 0.3},
1435             "maxCount": {"secure": 8}, "availability": "HIGH",
1436             "dataCenters": [{"id": "EU\u{1b}[31m-RO-1", "availability": "NONE"}]}
1437        ]))?;
1438        assert_eq!(gpus[0].id, "odd gpu", "cleaned when parsed");
1439        assert_eq!(gpus[0].data_centers[0].id, "EU-RO-1");
1440        let error = resolve(&target(list(&["odd gpu"]), ListOrAuto::Auto), &gpus)
1441            .err()
1442            .map(|error| error.to_string());
1443        assert_eq!(
1444            error.as_deref(),
1445            Some(
1446                "no data center has odd gpu in stock for data_center_ids = \"auto\" (gpu_count = 1)"
1447            )
1448        );
1449        Ok(())
1450    }
1451
1452    fn out_of_stock(id: &str, memory: u32, price: f64) -> GpuType {
1453        GpuType {
1454            availability: Availability::None,
1455            ..gpu(id, memory, Some(price))
1456        }
1457    }
1458
1459    fn strings(items: &[&str]) -> Vec<String> {
1460        items.iter().map(|item| (*item).to_string()).collect()
1461    }
1462
1463    fn order(
1464        target: &RunpodTarget,
1465        gpus: &[GpuType],
1466        floor: Option<u32>,
1467        tried: &[&str],
1468        cap: usize,
1469    ) -> Result<(ListOrAuto, Vec<String>), ResolveError> {
1470        walk_order(target, gpus, floor, &strings(tried), cap)
1471            .map(|order| (order.target.gpu_types, order.out_of_stock))
1472    }
1473
1474    #[test]
1475    fn cheaper_types_out_of_stock_come_first_cheapest_first_capped() {
1476        let gpus = [
1477            gpu("stocked", 24, Some(0.5)),
1478            gpu("dearer", 24, Some(0.9)),
1479            out_of_stock("same", 24, 0.5),
1480            out_of_stock("c", 24, 0.3),
1481            out_of_stock("a", 24, 0.1),
1482            out_of_stock("b", 24, 0.2),
1483            out_of_stock("d", 24, 0.4),
1484        ];
1485        let auto = target(ListOrAuto::Auto, ListOrAuto::default());
1486        assert_eq!(
1487            order(&auto, &gpus, None, &[], MAX_OUT_OF_STOCK),
1488            Ok((
1489                list(&["a", "b", "c", "stocked", "dearer"]),
1490                strings(&["a", "b", "c"])
1491            ))
1492        );
1493        // Tried types are left out, and the cap is what the walk has left.
1494        assert_eq!(
1495            order(&auto, &gpus, None, &["a", "stocked"], 1),
1496            Ok((list(&["b", "dearer"]), strings(&["b"])))
1497        );
1498        assert_eq!(
1499            order(&auto, &gpus, None, &[], 0),
1500            Ok((list(&["stocked", "dearer"]), Vec::new()))
1501        );
1502    }
1503
1504    #[test]
1505    fn types_out_of_stock_keep_every_limit() {
1506        let mut single = out_of_stock("single", 80, 0.1);
1507        single.max_count.secure = 1;
1508        let mut community = out_of_stock("community", 80, 0.1);
1509        community.secure = Some(false);
1510        let gpus = [
1511            gpu("stocked", 48, Some(0.8)),
1512            single,
1513            community,
1514            out_of_stock("small", 16, 0.1),
1515            out_of_stock("over", 80, 0.7),
1516            out_of_stock("fits", 48, 0.2),
1517        ];
1518        let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
1519        auto.gpu_count = 2;
1520        auto.max_price_per_hour = Some(0.6);
1521        // "stocked" is over the price limit itself: nothing in stock.
1522        assert!(matches!(
1523            order(&auto, &gpus, Some(40), &[], 3),
1524            Err(ResolveError::NoGpuType { .. })
1525        ));
1526        auto.max_price_per_hour = Some(0.8);
1527        // Not "single" (one GPU per pod at most), "community" (not on Secure
1528        // Cloud) or "small" (under the floor).
1529        assert_eq!(
1530            order(&auto, &gpus, Some(40), &[], 3),
1531            Ok((
1532                list(&["fits", "over", "stocked"]),
1533                strings(&["fits", "over"])
1534            ))
1535        );
1536        auto.min_vram_gb = Some(60);
1537        // An explicit min_vram_gb wins over the floor: "stocked" is too small.
1538        assert!(order(&auto, &gpus, Some(10), &[], 3).is_err());
1539    }
1540
1541    #[test]
1542    fn nothing_in_stock_or_nothing_left_untried_is_an_error() {
1543        let auto = target(ListOrAuto::Auto, ListOrAuto::default());
1544        let none = [out_of_stock("a", 24, 0.1)];
1545        assert_eq!(
1546            order(&auto, &none, None, &[], 3),
1547            Err(ResolveError::NoGpuType {
1548                asked: "gpu_count = 1".into()
1549            })
1550        );
1551        let gpus = [gpu("stocked", 24, Some(0.5)), out_of_stock("a", 24, 0.1)];
1552        assert!(matches!(
1553            order(&auto, &gpus, None, &["stocked"], 3),
1554            Err(ResolveError::NoGpuType { .. })
1555        ));
1556    }
1557
1558    #[test]
1559    fn listed_data_centers_bound_the_types_out_of_stock() {
1560        let mut stocked = gpu("stocked", 24, Some(0.5));
1561        stocked.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1562        let mut here = out_of_stock("here", 24, 0.1);
1563        here.data_centers = vec![stock("EU-RO-1", Availability::None)];
1564        let mut elsewhere = out_of_stock("elsewhere", 24, 0.1);
1565        elsewhere.data_centers = vec![stock("US-KS-2", Availability::None)];
1566        let unknown = out_of_stock("unknown", 24, 0.2);
1567        let gpus = [stocked, here, elsewhere, unknown];
1568        let listed = target(ListOrAuto::Auto, list(&["EU-RO-1"]));
1569        let walk = walk_order(&listed, &gpus, None, &[], 3);
1570        assert_eq!(
1571            walk.as_ref().map(|order| &order.target.gpu_types),
1572            Ok(&list(&["here", "unknown", "stocked"]))
1573        );
1574        assert_eq!(
1575            walk.map(|order| order.target.data_center_ids),
1576            Ok(list(&["EU-RO-1"]))
1577        );
1578    }
1579
1580    #[test]
1581    fn auto_data_centers_come_from_the_untried_types_in_stock() {
1582        let mut cheap = gpu("cheap", 24, Some(0.3));
1583        cheap.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1584        let mut dear = gpu("dear", 24, Some(0.6));
1585        dear.data_centers = vec![stock("US-KS-2", Availability::High)];
1586        let gpus = [cheap, dear, out_of_stock("out", 24, 0.1)];
1587        let auto = target(ListOrAuto::Auto, ListOrAuto::Auto);
1588        let centers = |tried: &[&str]| {
1589            walk_order(&auto, &gpus, None, &strings(tried), 3)
1590                .map(|order| order.target.data_center_ids)
1591        };
1592        assert_eq!(centers(&[]), Ok(list(&["EU-RO-1", "US-KS-2"])));
1593        assert_eq!(centers(&["cheap"]), Ok(list(&["US-KS-2"])));
1594    }
1595
1596    #[test]
1597    fn listed_types_have_no_walk_order_of_their_own() {
1598        let gpus = [gpu("stocked", 24, Some(0.5)), out_of_stock("a", 24, 0.1)];
1599        let listed = target(list(&["x", "y"]), ListOrAuto::default());
1600        assert_eq!(
1601            order(&listed, &gpus, None, &["x"], 3),
1602            Ok((list(&["x", "y"]), Vec::new()))
1603        );
1604    }
1605}