agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{AgentKind, AgentParseError};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SshHost(String);

impl SshHost {
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for SshHost {
    type Error = EndpointError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::from_str(&value)
    }
}

impl From<SshHost> for String {
    fn from(value: SshHost) -> Self {
        value.0
    }
}

impl FromStr for SshHost {
    type Err = EndpointError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let mut bytes = value.bytes();
        let first = bytes.next().ok_or(EndpointError::InvalidHost)?;
        let valid_first = first.is_ascii_alphanumeric();
        let valid_rest =
            bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
        if value.len() > 64 || !valid_first || !valid_rest || value == "local" {
            return Err(EndpointError::InvalidHost);
        }
        Ok(Self(value.to_owned()))
    }
}

impl fmt::Display for SshHost {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Host {
    Local,
    Ssh(SshHost),
}

impl fmt::Display for Host {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Local => formatter.write_str("local"),
            Self::Ssh(host) => host.fmt(formatter),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Endpoint {
    agent: AgentKind,
    host: Host,
}

impl Endpoint {
    #[must_use]
    pub fn new(agent: AgentKind, host: Host) -> Self {
        Self { agent, host }
    }

    #[must_use]
    pub fn agent(&self) -> AgentKind {
        self.agent
    }

    #[must_use]
    pub fn host(&self) -> &Host {
        &self.host
    }
}

impl FromStr for Endpoint {
    type Err = EndpointError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let (agent, host) = value.split_once('@').ok_or(EndpointError::InvalidFormat)?;
        if host.contains('@') {
            return Err(EndpointError::InvalidFormat);
        }
        let agent = AgentKind::from_str(agent)?;
        let host = match host {
            "local" => Host::Local,
            remote => Host::Ssh(SshHost::from_str(remote)?),
        };
        Ok(Self { agent, host })
    }
}

impl fmt::Display for Endpoint {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}@{}", self.agent, self.host)
    }
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum EndpointError {
    #[error("endpoint must use <agent>@<host>")]
    InvalidFormat,
    #[error("invalid SSH config host alias")]
    InvalidHost,
    #[error(transparent)]
    Agent(#[from] AgentParseError),
}