Skip to main content

outrig_cli/cli/
volume_arg.rs

1//! Parse `--volume` CLI entries for `outrig run` and `outrig mcp`.
2//!
3//! Syntax: `HOST:CONTAINER[:ro|rw]`. Two segments use the default access
4//! (read-only, matching config-file `[workspace.mounts]`); a third segment, if
5//! present, must be exactly `ro` or `rw`. Host paths therefore cannot contain
6//! `:` -- Windows drive-letter paths are unsupported, consistent with this
7//! being a Linux/podman tool.
8//!
9//! Relative host paths are preserved verbatim here and resolved against the
10//! repo root / current directory at launch time, identically to config mounts.
11
12use std::path::PathBuf;
13
14use outrig::config::MountAccess;
15
16/// A parsed `--volume HOST:CONTAINER[:ro|rw]` bind mount.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CliVolume {
19    pub host: PathBuf,
20    pub container: PathBuf,
21    pub access: MountAccess,
22}
23
24/// clap `value_parser` for `--volume`.
25pub fn parse_volume(raw: &str) -> Result<CliVolume, String> {
26    let parts: Vec<&str> = raw.split(':').collect();
27    let (host, container, access) = match parts.as_slice() {
28        [host, container] => (*host, *container, MountAccess::ReadOnly),
29        [host, container, mode] => {
30            let access = match *mode {
31                "ro" => MountAccess::ReadOnly,
32                "rw" => MountAccess::ReadWrite,
33                other => {
34                    return Err(format!(
35                        "invalid access {other:?} in --volume {raw:?}: expected `ro` or `rw`"
36                    ));
37                }
38            };
39            (*host, *container, access)
40        }
41        _ => {
42            return Err(format!(
43                "invalid --volume {raw:?}: expected HOST:CONTAINER[:ro|rw]"
44            ));
45        }
46    };
47
48    if host.is_empty() {
49        return Err(format!("invalid --volume {raw:?}: empty host path"));
50    }
51    if container.is_empty() {
52        return Err(format!("invalid --volume {raw:?}: empty container path"));
53    }
54
55    Ok(CliVolume {
56        host: PathBuf::from(host),
57        container: PathBuf::from(container),
58        access,
59    })
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn two_segments_default_to_read_only() {
68        let v = parse_volume("/host/data:/data").unwrap();
69        assert_eq!(v.host, PathBuf::from("/host/data"));
70        assert_eq!(v.container, PathBuf::from("/data"));
71        assert_eq!(v.access, MountAccess::ReadOnly);
72    }
73
74    #[test]
75    fn explicit_rw_and_ro_are_honored() {
76        assert_eq!(
77            parse_volume("/h:/c:rw").unwrap().access,
78            MountAccess::ReadWrite
79        );
80        assert_eq!(
81            parse_volume("/h:/c:ro").unwrap().access,
82            MountAccess::ReadOnly
83        );
84    }
85
86    #[test]
87    fn relative_host_path_is_preserved_verbatim() {
88        // Resolution against repo_root/cwd happens at launch, not here.
89        let v = parse_volume("data:/data:rw").unwrap();
90        assert_eq!(v.host, PathBuf::from("data"));
91    }
92
93    #[test]
94    fn rejects_unknown_access_segment() {
95        let err = parse_volume("/h:/c:rwx").unwrap_err();
96        assert!(err.contains("expected `ro` or `rw`"), "got: {err}");
97    }
98
99    #[test]
100    fn rejects_too_many_segments() {
101        let err = parse_volume("/h:/c:rw:extra").unwrap_err();
102        assert!(err.contains("HOST:CONTAINER[:ro|rw]"), "got: {err}");
103    }
104
105    #[test]
106    fn rejects_single_segment() {
107        let err = parse_volume("/h").unwrap_err();
108        assert!(err.contains("HOST:CONTAINER[:ro|rw]"), "got: {err}");
109    }
110
111    #[test]
112    fn rejects_empty_host() {
113        let err = parse_volume(":/c").unwrap_err();
114        assert!(err.contains("empty host path"), "got: {err}");
115    }
116
117    #[test]
118    fn rejects_empty_container() {
119        let err = parse_volume("/h:").unwrap_err();
120        assert!(err.contains("empty container path"), "got: {err}");
121    }
122}