Skip to main content

purple_ssh/providers/
kind.rs

1//! Typed provider kind. One variant per supported provider.
2//!
3//! Used everywhere outside the three string boundaries (TOML on disk,
4//! SSH config write path, provider API JSON). `as_str` and `from_str`
5//! bridge those boundaries; everything in-process compares variants
6//! directly so dispatch is compiler-checked exhaustive.
7
8use std::fmt;
9use std::str::FromStr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ProviderKind {
13    Aws,
14    Azure,
15    DigitalOcean,
16    Gcp,
17    Hetzner,
18    I3d,
19    Leaseweb,
20    Linode,
21    Oracle,
22    Ovh,
23    Proxmox,
24    Scaleway,
25    Tailscale,
26    Transip,
27    UpCloud,
28    Vultr,
29}
30
31impl ProviderKind {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            ProviderKind::Aws => "aws",
35            ProviderKind::Azure => "azure",
36            ProviderKind::DigitalOcean => "digitalocean",
37            ProviderKind::Gcp => "gcp",
38            ProviderKind::Hetzner => "hetzner",
39            ProviderKind::I3d => "i3d",
40            ProviderKind::Leaseweb => "leaseweb",
41            ProviderKind::Linode => "linode",
42            ProviderKind::Oracle => "oracle",
43            ProviderKind::Ovh => "ovh",
44            ProviderKind::Proxmox => "proxmox",
45            ProviderKind::Scaleway => "scaleway",
46            ProviderKind::Tailscale => "tailscale",
47            ProviderKind::Transip => "transip",
48            ProviderKind::UpCloud => "upcloud",
49            ProviderKind::Vultr => "vultr",
50        }
51    }
52
53    /// Default `auto_sync` value for a new section of this provider.
54    /// Proxmox opts out by default because its API is N+1 per VM.
55    pub fn default_auto_sync(self) -> bool {
56        match self {
57            ProviderKind::Proxmox => false,
58            ProviderKind::Aws
59            | ProviderKind::Azure
60            | ProviderKind::DigitalOcean
61            | ProviderKind::Gcp
62            | ProviderKind::Hetzner
63            | ProviderKind::I3d
64            | ProviderKind::Leaseweb
65            | ProviderKind::Linode
66            | ProviderKind::Oracle
67            | ProviderKind::Ovh
68            | ProviderKind::Scaleway
69            | ProviderKind::Tailscale
70            | ProviderKind::Transip
71            | ProviderKind::UpCloud
72            | ProviderKind::Vultr => true,
73        }
74    }
75
76    /// Canonical short alias-prefix suggestion shown in the provider form.
77    /// Returned value is a project identifier, not localisable copy.
78    pub fn alias_prefix(self) -> &'static str {
79        match self {
80            ProviderKind::Aws => "aws",
81            ProviderKind::Azure => "az",
82            ProviderKind::DigitalOcean => "do",
83            ProviderKind::Gcp => "gcp",
84            ProviderKind::Hetzner => "hetzner",
85            ProviderKind::I3d => "i3d",
86            ProviderKind::Leaseweb => "leaseweb",
87            ProviderKind::Linode => "linode",
88            ProviderKind::Oracle => "oci",
89            ProviderKind::Ovh => "ovh",
90            ProviderKind::Proxmox => "pve",
91            ProviderKind::Scaleway => "scw",
92            ProviderKind::Tailscale => "ts",
93            ProviderKind::Transip => "transip",
94            ProviderKind::UpCloud => "uc",
95            ProviderKind::Vultr => "vultr",
96        }
97    }
98
99    /// Whether this provider requires a `url` (Proxmox endpoint).
100    pub fn requires_url(self) -> bool {
101        match self {
102            ProviderKind::Proxmox => true,
103            ProviderKind::Aws
104            | ProviderKind::Azure
105            | ProviderKind::DigitalOcean
106            | ProviderKind::Gcp
107            | ProviderKind::Hetzner
108            | ProviderKind::I3d
109            | ProviderKind::Leaseweb
110            | ProviderKind::Linode
111            | ProviderKind::Oracle
112            | ProviderKind::Ovh
113            | ProviderKind::Scaleway
114            | ProviderKind::Tailscale
115            | ProviderKind::Transip
116            | ProviderKind::UpCloud
117            | ProviderKind::Vultr => false,
118        }
119    }
120
121    /// Whether the CLI's `--regions` flag applies. Subset of `has_regions_field`
122    /// because not every provider with a regions form field also exposes the CLI flag.
123    pub fn accepts_cli_regions(self) -> bool {
124        match self {
125            ProviderKind::Aws
126            | ProviderKind::Azure
127            | ProviderKind::Gcp
128            | ProviderKind::Oracle
129            | ProviderKind::Scaleway => true,
130            ProviderKind::DigitalOcean
131            | ProviderKind::Hetzner
132            | ProviderKind::I3d
133            | ProviderKind::Leaseweb
134            | ProviderKind::Linode
135            | ProviderKind::Ovh
136            | ProviderKind::Proxmox
137            | ProviderKind::Tailscale
138            | ProviderKind::Transip
139            | ProviderKind::UpCloud
140            | ProviderKind::Vultr => false,
141        }
142    }
143
144    /// Whether the provider form exposes a `regions` field at all.
145    pub fn has_regions_field(self) -> bool {
146        match self {
147            ProviderKind::Aws
148            | ProviderKind::Azure
149            | ProviderKind::Gcp
150            | ProviderKind::Oracle
151            | ProviderKind::Ovh
152            | ProviderKind::Scaleway => true,
153            ProviderKind::DigitalOcean
154            | ProviderKind::Hetzner
155            | ProviderKind::I3d
156            | ProviderKind::Leaseweb
157            | ProviderKind::Linode
158            | ProviderKind::Proxmox
159            | ProviderKind::Tailscale
160            | ProviderKind::Transip
161            | ProviderKind::UpCloud
162            | ProviderKind::Vultr => false,
163        }
164    }
165
166    /// Whether the `regions` field is mandatory for form submission.
167    /// GCP and Oracle have meaningful defaults so they are merely optional;
168    /// the others either need an explicit list or, for Azure, subscription IDs.
169    pub fn regions_field_is_mandatory(self) -> bool {
170        match self {
171            ProviderKind::Aws
172            | ProviderKind::Azure
173            | ProviderKind::Ovh
174            | ProviderKind::Scaleway => true,
175            ProviderKind::DigitalOcean
176            | ProviderKind::Gcp
177            | ProviderKind::Hetzner
178            | ProviderKind::I3d
179            | ProviderKind::Leaseweb
180            | ProviderKind::Linode
181            | ProviderKind::Oracle
182            | ProviderKind::Proxmox
183            | ProviderKind::Tailscale
184            | ProviderKind::Transip
185            | ProviderKind::UpCloud
186            | ProviderKind::Vultr => false,
187        }
188    }
189
190    /// Whether activating the `regions` field opens a structured picker
191    /// rather than accepting free-form text. Azure takes subscription IDs
192    /// as free-form CSV input.
193    pub fn regions_field_is_picker(self) -> bool {
194        match self {
195            ProviderKind::Aws
196            | ProviderKind::Gcp
197            | ProviderKind::Oracle
198            | ProviderKind::Ovh
199            | ProviderKind::Scaleway => true,
200            ProviderKind::Azure
201            | ProviderKind::DigitalOcean
202            | ProviderKind::Hetzner
203            | ProviderKind::I3d
204            | ProviderKind::Leaseweb
205            | ProviderKind::Linode
206            | ProviderKind::Proxmox
207            | ProviderKind::Tailscale
208            | ProviderKind::Transip
209            | ProviderKind::UpCloud
210            | ProviderKind::Vultr => false,
211        }
212    }
213
214    /// Whether the provider form exposes a `project` field.
215    pub fn has_project_field(self) -> bool {
216        match self {
217            ProviderKind::Gcp | ProviderKind::Ovh => true,
218            ProviderKind::Aws
219            | ProviderKind::Azure
220            | ProviderKind::DigitalOcean
221            | ProviderKind::Hetzner
222            | ProviderKind::I3d
223            | ProviderKind::Leaseweb
224            | ProviderKind::Linode
225            | ProviderKind::Oracle
226            | ProviderKind::Proxmox
227            | ProviderKind::Scaleway
228            | ProviderKind::Tailscale
229            | ProviderKind::Transip
230            | ProviderKind::UpCloud
231            | ProviderKind::Vultr => false,
232        }
233    }
234}
235
236/// Error returned when a string does not match any known `ProviderKind`.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct UnknownProviderKind;
239
240impl fmt::Display for UnknownProviderKind {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.write_str("unknown provider kind")
243    }
244}
245
246impl std::error::Error for UnknownProviderKind {}
247
248impl FromStr for ProviderKind {
249    type Err = UnknownProviderKind;
250
251    fn from_str(s: &str) -> Result<Self, UnknownProviderKind> {
252        match s {
253            "aws" => Ok(ProviderKind::Aws),
254            "azure" => Ok(ProviderKind::Azure),
255            "digitalocean" => Ok(ProviderKind::DigitalOcean),
256            "gcp" => Ok(ProviderKind::Gcp),
257            "hetzner" => Ok(ProviderKind::Hetzner),
258            "i3d" => Ok(ProviderKind::I3d),
259            "leaseweb" => Ok(ProviderKind::Leaseweb),
260            "linode" => Ok(ProviderKind::Linode),
261            "oracle" => Ok(ProviderKind::Oracle),
262            "ovh" => Ok(ProviderKind::Ovh),
263            "proxmox" => Ok(ProviderKind::Proxmox),
264            "scaleway" => Ok(ProviderKind::Scaleway),
265            "tailscale" => Ok(ProviderKind::Tailscale),
266            "transip" => Ok(ProviderKind::Transip),
267            "upcloud" => Ok(ProviderKind::UpCloud),
268            "vultr" => Ok(ProviderKind::Vultr),
269            _ => Err(UnknownProviderKind),
270        }
271    }
272}
273
274impl fmt::Display for ProviderKind {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.write_str(self.as_str())
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    const ALL: &[(&str, ProviderKind)] = &[
285        ("aws", ProviderKind::Aws),
286        ("azure", ProviderKind::Azure),
287        ("digitalocean", ProviderKind::DigitalOcean),
288        ("gcp", ProviderKind::Gcp),
289        ("hetzner", ProviderKind::Hetzner),
290        ("i3d", ProviderKind::I3d),
291        ("leaseweb", ProviderKind::Leaseweb),
292        ("linode", ProviderKind::Linode),
293        ("oracle", ProviderKind::Oracle),
294        ("ovh", ProviderKind::Ovh),
295        ("proxmox", ProviderKind::Proxmox),
296        ("scaleway", ProviderKind::Scaleway),
297        ("tailscale", ProviderKind::Tailscale),
298        ("transip", ProviderKind::Transip),
299        ("upcloud", ProviderKind::UpCloud),
300        ("vultr", ProviderKind::Vultr),
301    ];
302
303    /// Marketing copy hardcodes the provider count in prose ("16 cloud
304    /// providers"). This guard fails when a provider is added to `ALL`
305    /// without updating the headline count in `llms.txt` and
306    /// `site/page.html`, so the marketing sweep cannot be silently skipped.
307    #[test]
308    fn marketing_provider_count_matches_all() {
309        let phrase = format!("{} cloud provider", ALL.len());
310        let llms = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/llms.txt"));
311        let page = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/site/page.html"));
312        assert!(
313            llms.contains(&phrase),
314            "llms.txt must mention \"{phrase}\" (ProviderKind has {} variants); update marketing when adding a provider",
315            ALL.len()
316        );
317        assert!(
318            page.contains(&phrase),
319            "site/page.html must mention \"{phrase}\" (ProviderKind has {} variants); update marketing when adding a provider",
320            ALL.len()
321        );
322    }
323
324    #[test]
325    fn round_trip_string_to_kind_to_string() {
326        for (name, kind) in ALL {
327            assert_eq!(
328                name.parse::<ProviderKind>().ok(),
329                Some(*kind),
330                "parse({name})"
331            );
332            assert_eq!(kind.as_str(), *name, "as_str for {name}");
333        }
334    }
335
336    #[test]
337    fn unknown_returns_err() {
338        assert!("not-a-provider".parse::<ProviderKind>().is_err());
339        assert!("".parse::<ProviderKind>().is_err());
340        assert!("AWS".parse::<ProviderKind>().is_err(), "case sensitive");
341    }
342
343    #[test]
344    fn display_matches_as_str() {
345        assert_eq!(format!("{}", ProviderKind::Hetzner), "hetzner");
346        assert_eq!(format!("{}", ProviderKind::DigitalOcean), "digitalocean");
347        assert_eq!(format!("{}", ProviderKind::UpCloud), "upcloud");
348    }
349
350    #[test]
351    fn requires_url_only_proxmox() {
352        for (_, kind) in ALL {
353            assert_eq!(
354                kind.requires_url(),
355                *kind == ProviderKind::Proxmox,
356                "requires_url for {kind:?}"
357            );
358        }
359    }
360
361    #[test]
362    fn accepts_cli_regions_matches_documented_set() {
363        let expected: &[ProviderKind] = &[
364            ProviderKind::Aws,
365            ProviderKind::Azure,
366            ProviderKind::Gcp,
367            ProviderKind::Oracle,
368            ProviderKind::Scaleway,
369        ];
370        for (_, kind) in ALL {
371            let want = expected.contains(kind);
372            assert_eq!(kind.accepts_cli_regions(), want, "regions cli for {kind:?}");
373        }
374    }
375
376    #[test]
377    fn has_regions_field_is_cli_set_plus_ovh() {
378        for (_, kind) in ALL {
379            let want = kind.accepts_cli_regions() || *kind == ProviderKind::Ovh;
380            assert_eq!(kind.has_regions_field(), want, "regions field for {kind:?}");
381        }
382    }
383
384    #[test]
385    fn regions_mandatory_implies_has_field() {
386        for (_, kind) in ALL {
387            if kind.regions_field_is_mandatory() {
388                assert!(
389                    kind.has_regions_field(),
390                    "{kind:?} mandates regions but has no field"
391                );
392            }
393        }
394    }
395
396    #[test]
397    fn regions_picker_implies_has_field_excluding_azure() {
398        for (_, kind) in ALL {
399            if kind.regions_field_is_picker() {
400                assert!(kind.has_regions_field(), "{kind:?} picker without field");
401                assert_ne!(*kind, ProviderKind::Azure, "azure regions are free-form");
402            }
403        }
404    }
405
406    #[test]
407    fn project_field_set() {
408        for (_, kind) in ALL {
409            let want = matches!(kind, ProviderKind::Gcp | ProviderKind::Ovh);
410            assert_eq!(kind.has_project_field(), want, "project field for {kind:?}");
411        }
412    }
413}