purple-ssh 3.15.4

Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed.
Documentation
//! Typed provider kind. One variant per supported provider.
//!
//! Used everywhere outside the three string boundaries (TOML on disk,
//! SSH config write path, provider API JSON). `as_str` and `from_str`
//! bridge those boundaries; everything in-process compares variants
//! directly so dispatch is compiler-checked exhaustive.

use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderKind {
    Aws,
    Azure,
    DigitalOcean,
    Gcp,
    Hetzner,
    I3d,
    Leaseweb,
    Linode,
    Oracle,
    Ovh,
    Proxmox,
    Scaleway,
    Tailscale,
    Transip,
    UpCloud,
    Vultr,
}

impl ProviderKind {
    pub fn as_str(self) -> &'static str {
        match self {
            ProviderKind::Aws => "aws",
            ProviderKind::Azure => "azure",
            ProviderKind::DigitalOcean => "digitalocean",
            ProviderKind::Gcp => "gcp",
            ProviderKind::Hetzner => "hetzner",
            ProviderKind::I3d => "i3d",
            ProviderKind::Leaseweb => "leaseweb",
            ProviderKind::Linode => "linode",
            ProviderKind::Oracle => "oracle",
            ProviderKind::Ovh => "ovh",
            ProviderKind::Proxmox => "proxmox",
            ProviderKind::Scaleway => "scaleway",
            ProviderKind::Tailscale => "tailscale",
            ProviderKind::Transip => "transip",
            ProviderKind::UpCloud => "upcloud",
            ProviderKind::Vultr => "vultr",
        }
    }

    /// Default `auto_sync` value for a new section of this provider.
    /// Proxmox opts out by default because its API is N+1 per VM.
    pub fn default_auto_sync(self) -> bool {
        match self {
            ProviderKind::Proxmox => false,
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::DigitalOcean
            | ProviderKind::Gcp
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Oracle
            | ProviderKind::Ovh
            | ProviderKind::Scaleway
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => true,
        }
    }

    /// Canonical short alias-prefix suggestion shown in the provider form.
    /// Returned value is a project identifier, not localisable copy.
    pub fn alias_prefix(self) -> &'static str {
        match self {
            ProviderKind::Aws => "aws",
            ProviderKind::Azure => "az",
            ProviderKind::DigitalOcean => "do",
            ProviderKind::Gcp => "gcp",
            ProviderKind::Hetzner => "hetzner",
            ProviderKind::I3d => "i3d",
            ProviderKind::Leaseweb => "leaseweb",
            ProviderKind::Linode => "linode",
            ProviderKind::Oracle => "oci",
            ProviderKind::Ovh => "ovh",
            ProviderKind::Proxmox => "pve",
            ProviderKind::Scaleway => "scw",
            ProviderKind::Tailscale => "ts",
            ProviderKind::Transip => "transip",
            ProviderKind::UpCloud => "uc",
            ProviderKind::Vultr => "vultr",
        }
    }

    /// Whether this provider requires a `url` (Proxmox endpoint).
    pub fn requires_url(self) -> bool {
        match self {
            ProviderKind::Proxmox => true,
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::DigitalOcean
            | ProviderKind::Gcp
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Oracle
            | ProviderKind::Ovh
            | ProviderKind::Scaleway
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }

    /// Whether the CLI's `--regions` flag applies. Subset of `has_regions_field`
    /// because not every provider with a regions form field also exposes the CLI flag.
    pub fn accepts_cli_regions(self) -> bool {
        match self {
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::Gcp
            | ProviderKind::Oracle
            | ProviderKind::Scaleway => true,
            ProviderKind::DigitalOcean
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Ovh
            | ProviderKind::Proxmox
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }

    /// Whether the provider form exposes a `regions` field at all.
    pub fn has_regions_field(self) -> bool {
        match self {
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::Gcp
            | ProviderKind::Oracle
            | ProviderKind::Ovh
            | ProviderKind::Scaleway => true,
            ProviderKind::DigitalOcean
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Proxmox
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }

    /// Whether the `regions` field is mandatory for form submission.
    /// GCP and Oracle have meaningful defaults so they are merely optional;
    /// the others either need an explicit list or, for Azure, subscription IDs.
    pub fn regions_field_is_mandatory(self) -> bool {
        match self {
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::Ovh
            | ProviderKind::Scaleway => true,
            ProviderKind::DigitalOcean
            | ProviderKind::Gcp
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Oracle
            | ProviderKind::Proxmox
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }

    /// Whether activating the `regions` field opens a structured picker
    /// rather than accepting free-form text. Azure takes subscription IDs
    /// as free-form CSV input.
    pub fn regions_field_is_picker(self) -> bool {
        match self {
            ProviderKind::Aws
            | ProviderKind::Gcp
            | ProviderKind::Oracle
            | ProviderKind::Ovh
            | ProviderKind::Scaleway => true,
            ProviderKind::Azure
            | ProviderKind::DigitalOcean
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Proxmox
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }

    /// Whether the provider form exposes a `project` field.
    pub fn has_project_field(self) -> bool {
        match self {
            ProviderKind::Gcp | ProviderKind::Ovh => true,
            ProviderKind::Aws
            | ProviderKind::Azure
            | ProviderKind::DigitalOcean
            | ProviderKind::Hetzner
            | ProviderKind::I3d
            | ProviderKind::Leaseweb
            | ProviderKind::Linode
            | ProviderKind::Oracle
            | ProviderKind::Proxmox
            | ProviderKind::Scaleway
            | ProviderKind::Tailscale
            | ProviderKind::Transip
            | ProviderKind::UpCloud
            | ProviderKind::Vultr => false,
        }
    }
}

/// Error returned when a string does not match any known `ProviderKind`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownProviderKind;

impl fmt::Display for UnknownProviderKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("unknown provider kind")
    }
}

impl std::error::Error for UnknownProviderKind {}

impl FromStr for ProviderKind {
    type Err = UnknownProviderKind;

    fn from_str(s: &str) -> Result<Self, UnknownProviderKind> {
        match s {
            "aws" => Ok(ProviderKind::Aws),
            "azure" => Ok(ProviderKind::Azure),
            "digitalocean" => Ok(ProviderKind::DigitalOcean),
            "gcp" => Ok(ProviderKind::Gcp),
            "hetzner" => Ok(ProviderKind::Hetzner),
            "i3d" => Ok(ProviderKind::I3d),
            "leaseweb" => Ok(ProviderKind::Leaseweb),
            "linode" => Ok(ProviderKind::Linode),
            "oracle" => Ok(ProviderKind::Oracle),
            "ovh" => Ok(ProviderKind::Ovh),
            "proxmox" => Ok(ProviderKind::Proxmox),
            "scaleway" => Ok(ProviderKind::Scaleway),
            "tailscale" => Ok(ProviderKind::Tailscale),
            "transip" => Ok(ProviderKind::Transip),
            "upcloud" => Ok(ProviderKind::UpCloud),
            "vultr" => Ok(ProviderKind::Vultr),
            _ => Err(UnknownProviderKind),
        }
    }
}

impl fmt::Display for ProviderKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ALL: &[(&str, ProviderKind)] = &[
        ("aws", ProviderKind::Aws),
        ("azure", ProviderKind::Azure),
        ("digitalocean", ProviderKind::DigitalOcean),
        ("gcp", ProviderKind::Gcp),
        ("hetzner", ProviderKind::Hetzner),
        ("i3d", ProviderKind::I3d),
        ("leaseweb", ProviderKind::Leaseweb),
        ("linode", ProviderKind::Linode),
        ("oracle", ProviderKind::Oracle),
        ("ovh", ProviderKind::Ovh),
        ("proxmox", ProviderKind::Proxmox),
        ("scaleway", ProviderKind::Scaleway),
        ("tailscale", ProviderKind::Tailscale),
        ("transip", ProviderKind::Transip),
        ("upcloud", ProviderKind::UpCloud),
        ("vultr", ProviderKind::Vultr),
    ];

    #[test]
    fn round_trip_string_to_kind_to_string() {
        for (name, kind) in ALL {
            assert_eq!(
                name.parse::<ProviderKind>().ok(),
                Some(*kind),
                "parse({name})"
            );
            assert_eq!(kind.as_str(), *name, "as_str for {name}");
        }
    }

    #[test]
    fn unknown_returns_err() {
        assert!("not-a-provider".parse::<ProviderKind>().is_err());
        assert!("".parse::<ProviderKind>().is_err());
        assert!("AWS".parse::<ProviderKind>().is_err(), "case sensitive");
    }

    #[test]
    fn display_matches_as_str() {
        assert_eq!(format!("{}", ProviderKind::Hetzner), "hetzner");
        assert_eq!(format!("{}", ProviderKind::DigitalOcean), "digitalocean");
        assert_eq!(format!("{}", ProviderKind::UpCloud), "upcloud");
    }

    #[test]
    fn requires_url_only_proxmox() {
        for (_, kind) in ALL {
            assert_eq!(
                kind.requires_url(),
                *kind == ProviderKind::Proxmox,
                "requires_url for {kind:?}"
            );
        }
    }

    #[test]
    fn accepts_cli_regions_matches_documented_set() {
        let expected: &[ProviderKind] = &[
            ProviderKind::Aws,
            ProviderKind::Azure,
            ProviderKind::Gcp,
            ProviderKind::Oracle,
            ProviderKind::Scaleway,
        ];
        for (_, kind) in ALL {
            let want = expected.contains(kind);
            assert_eq!(kind.accepts_cli_regions(), want, "regions cli for {kind:?}");
        }
    }

    #[test]
    fn has_regions_field_is_cli_set_plus_ovh() {
        for (_, kind) in ALL {
            let want = kind.accepts_cli_regions() || *kind == ProviderKind::Ovh;
            assert_eq!(kind.has_regions_field(), want, "regions field for {kind:?}");
        }
    }

    #[test]
    fn regions_mandatory_implies_has_field() {
        for (_, kind) in ALL {
            if kind.regions_field_is_mandatory() {
                assert!(
                    kind.has_regions_field(),
                    "{kind:?} mandates regions but has no field"
                );
            }
        }
    }

    #[test]
    fn regions_picker_implies_has_field_excluding_azure() {
        for (_, kind) in ALL {
            if kind.regions_field_is_picker() {
                assert!(kind.has_regions_field(), "{kind:?} picker without field");
                assert_ne!(*kind, ProviderKind::Azure, "azure regions are free-form");
            }
        }
    }

    #[test]
    fn project_field_set() {
        for (_, kind) in ALL {
            let want = matches!(kind, ProviderKind::Gcp | ProviderKind::Ovh);
            assert_eq!(kind.has_project_field(), want, "project field for {kind:?}");
        }
    }
}