Skip to main content

use_docker_volume/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when Docker volume syntax is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerVolumeError {
10    /// The mount text was empty after trimming.
11    Empty,
12    /// The target path was empty.
13    EmptyTarget,
14    /// The access mode was not `ro` or `rw`.
15    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/// Volume mount access mode.
31#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum MountAccess {
33    /// Read-write access.
34    ReadWrite,
35    /// Read-only access.
36    ReadOnly,
37}
38
39impl MountAccess {
40    /// Returns the Docker access suffix.
41    #[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/// Broad mount source classification.
69#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub enum VolumeKind {
71    /// A target-only anonymous volume.
72    Anonymous,
73    /// A named Docker volume.
74    Named,
75    /// A host bind mount.
76    Bind,
77}
78
79/// A Docker volume or bind mount specification.
80#[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    /// Creates a mount from validated parts.
90    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    /// Returns the optional mount source.
111    #[must_use]
112    pub fn source(&self) -> Option<&str> {
113        self.source.as_deref()
114    }
115
116    /// Returns the target path.
117    #[must_use]
118    pub fn target(&self) -> &str {
119        &self.target
120    }
121
122    /// Returns the broad mount kind.
123    #[must_use]
124    pub const fn kind(&self) -> VolumeKind {
125        self.kind
126    }
127
128    /// Returns the mount access mode.
129    #[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}