pub mod blkio_config;
pub mod build;
mod byte_value;
mod config_or_secret;
mod cpuset;
mod credential_spec;
pub mod deploy;
pub mod develop;
pub mod device;
pub mod env_file;
mod expose;
pub mod healthcheck;
mod hostname;
pub mod image;
mod limit;
pub mod network_config;
pub mod platform;
pub mod ports;
mod ulimit;
pub mod user_or_group;
pub mod volumes;
use std::{
borrow::Cow,
fmt::{self, Display, Formatter},
net::IpAddr,
ops::Not,
path::PathBuf,
time::Duration,
};
use compose_spec_macros::{DeserializeTryFromString, SerializeDisplay};
use indexmap::{map::Keys, IndexMap, IndexSet};
use serde::{de, Deserialize, Deserializer, Serialize};
use thiserror::Error;
use crate::{
impl_from_str,
serde::{
default_true, display_from_str_option, duration_option, duration_us_option, skip_true,
ItemOrListVisitor,
},
AsShortIter, Extensions, Identifier, InvalidIdentifierError, ItemOrList, ListOrMap, Map,
MapKey, ShortOrLong, StringOrNumber, Value,
};
use self::build::Context;
pub use self::{
blkio_config::BlkioConfig,
build::Build,
byte_value::{ByteValue, ParseByteValueError},
config_or_secret::ConfigOrSecret,
cpuset::{CpuSet, ParseCpuSetError},
credential_spec::{CredentialSpec, Kind as CredentialSpecKind},
deploy::{resources::Cpus, Deploy},
develop::Develop,
device::Device,
env_file::EnvFile,
expose::Expose,
healthcheck::Healthcheck,
hostname::{Hostname, InvalidHostnameError},
image::Image,
limit::Limit,
network_config::{MacAddress, NetworkConfig},
platform::Platform,
ports::Ports,
ulimit::{InvalidResourceError, Resource, Ulimit, Ulimits},
user_or_group::UserOrGroup,
volumes::{AbsolutePath, Volumes},
};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize, Deserialize, Debug, compose_spec_macros::Default, Clone, PartialEq)]
pub struct Service {
#[serde(default = "default_true", skip_serializing_if = "skip_true")]
#[default = true]
pub attach: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<ShortOrLong<Context, Build>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blkio_config: Option<BlkioConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_percent: Option<Percent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_shares: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_us_option"
)]
pub cpu_period: Option<Duration>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_us_option"
)]
pub cpu_quota: Option<Duration>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_option"
)]
pub cpu_rt_runtime: Option<Duration>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_option"
)]
pub cpu_rt_period: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<Cpus>,
#[serde(default, skip_serializing_if = "CpuSet::is_empty")]
pub cpuset: CpuSet,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub cap_add: IndexSet<String>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub cap_drop: IndexSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cgroup: Option<Cgroup>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cgroup_parent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Command>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub configs: Vec<ShortOrLong<Identifier, ConfigOrSecret>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_name: Option<Identifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_spec: Option<CredentialSpec>,
#[serde(default, skip_serializing_if = "depends_on_is_empty")]
pub depends_on: DependsOn,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy: Option<Deploy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub develop: Option<Develop>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub device_cgroup_rules: IndexSet<device::CgroupRule>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub devices: IndexSet<Device>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns: Option<ItemOrList<IpAddr>>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub dns_opt: IndexSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_search: Option<ItemOrList<Hostname>>,
#[serde(
rename = "domainname",
default,
skip_serializing_if = "Option::is_none"
)]
pub domain_name: Option<Hostname>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<Command>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_file: Option<EnvFile>,
#[serde(default, skip_serializing_if = "ListOrMap::is_empty")]
pub environment: ListOrMap,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub expose: IndexSet<Expose>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extends: Option<Extends>,
#[serde(default, skip_serializing_if = "ListOrMap::is_empty")]
pub annotations: ListOrMap,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub external_links: IndexSet<Link>,
#[serde(
default,
skip_serializing_if = "IndexMap::is_empty",
deserialize_with = "extra_hosts"
)]
pub extra_hosts: IndexMap<Hostname, IpAddr>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub group_add: IndexSet<UserOrGroup>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub healthcheck: Option<Healthcheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<Hostname>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<Image>,
#[serde(default, skip_serializing_if = "Not::not")]
pub init: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipc: Option<Ipc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uts: Option<Uts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<String>,
#[serde(default, skip_serializing_if = "ListOrMap::is_empty")]
pub labels: ListOrMap,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub links: IndexSet<Link>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<Logging>,
#[serde(flatten, with = "network_config::option")]
pub network_config: Option<NetworkConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mac_address: Option<MacAddress>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_limit: Option<ByteValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_reservation: Option<ByteValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_swappiness: Option<Percent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memswap_limit: Option<Limit<ByteValue>>,
#[serde(default, skip_serializing_if = "Not::not")]
pub oom_kill_disable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_score_adj: Option<OomScoreAdj>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids_limit: Option<Limit<u32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<Platform>,
#[serde(default, skip_serializing_if = "Ports::is_empty")]
pub ports: Ports,
#[serde(default, skip_serializing_if = "Not::not")]
pub privileged: bool,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub profiles: IndexSet<Identifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_policy: Option<PullPolicy>,
#[serde(default, skip_serializing_if = "Not::not")]
pub read_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<Restart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scale: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<ShortOrLong<Identifier, ConfigOrSecret>>,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub security_opt: IndexSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shm_size: Option<ByteValue>,
#[serde(default, skip_serializing_if = "Not::not")]
pub stdin_open: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_option"
)]
pub stop_grace_period: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_signal: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub storage_opt: Map,
#[serde(default, skip_serializing_if = "ListOrMap::is_empty")]
pub sysctls: ListOrMap,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmpfs: Option<ItemOrList<AbsolutePath>>,
#[serde(default, skip_serializing_if = "Not::not")]
pub tty: bool,
#[serde(default, skip_serializing_if = "Ulimits::is_empty")]
pub ulimits: Ulimits,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "display_from_str_option::serialize"
)]
pub user: Option<UserOrGroup>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub userns_mode: Option<String>,
#[serde(default, skip_serializing_if = "Volumes::is_empty")]
pub volumes: Volumes,
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
pub volumes_from: IndexSet<VolumesFrom>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<AbsolutePath>,
#[serde(flatten)]
pub extensions: Extensions,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(into = "u8", try_from = "u8")]
pub struct Percent(u8);
impl Percent {
pub fn new(percent: u8) -> Result<Self, RangeError> {
match percent {
0..=100 => Ok(Self(percent)),
value => Err(RangeError {
value: value.into(),
start: 0,
end: 100,
}),
}
}
#[must_use]
pub const fn into_inner(self) -> u8 {
self.0
}
}
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("value `{value}` is not between {start} and {end}")]
pub struct RangeError {
value: i64,
start: i64,
end: i64,
}
impl TryFrom<u8> for Percent {
type Error = RangeError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Percent> for u8 {
fn from(value: Percent) -> Self {
value.into_inner()
}
}
impl PartialEq<u8> for Percent {
fn eq(&self, other: &u8) -> bool {
self.0.eq(other)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Cgroup {
Host,
Private,
}
impl Cgroup {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Host => "host",
Self::Private => "private",
}
}
}
impl AsRef<str> for Cgroup {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for Cgroup {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum Command {
String(String),
List(Vec<String>),
}
impl From<String> for Command {
fn from(value: String) -> Self {
Self::String(value)
}
}
impl From<Vec<String>> for Command {
fn from(value: Vec<String>) -> Self {
Self::List(value)
}
}
impl<'de> Deserialize<'de> for Command {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
ItemOrListVisitor::<_, String>::new("a string or list of strings").deserialize(deserializer)
}
}
pub type DependsOn = ShortOrLong<IndexSet<Identifier>, IndexMap<Identifier, Dependency>>;
#[derive(
Serialize, Deserialize, Debug, compose_spec_macros::Default, Clone, Copy, PartialEq, Eq,
)]
pub struct Dependency {
pub condition: Condition,
#[serde(default, skip_serializing_if = "Not::not")]
pub restart: bool,
#[serde(default = "default_true", skip_serializing_if = "skip_true")]
#[default = true]
pub required: bool,
}
#[allow(clippy::enum_variant_names)]
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Condition {
#[default]
ServiceStarted,
ServiceHealthy,
ServiceCompletedSuccessfully,
}
impl Condition {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ServiceStarted => "service_started",
Self::ServiceHealthy => "service_healthy",
Self::ServiceCompletedSuccessfully => "service_completed_successfully",
}
}
}
impl AsRef<str> for Condition {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for Condition {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl<'a> AsShortIter<'a> for IndexMap<Identifier, Dependency> {
type Iter = Keys<'a, Identifier, Dependency>;
fn as_short_iter(&'a self) -> Option<Self::Iter> {
let default_options = Dependency::default();
self.values()
.all(|options| *options == default_options)
.then(|| self.keys())
}
}
fn depends_on_is_empty(depends_on: &DependsOn) -> bool {
match depends_on {
ShortOrLong::Short(short) => short.is_empty(),
ShortOrLong::Long(long) => long.is_empty(),
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Extends {
pub service: Identifier,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<PathBuf>,
}
#[derive(SerializeDisplay, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, Hash)]
pub struct Link {
pub service: Identifier,
pub alias: Option<String>,
}
impl Link {
pub fn parse<T>(link: T) -> Result<Self, InvalidIdentifierError>
where
T: AsRef<str> + TryInto<Identifier>,
T::Error: Into<InvalidIdentifierError>,
{
if let Some((service, alias)) = link.as_ref().split_once(':') {
Ok(Self {
service: service.parse()?,
alias: Some(alias.to_owned()),
})
} else {
link.try_into().map(Into::into).map_err(Into::into)
}
}
}
impl From<Identifier> for Link {
fn from(service: Identifier) -> Self {
Self {
service,
alias: None,
}
}
}
impl_from_str!(Link => InvalidIdentifierError);
impl Display for Link {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let Self { service, alias } = self;
Display::fmt(service, f)?;
if let Some(alias) = alias {
write!(f, ":{alias}")?;
}
Ok(())
}
}
impl From<Link> for String {
fn from(value: Link) -> Self {
if value.alias.is_none() {
value.service.into()
} else {
value.to_string()
}
}
}
fn extra_hosts<'de, D>(deserializer: D) -> Result<IndexMap<Hostname, IpAddr>, D::Error>
where
D: Deserializer<'de>,
{
ListOrMap::deserialize(deserializer)?
.into_map_split_on(&['=', ':'])
.map_err(de::Error::custom)?
.into_iter()
.map(|(key, value)| {
let value = value.as_ref().and_then(Value::as_string).ok_or_else(|| {
de::Error::custom("extra host value must be a string representing an IP address")
})?;
let value = value.strip_prefix('[').unwrap_or(value);
let value = value.strip_suffix(']').unwrap_or(value);
Ok((
Hostname::new(key).map_err(de::Error::custom)?,
value.parse().map_err(de::Error::custom)?,
))
})
.collect()
}
#[derive(SerializeDisplay, DeserializeTryFromString, Debug, Clone, PartialEq, Eq)]
pub enum Ipc {
Shareable,
Service(Identifier),
Other(String),
}
impl Ipc {
const SHAREABLE: &'static str = "shareable";
const SERVICE_PREFIX: &'static str = "service:";
pub fn parse<T>(ipc: T) -> Result<Self, ParseIpcError>
where
T: AsRef<str> + Into<String>,
{
if ipc.as_ref() == Self::SHAREABLE {
Ok(Self::Shareable)
} else if let Some(service) = ipc.as_ref().strip_prefix(Self::SERVICE_PREFIX) {
service.parse().map(Self::Service).map_err(Into::into)
} else {
Ok(Self::Other(ipc.into()))
}
}
#[must_use]
pub const fn is_shareable(&self) -> bool {
matches!(self, Self::Shareable)
}
#[must_use]
pub const fn is_service(&self) -> bool {
matches!(self, Self::Service(..))
}
#[must_use]
pub const fn as_service(&self) -> Option<&Identifier> {
if let Self::Service(v) = self {
Some(v)
} else {
None
}
}
#[must_use]
pub const fn is_other(&self) -> bool {
matches!(self, Self::Other(..))
}
#[must_use]
pub const fn as_other(&self) -> Option<&String> {
if let Self::Other(v) = self {
Some(v)
} else {
None
}
}
}
impl_from_str!(Ipc => ParseIpcError);
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("error parsing service IPC isolation mode")]
pub struct ParseIpcError(#[from] InvalidIdentifierError);
impl Display for Ipc {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::Shareable => f.write_str(Self::SHAREABLE),
Self::Service(service) => write!(f, "{}{service}", Self::SERVICE_PREFIX),
Self::Other(other) => f.write_str(other),
}
}
}
impl From<Ipc> for String {
fn from(value: Ipc) -> Self {
if let Ipc::Other(other) = value {
other
} else {
value.to_string()
}
}
}
impl From<Ipc> for Cow<'static, str> {
fn from(value: Ipc) -> Self {
if value.is_shareable() {
Ipc::SHAREABLE.into()
} else {
value.to_string().into()
}
}
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Uts {
#[default]
Host,
}
impl Uts {
#[must_use]
pub const fn as_str(self) -> &'static str {
"host"
}
}
impl AsRef<str> for Uts {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for Uts {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
pub struct Logging {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub options: IndexMap<MapKey, Option<StringOrNumber>>,
#[serde(flatten)]
pub extensions: Extensions,
}
impl Logging {
#[must_use]
pub fn is_empty(&self) -> bool {
let Self {
driver,
options,
extensions,
} = self;
driver.is_none() && options.is_empty() && extensions.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(into = "i16", try_from = "i16")]
pub struct OomScoreAdj(i16);
impl OomScoreAdj {
pub fn new(oom_score_adj: i16) -> Result<Self, RangeError> {
match oom_score_adj {
-1000..=1000 => Ok(Self(oom_score_adj)),
value => Err(RangeError {
value: value.into(),
start: -1000,
end: 1000,
}),
}
}
}
impl TryFrom<i16> for OomScoreAdj {
type Error = RangeError;
fn try_from(value: i16) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<OomScoreAdj> for i16 {
fn from(value: OomScoreAdj) -> Self {
value.0
}
}
impl PartialEq<i16> for OomScoreAdj {
fn eq(&self, other: &i16) -> bool {
self.0.eq(other)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PullPolicy {
Always,
Never,
#[serde(alias = "if_not_present")]
#[default]
Missing,
Build,
}
impl PullPolicy {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Always => "always",
Self::Never => "never",
Self::Missing => "missing",
Self::Build => "build",
}
}
}
impl AsRef<str> for PullPolicy {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for PullPolicy {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Restart {
#[default]
No,
Always,
OnFailure,
UnlessStopped,
}
impl Restart {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::No => "no",
Self::Always => "always",
Self::OnFailure => "on-failure",
Self::UnlessStopped => "unless-stopped",
}
}
}
impl AsRef<str> for Restart {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for Restart {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(SerializeDisplay, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, Hash)]
pub struct VolumesFrom {
pub source: VolumesFromSource,
pub read_only: bool,
}
impl VolumesFrom {
const READ_ONLY_SUFFIX: &'static str = ":ro";
pub fn parse<T>(volumes_from: T) -> Result<Self, InvalidIdentifierError>
where
T: AsRef<str> + TryInto<Identifier>,
T::Error: Into<InvalidIdentifierError>,
{
#[allow(clippy::map_unwrap_or)]
volumes_from
.as_ref()
.strip_suffix(Self::READ_ONLY_SUFFIX)
.map(|volumes_from| {
volumes_from.parse().map(|source| Self {
source,
read_only: true,
})
})
.unwrap_or_else(|| {
volumes_from
.as_ref()
.strip_suffix(":rw")
.map(str::parse)
.unwrap_or_else(|| VolumesFromSource::parse(volumes_from))
.map(Into::into)
})
}
}
impl_from_str!(VolumesFrom => InvalidIdentifierError);
impl Display for VolumesFrom {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let Self { source, read_only } = self;
source.fmt(f)?;
if *read_only {
f.write_str(Self::READ_ONLY_SUFFIX)?;
}
Ok(())
}
}
impl From<VolumesFromSource> for VolumesFrom {
fn from(source: VolumesFromSource) -> Self {
Self {
source,
read_only: false,
}
}
}
impl From<VolumesFrom> for String {
fn from(value: VolumesFrom) -> Self {
if value.read_only {
value.to_string()
} else {
value.source.into()
}
}
}
#[derive(SerializeDisplay, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, Hash)]
pub enum VolumesFromSource {
Service(Identifier),
Container(Identifier),
}
impl VolumesFromSource {
const CONTAINER_PREFIX: &'static str = "container:";
pub fn parse<T>(source: T) -> Result<Self, InvalidIdentifierError>
where
T: AsRef<str> + TryInto<Identifier>,
T::Error: Into<InvalidIdentifierError>,
{
#[allow(clippy::map_unwrap_or)]
source
.as_ref()
.strip_prefix(Self::CONTAINER_PREFIX)
.map(|container| container.parse().map(Self::Container))
.unwrap_or_else(|| source.try_into().map(Self::Service).map_err(Into::into))
}
}
impl_from_str!(VolumesFromSource => InvalidIdentifierError);
impl Display for VolumesFromSource {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::Service(service) => service.fmt(f),
Self::Container(container) => write!(f, "{}{container}", Self::CONTAINER_PREFIX),
}
}
}
impl From<VolumesFromSource> for String {
fn from(value: VolumesFromSource) -> Self {
match value {
VolumesFromSource::Service(service) => service.into(),
VolumesFromSource::Container(_) => value.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use proptest::{
arbitrary::{any, Arbitrary},
path::PathParams,
prop_assert_eq, prop_oneof, proptest,
strategy::{Just, Strategy},
};
use super::*;
pub(super) fn path_no_colon() -> impl Strategy<Value = PathBuf> {
PathBuf::arbitrary_with(PathParams::default().with_component_regex("[^:]*"))
}
mod volumes_from {
use super::*;
proptest! {
#[test]
fn parse_no_panic(string: String) {
let _ = string.parse::<VolumesFrom>();
}
#[test]
fn round_trip(volumes_from in volumes_from()) {
prop_assert_eq!(&volumes_from, &volumes_from.to_string().parse()?);
}
}
}
fn volumes_from() -> impl Strategy<Value = VolumesFrom> {
any::<(Identifier, bool)>()
.prop_flat_map(|(ident, read_only)| {
(
prop_oneof![
Just(VolumesFromSource::Service(ident.clone())),
Just(VolumesFromSource::Container(ident))
],
Just(read_only),
)
})
.prop_map(|(source, read_only)| VolumesFrom { source, read_only })
}
}