syncable_cli/analyzer/dclint/parser/
compose.rs

1//! Docker Compose file structure types.
2//!
3//! Defines the structure of a docker-compose.yaml file with support for
4//! position tracking.
5
6use std::collections::HashMap;
7use yaml_rust2::{Yaml, YamlLoader};
8
9/// Error type for parsing.
10#[derive(Debug, Clone, thiserror::Error)]
11pub enum ParseError {
12    #[error("YAML parse error: {0}")]
13    YamlError(String),
14    #[error("Empty document")]
15    EmptyDocument,
16    #[error("Invalid structure: {0}")]
17    InvalidStructure(String),
18}
19
20/// Position in the source file.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct Position {
23    pub line: u32,
24    pub column: u32,
25}
26
27impl Position {
28    pub fn new(line: u32, column: u32) -> Self {
29        Self { line, column }
30    }
31}
32
33/// Parsed Docker Compose file.
34#[derive(Debug, Clone, Default)]
35pub struct ComposeFile {
36    /// The deprecated `version` field.
37    pub version: Option<String>,
38    /// Position of the version field.
39    pub version_pos: Option<Position>,
40    /// The `name` field (project name).
41    pub name: Option<String>,
42    /// Position of the name field.
43    pub name_pos: Option<Position>,
44    /// Services defined in the compose file.
45    pub services: HashMap<String, Service>,
46    /// Position of the services section.
47    pub services_pos: Option<Position>,
48    /// Networks defined in the compose file.
49    pub networks: HashMap<String, serde_json::Value>,
50    /// Volumes defined in the compose file.
51    pub volumes: HashMap<String, serde_json::Value>,
52    /// Configs defined in the compose file.
53    pub configs: HashMap<String, serde_json::Value>,
54    /// Secrets defined in the compose file.
55    pub secrets: HashMap<String, serde_json::Value>,
56    /// Top-level key order (for ordering rules).
57    pub top_level_keys: Vec<String>,
58    /// Raw source content for position lookups.
59    pub source: String,
60}
61
62/// A service definition.
63#[derive(Debug, Clone, Default)]
64pub struct Service {
65    /// Service name.
66    pub name: String,
67    /// Position of the service definition.
68    pub position: Position,
69    /// The image to use.
70    pub image: Option<String>,
71    /// Position of the image field.
72    pub image_pos: Option<Position>,
73    /// Build configuration.
74    pub build: Option<ServiceBuild>,
75    /// Position of the build field.
76    pub build_pos: Option<Position>,
77    /// Container name.
78    pub container_name: Option<String>,
79    /// Position of the container_name field.
80    pub container_name_pos: Option<Position>,
81    /// Port mappings.
82    pub ports: Vec<ServicePort>,
83    /// Position of the ports field.
84    pub ports_pos: Option<Position>,
85    /// Volume mounts.
86    pub volumes: Vec<ServiceVolume>,
87    /// Position of the volumes field.
88    pub volumes_pos: Option<Position>,
89    /// Service dependencies.
90    pub depends_on: Vec<String>,
91    /// Position of the depends_on field.
92    pub depends_on_pos: Option<Position>,
93    /// Environment variables.
94    pub environment: HashMap<String, String>,
95    /// Pull policy (for build+image combinations).
96    pub pull_policy: Option<String>,
97    /// All keys in this service (for ordering rules).
98    pub keys: Vec<String>,
99    /// Raw YAML for this service.
100    pub raw: Option<Yaml>,
101}
102
103/// Build configuration for a service.
104#[derive(Debug, Clone)]
105pub enum ServiceBuild {
106    /// Simple build context path.
107    Simple(String),
108    /// Extended build configuration.
109    Extended {
110        context: Option<String>,
111        dockerfile: Option<String>,
112        args: HashMap<String, String>,
113        target: Option<String>,
114    },
115}
116
117impl Default for ServiceBuild {
118    fn default() -> Self {
119        Self::Simple(".".to_string())
120    }
121}
122
123/// Port mapping for a service.
124#[derive(Debug, Clone)]
125pub struct ServicePort {
126    /// Raw port string (e.g., "8080:80" or "80").
127    pub raw: String,
128    /// Position in the source.
129    pub position: Position,
130    /// Whether the port is quoted in source.
131    pub is_quoted: bool,
132    /// Host port (if specified).
133    pub host_port: Option<u16>,
134    /// Container port.
135    pub container_port: u16,
136    /// Host IP binding (e.g., "127.0.0.1").
137    pub host_ip: Option<String>,
138    /// Protocol (tcp/udp).
139    pub protocol: Option<String>,
140}
141
142impl ServicePort {
143    /// Parse a port string.
144    pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
145        let raw = raw.trim();
146        if raw.is_empty() {
147            return None;
148        }
149
150        // Handle protocol suffix
151        let (port_part, protocol) = if raw.contains('/') {
152            let parts: Vec<&str> = raw.rsplitn(2, '/').collect();
153            (parts[1], Some(parts[0].to_string()))
154        } else {
155            (raw, None)
156        };
157
158        // Handle different formats:
159        // - "80" (container only)
160        // - "8080:80" (host:container)
161        // - "127.0.0.1:8080:80" (ip:host:container)
162        // - "80-90:80-90" (range)
163        let parts: Vec<&str> = port_part.split(':').collect();
164
165        let (host_ip, host_port, container_port) = match parts.len() {
166            1 => {
167                // Just container port
168                let cp = parts[0].parse().ok()?;
169                (None, None, cp)
170            }
171            2 => {
172                // host:container
173                let hp = parts[0].parse().ok();
174                let cp = parts[1].parse().ok()?;
175                (None, hp, cp)
176            }
177            3 => {
178                // ip:host:container
179                let ip = Some(parts[0].to_string());
180                let hp = parts[1].parse().ok();
181                let cp = parts[2].parse().ok()?;
182                (ip, hp, cp)
183            }
184            _ => return None,
185        };
186
187        Some(Self {
188            raw: raw.to_string(),
189            position,
190            is_quoted,
191            host_port,
192            container_port,
193            host_ip,
194            protocol,
195        })
196    }
197
198    /// Check if this port has an explicit host binding interface.
199    pub fn has_explicit_interface(&self) -> bool {
200        self.host_ip.is_some()
201    }
202
203    /// Get the exported port (for duplicate checking).
204    pub fn exported_port(&self) -> Option<String> {
205        self.host_port.map(|p| {
206            if let Some(ip) = &self.host_ip {
207                format!("{}:{}", ip, p)
208            } else {
209                p.to_string()
210            }
211        })
212    }
213}
214
215/// Volume mount for a service.
216#[derive(Debug, Clone)]
217pub struct ServiceVolume {
218    /// Raw volume string.
219    pub raw: String,
220    /// Position in the source.
221    pub position: Position,
222    /// Whether the volume is quoted in source.
223    pub is_quoted: bool,
224    /// Source path or volume name.
225    pub source: Option<String>,
226    /// Target mount path in container.
227    pub target: String,
228    /// Mount options (ro, rw, etc.).
229    pub options: Option<String>,
230}
231
232impl ServiceVolume {
233    /// Parse a volume string.
234    pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
235        let raw = raw.trim();
236        if raw.is_empty() {
237            return None;
238        }
239
240        // Handle different formats:
241        // - "/path" (anonymous volume at path)
242        // - "name:/path" (named volume)
243        // - "/host:/container" (bind mount)
244        // - "/host:/container:ro" (bind mount with options)
245        let parts: Vec<&str> = raw.splitn(3, ':').collect();
246
247        let (source, target, options) = match parts.len() {
248            1 => (None, parts[0].to_string(), None),
249            2 => (Some(parts[0].to_string()), parts[1].to_string(), None),
250            3 => (
251                Some(parts[0].to_string()),
252                parts[1].to_string(),
253                Some(parts[2].to_string()),
254            ),
255            _ => return None,
256        };
257
258        Some(Self {
259            raw: raw.to_string(),
260            position,
261            is_quoted,
262            source,
263            target,
264            options,
265        })
266    }
267}
268
269/// Parse a Docker Compose file from a string.
270pub fn parse_compose(content: &str) -> Result<ComposeFile, ParseError> {
271    parse_compose_with_positions(content)
272}
273
274/// Parse a Docker Compose file with position tracking.
275pub fn parse_compose_with_positions(content: &str) -> Result<ComposeFile, ParseError> {
276    let docs =
277        YamlLoader::load_from_str(content).map_err(|e| ParseError::YamlError(e.to_string()))?;
278
279    let doc = docs.into_iter().next().ok_or(ParseError::EmptyDocument)?;
280
281    let hash = match &doc {
282        Yaml::Hash(h) => h,
283        _ => {
284            return Err(ParseError::InvalidStructure(
285                "Root must be a mapping".to_string(),
286            ));
287        }
288    };
289
290    let mut compose = ComposeFile {
291        source: content.to_string(),
292        ..Default::default()
293    };
294
295    // Track top-level key order
296    for (key, _) in hash {
297        if let Yaml::String(k) = key {
298            compose.top_level_keys.push(k.clone());
299        }
300    }
301
302    // Parse version
303    if let Some(Yaml::String(version)) = hash.get(&Yaml::String("version".to_string())) {
304        compose.version = Some(version.clone());
305        compose.version_pos =
306            super::find_line_for_key(content, &["version"]).map(|l| Position::new(l, 1));
307    }
308
309    // Parse name
310    if let Some(Yaml::String(name)) = hash.get(&Yaml::String("name".to_string())) {
311        compose.name = Some(name.clone());
312        compose.name_pos =
313            super::find_line_for_key(content, &["name"]).map(|l| Position::new(l, 1));
314    }
315
316    // Parse services
317    if let Some(Yaml::Hash(services)) = hash.get(&Yaml::String("services".to_string())) {
318        compose.services_pos =
319            super::find_line_for_key(content, &["services"]).map(|l| Position::new(l, 1));
320
321        for (name_yaml, service_yaml) in services {
322            if let Yaml::String(name) = name_yaml {
323                let service = parse_service(name, service_yaml, content)?;
324                compose.services.insert(name.clone(), service);
325            }
326        }
327    }
328
329    // Parse networks (as raw JSON for now)
330    if let Some(Yaml::Hash(networks)) = hash.get(&Yaml::String("networks".to_string())) {
331        for (name_yaml, value_yaml) in networks {
332            if let Yaml::String(name) = name_yaml {
333                compose
334                    .networks
335                    .insert(name.clone(), yaml_to_json(value_yaml));
336            }
337        }
338    }
339
340    // Parse volumes (as raw JSON for now)
341    if let Some(Yaml::Hash(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
342        for (name_yaml, value_yaml) in volumes {
343            if let Yaml::String(name) = name_yaml {
344                compose
345                    .volumes
346                    .insert(name.clone(), yaml_to_json(value_yaml));
347            }
348        }
349    }
350
351    Ok(compose)
352}
353
354/// Parse a service definition.
355fn parse_service(name: &str, yaml: &Yaml, source: &str) -> Result<Service, ParseError> {
356    let hash = match yaml {
357        Yaml::Hash(h) => h,
358        Yaml::Null => {
359            return Ok(Service {
360                name: name.to_string(),
361                ..Default::default()
362            });
363        }
364        _ => {
365            return Err(ParseError::InvalidStructure(format!(
366                "Service '{}' must be a mapping",
367                name
368            )));
369        }
370    };
371
372    let position = super::find_line_for_service(source, name)
373        .map(|l| Position::new(l, 1))
374        .unwrap_or_default();
375
376    let mut service = Service {
377        name: name.to_string(),
378        position,
379        raw: Some(yaml.clone()),
380        ..Default::default()
381    };
382
383    // Track key order
384    for (key, _) in hash {
385        if let Yaml::String(k) = key {
386            service.keys.push(k.clone());
387        }
388    }
389
390    // Parse image
391    if let Some(Yaml::String(image)) = hash.get(&Yaml::String("image".to_string())) {
392        service.image = Some(image.clone());
393        service.image_pos =
394            super::find_line_for_service_key(source, name, "image").map(|l| Position::new(l, 1));
395    }
396
397    // Parse build
398    if let Some(build_yaml) = hash.get(&Yaml::String("build".to_string())) {
399        service.build_pos =
400            super::find_line_for_service_key(source, name, "build").map(|l| Position::new(l, 1));
401
402        service.build = Some(match build_yaml {
403            Yaml::String(s) => ServiceBuild::Simple(s.clone()),
404            Yaml::Hash(h) => {
405                let context = h
406                    .get(&Yaml::String("context".to_string()))
407                    .and_then(|v| match v {
408                        Yaml::String(s) => Some(s.clone()),
409                        _ => None,
410                    });
411                let dockerfile =
412                    h.get(&Yaml::String("dockerfile".to_string()))
413                        .and_then(|v| match v {
414                            Yaml::String(s) => Some(s.clone()),
415                            _ => None,
416                        });
417                let target = h
418                    .get(&Yaml::String("target".to_string()))
419                    .and_then(|v| match v {
420                        Yaml::String(s) => Some(s.clone()),
421                        _ => None,
422                    });
423
424                ServiceBuild::Extended {
425                    context,
426                    dockerfile,
427                    args: HashMap::new(),
428                    target,
429                }
430            }
431            _ => ServiceBuild::Simple(".".to_string()),
432        });
433    }
434
435    // Parse container_name
436    if let Some(Yaml::String(container_name)) =
437        hash.get(&Yaml::String("container_name".to_string()))
438    {
439        service.container_name = Some(container_name.clone());
440        service.container_name_pos =
441            super::find_line_for_service_key(source, name, "container_name")
442                .map(|l| Position::new(l, 1));
443    }
444
445    // Parse ports
446    if let Some(Yaml::Array(ports)) = hash.get(&Yaml::String("ports".to_string())) {
447        service.ports_pos =
448            super::find_line_for_service_key(source, name, "ports").map(|l| Position::new(l, 1));
449
450        let ports_start_line = service.ports_pos.map(|p| p.line).unwrap_or(1);
451
452        for (idx, port_yaml) in ports.iter().enumerate() {
453            let line = ports_start_line + 1 + idx as u32;
454            let position = Position::new(line, 1);
455
456            match port_yaml {
457                Yaml::String(s) => {
458                    // Check if quoted in source
459                    let is_quoted = is_value_quoted_at_line(source, line);
460                    if let Some(port) = ServicePort::parse(s, position, is_quoted) {
461                        service.ports.push(port);
462                    }
463                }
464                Yaml::Integer(i) => {
465                    // Integer ports are unquoted
466                    let raw = i.to_string();
467                    if let Some(port) = ServicePort::parse(&raw, position, false) {
468                        service.ports.push(port);
469                    }
470                }
471                Yaml::Hash(h) => {
472                    // Long syntax port
473                    let target = h
474                        .get(&Yaml::String("target".to_string()))
475                        .and_then(|v| match v {
476                            Yaml::Integer(i) => Some(*i as u16),
477                            Yaml::String(s) => s.parse().ok(),
478                            _ => None,
479                        });
480                    let published =
481                        h.get(&Yaml::String("published".to_string()))
482                            .and_then(|v| match v {
483                                Yaml::Integer(i) => Some(*i as u16),
484                                Yaml::String(s) => s.parse().ok(),
485                                _ => None,
486                            });
487                    let host_ip =
488                        h.get(&Yaml::String("host_ip".to_string()))
489                            .and_then(|v| match v {
490                                Yaml::String(s) => Some(s.clone()),
491                                _ => None,
492                            });
493
494                    if let Some(container_port) = target {
495                        service.ports.push(ServicePort {
496                            raw: format!(
497                                "{}:{}",
498                                published.unwrap_or(container_port),
499                                container_port
500                            ),
501                            position,
502                            is_quoted: false,
503                            host_port: published,
504                            container_port,
505                            host_ip,
506                            protocol: None,
507                        });
508                    }
509                }
510                _ => {}
511            }
512        }
513    }
514
515    // Parse volumes
516    if let Some(Yaml::Array(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
517        service.volumes_pos =
518            super::find_line_for_service_key(source, name, "volumes").map(|l| Position::new(l, 1));
519
520        let volumes_start_line = service.volumes_pos.map(|p| p.line).unwrap_or(1);
521
522        for (idx, vol_yaml) in volumes.iter().enumerate() {
523            let line = volumes_start_line + 1 + idx as u32;
524            let position = Position::new(line, 1);
525
526            if let Yaml::String(s) = vol_yaml {
527                let is_quoted = is_value_quoted_at_line(source, line);
528                if let Some(vol) = ServiceVolume::parse(s, position, is_quoted) {
529                    service.volumes.push(vol);
530                }
531            }
532        }
533    }
534
535    // Parse depends_on
536    if let Some(depends_on_yaml) = hash.get(&Yaml::String("depends_on".to_string())) {
537        service.depends_on_pos = super::find_line_for_service_key(source, name, "depends_on")
538            .map(|l| Position::new(l, 1));
539
540        match depends_on_yaml {
541            Yaml::Array(arr) => {
542                for dep in arr {
543                    if let Yaml::String(s) = dep {
544                        service.depends_on.push(s.clone());
545                    }
546                }
547            }
548            Yaml::Hash(h) => {
549                // Long syntax: depends_on: { db: { condition: service_healthy } }
550                for (dep_name, _) in h {
551                    if let Yaml::String(s) = dep_name {
552                        service.depends_on.push(s.clone());
553                    }
554                }
555            }
556            _ => {}
557        }
558    }
559
560    // Parse environment
561    if let Some(env_yaml) = hash.get(&Yaml::String("environment".to_string())) {
562        match env_yaml {
563            Yaml::Hash(h) => {
564                for (key, value) in h {
565                    if let (Yaml::String(k), v) = (key, value) {
566                        let val = match v {
567                            Yaml::String(s) => s.clone(),
568                            Yaml::Integer(i) => i.to_string(),
569                            Yaml::Boolean(b) => b.to_string(),
570                            Yaml::Null => String::new(),
571                            _ => continue,
572                        };
573                        service.environment.insert(k.clone(), val);
574                    }
575                }
576            }
577            Yaml::Array(arr) => {
578                for item in arr {
579                    if let Yaml::String(s) = item {
580                        if let Some((k, v)) = s.split_once('=') {
581                            service.environment.insert(k.to_string(), v.to_string());
582                        } else {
583                            service.environment.insert(s.clone(), String::new());
584                        }
585                    }
586                }
587            }
588            _ => {}
589        }
590    }
591
592    // Parse pull_policy
593    if let Some(Yaml::String(pull_policy)) = hash.get(&Yaml::String("pull_policy".to_string())) {
594        service.pull_policy = Some(pull_policy.clone());
595    }
596
597    Ok(service)
598}
599
600/// Check if a value at a given line is quoted in the source.
601fn is_value_quoted_at_line(source: &str, line: u32) -> bool {
602    let lines: Vec<&str> = source.lines().collect();
603    if let Some(line_content) = lines.get((line - 1) as usize) {
604        let trimmed = line_content.trim();
605        // Check for list item with quoted value
606        if trimmed.starts_with('-') {
607            let after_dash = trimmed.trim_start_matches('-').trim();
608            return after_dash.starts_with('"') || after_dash.starts_with('\'');
609        }
610        // Check for key: value with quoted value
611        if let Some(pos) = trimmed.find(':') {
612            let after_colon = trimmed[pos + 1..].trim();
613            return after_colon.starts_with('"') || after_colon.starts_with('\'');
614        }
615    }
616    false
617}
618
619/// Convert a YAML value to JSON (for raw storage).
620fn yaml_to_json(yaml: &Yaml) -> serde_json::Value {
621    match yaml {
622        Yaml::Null => serde_json::Value::Null,
623        Yaml::Boolean(b) => serde_json::Value::Bool(*b),
624        Yaml::Integer(i) => serde_json::json!(i),
625        Yaml::Real(r) => {
626            if let Ok(f) = r.parse::<f64>() {
627                serde_json::json!(f)
628            } else {
629                serde_json::Value::String(r.clone())
630            }
631        }
632        Yaml::String(s) => serde_json::Value::String(s.clone()),
633        Yaml::Array(arr) => serde_json::Value::Array(arr.iter().map(yaml_to_json).collect()),
634        Yaml::Hash(h) => {
635            let mut map = serde_json::Map::new();
636            for (k, v) in h {
637                if let Yaml::String(key) = k {
638                    map.insert(key.clone(), yaml_to_json(v));
639                }
640            }
641            serde_json::Value::Object(map)
642        }
643        _ => serde_json::Value::Null,
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn test_parse_simple_compose() {
653        let yaml = r#"
654version: "3.8"
655name: myproject
656services:
657  web:
658    image: nginx:latest
659    ports:
660      - "8080:80"
661  db:
662    image: postgres:15
663"#;
664
665        let compose = parse_compose(yaml).unwrap();
666        assert_eq!(compose.version, Some("3.8".to_string()));
667        assert_eq!(compose.name, Some("myproject".to_string()));
668        assert_eq!(compose.services.len(), 2);
669
670        let web = compose.services.get("web").unwrap();
671        assert_eq!(web.image, Some("nginx:latest".to_string()));
672        assert_eq!(web.ports.len(), 1);
673        assert_eq!(web.ports[0].container_port, 80);
674        assert_eq!(web.ports[0].host_port, Some(8080));
675    }
676
677    #[test]
678    fn test_parse_build_and_image() {
679        let yaml = r#"
680services:
681  app:
682    build: .
683    image: myapp:latest
684"#;
685
686        let compose = parse_compose(yaml).unwrap();
687        let app = compose.services.get("app").unwrap();
688        assert!(app.build.is_some());
689        assert!(app.image.is_some());
690    }
691
692    #[test]
693    fn test_parse_port_formats() {
694        let yaml = r#"
695services:
696  web:
697    image: nginx
698    ports:
699      - 80
700      - "8080:80"
701      - "127.0.0.1:8081:80"
702"#;
703
704        let compose = parse_compose(yaml).unwrap();
705        let web = compose.services.get("web").unwrap();
706        assert_eq!(web.ports.len(), 3);
707
708        assert_eq!(web.ports[0].container_port, 80);
709        assert_eq!(web.ports[0].host_port, None);
710
711        assert_eq!(web.ports[1].container_port, 80);
712        assert_eq!(web.ports[1].host_port, Some(8080));
713
714        assert_eq!(web.ports[2].container_port, 80);
715        assert_eq!(web.ports[2].host_port, Some(8081));
716        assert_eq!(web.ports[2].host_ip, Some("127.0.0.1".to_string()));
717    }
718
719    #[test]
720    fn test_parse_depends_on() {
721        let yaml = r#"
722services:
723  web:
724    image: nginx
725    depends_on:
726      - db
727      - redis
728  db:
729    image: postgres
730  redis:
731    image: redis
732"#;
733
734        let compose = parse_compose(yaml).unwrap();
735        let web = compose.services.get("web").unwrap();
736        assert_eq!(web.depends_on, vec!["db", "redis"]);
737    }
738
739    #[test]
740    fn test_port_parsing() {
741        let pos = Position::new(1, 1);
742
743        let p1 = ServicePort::parse("80", pos, false).unwrap();
744        assert_eq!(p1.container_port, 80);
745        assert_eq!(p1.host_port, None);
746
747        let p2 = ServicePort::parse("8080:80", pos, true).unwrap();
748        assert_eq!(p2.container_port, 80);
749        assert_eq!(p2.host_port, Some(8080));
750        assert!(p2.is_quoted);
751
752        let p3 = ServicePort::parse("127.0.0.1:8080:80", pos, false).unwrap();
753        assert_eq!(p3.container_port, 80);
754        assert_eq!(p3.host_port, Some(8080));
755        assert_eq!(p3.host_ip, Some("127.0.0.1".to_string()));
756
757        let p4 = ServicePort::parse("80/udp", pos, false).unwrap();
758        assert_eq!(p4.container_port, 80);
759        assert_eq!(p4.protocol, Some("udp".to_string()));
760    }
761
762    #[test]
763    fn test_volume_parsing() {
764        let pos = Position::new(1, 1);
765
766        let v1 = ServiceVolume::parse("/data", pos, false).unwrap();
767        assert_eq!(v1.target, "/data");
768        assert_eq!(v1.source, None);
769
770        let v2 = ServiceVolume::parse("./host:/container", pos, false).unwrap();
771        assert_eq!(v2.source, Some("./host".to_string()));
772        assert_eq!(v2.target, "/container");
773
774        let v3 = ServiceVolume::parse("./host:/container:ro", pos, false).unwrap();
775        assert_eq!(v3.source, Some("./host".to_string()));
776        assert_eq!(v3.target, "/container");
777        assert_eq!(v3.options, Some("ro".to_string()));
778    }
779}