1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerVolumeError {
10 Empty,
12 EmptyTarget,
14 InvalidAccess,
16}
17
18impl fmt::Display for DockerVolumeError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("Docker volume mount cannot be empty"),
22 Self::EmptyTarget => formatter.write_str("Docker volume target cannot be empty"),
23 Self::InvalidAccess => formatter.write_str("Docker volume access must be ro or rw"),
24 }
25 }
26}
27
28impl Error for DockerVolumeError {}
29
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum MountAccess {
33 ReadWrite,
35 ReadOnly,
37}
38
39impl MountAccess {
40 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::ReadWrite => "rw",
45 Self::ReadOnly => "ro",
46 }
47 }
48}
49
50impl fmt::Display for MountAccess {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52 formatter.write_str(self.as_str())
53 }
54}
55
56impl FromStr for MountAccess {
57 type Err = DockerVolumeError;
58
59 fn from_str(value: &str) -> Result<Self, Self::Err> {
60 match value.trim().to_ascii_lowercase().as_str() {
61 "rw" => Ok(Self::ReadWrite),
62 "ro" => Ok(Self::ReadOnly),
63 _ => Err(DockerVolumeError::InvalidAccess),
64 }
65 }
66}
67
68#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub enum VolumeKind {
71 Anonymous,
73 Named,
75 Bind,
77}
78
79#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct DockerVolumeMount {
82 source: Option<String>,
83 target: String,
84 kind: VolumeKind,
85 access: MountAccess,
86}
87
88impl DockerVolumeMount {
89 pub fn new(
91 source: Option<String>,
92 target: impl AsRef<str>,
93 access: MountAccess,
94 ) -> Result<Self, DockerVolumeError> {
95 let target = target.as_ref().trim();
96 if target.is_empty() {
97 return Err(DockerVolumeError::EmptyTarget);
98 }
99 let kind = source
100 .as_deref()
101 .map_or(VolumeKind::Anonymous, classify_source);
102 Ok(Self {
103 source,
104 target: target.to_string(),
105 kind,
106 access,
107 })
108 }
109
110 #[must_use]
112 pub fn source(&self) -> Option<&str> {
113 self.source.as_deref()
114 }
115
116 #[must_use]
118 pub fn target(&self) -> &str {
119 &self.target
120 }
121
122 #[must_use]
124 pub const fn kind(&self) -> VolumeKind {
125 self.kind
126 }
127
128 #[must_use]
130 pub const fn access(&self) -> MountAccess {
131 self.access
132 }
133}
134
135impl fmt::Display for DockerVolumeMount {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 if let Some(source) = &self.source {
138 write!(formatter, "{source}:")?;
139 }
140 write!(formatter, "{}", self.target)?;
141 if self.access == MountAccess::ReadOnly {
142 write!(formatter, ":{}", self.access)?;
143 }
144 Ok(())
145 }
146}
147
148impl FromStr for DockerVolumeMount {
149 type Err = DockerVolumeError;
150
151 fn from_str(value: &str) -> Result<Self, Self::Err> {
152 parse_mount(value)
153 }
154}
155
156impl TryFrom<&str> for DockerVolumeMount {
157 type Error = DockerVolumeError;
158
159 fn try_from(value: &str) -> Result<Self, Self::Error> {
160 parse_mount(value)
161 }
162}
163
164fn parse_mount(value: &str) -> Result<DockerVolumeMount, DockerVolumeError> {
165 let trimmed = value.trim();
166 if trimmed.is_empty() {
167 return Err(DockerVolumeError::Empty);
168 }
169
170 let (body, access) = match trimmed.rsplit_once(':') {
171 Some((body, mode)) if matches!(mode, "ro" | "rw") => (body, mode.parse()?),
172 _ => (trimmed, MountAccess::ReadWrite),
173 };
174
175 let (source, target) = match body.rsplit_once(':') {
176 Some((source, target)) => (Some(source.to_string()), target),
177 None => (None, body),
178 };
179 DockerVolumeMount::new(source, target, access)
180}
181
182fn classify_source(source: &str) -> VolumeKind {
183 let bytes = source.as_bytes();
184 if source.starts_with(['/', '.', '~'])
185 || source.contains('\\')
186 || bytes.get(1).is_some_and(|byte| *byte == b':')
187 {
188 VolumeKind::Bind
189 } else {
190 VolumeKind::Named
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::{DockerVolumeMount, MountAccess, VolumeKind};
197
198 #[test]
199 fn parses_volume_mounts() -> Result<(), Box<dyn std::error::Error>> {
200 let named: DockerVolumeMount = "cache:/var/cache:ro".parse()?;
201 let bind: DockerVolumeMount = "C:\\data:/data".parse()?;
202 let anonymous: DockerVolumeMount = "/var/lib/app".parse()?;
203
204 assert_eq!(named.kind(), VolumeKind::Named);
205 assert_eq!(named.access(), MountAccess::ReadOnly);
206 assert_eq!(bind.kind(), VolumeKind::Bind);
207 assert_eq!(anonymous.kind(), VolumeKind::Anonymous);
208 assert_eq!(named.to_string(), "cache:/var/cache:ro");
209 Ok(())
210 }
211}