Skip to main content

flodl_cli/
spec.rs

1//! The scheme-plus-path grammar the artifact specs share.
2//!
3//! Everything a box needs before it can train answers one question, and
4//! the answers differ only in size, mutability and public availability:
5//! the dataset source root, libtorch, the training source. That makes
6//! the *grammar* real shared structure while the resolvers stay
7//! genuinely separate — mounting a tree, unpacking an archive and
8//! fetching a checkout have no common shape worth abstracting. This
9//! module is the shared half and nothing else.
10//!
11//! Errors here carry only the reason. The caller names the field and
12//! spells out its accepted forms, because both differ per artifact and a
13//! message that says `data_source` when the operator mistyped `source`
14//! sends them to the wrong line.
15
16/// Split `<scheme>://<rest>`. A value with no `://` carries no
17/// transport, which each field answers for itself: for `data_path` a
18/// bare value is a path already mounted, for a source spec it is an
19/// error naming the transports.
20pub fn split_scheme(spec: &str) -> (Option<&str>, &str) {
21    match spec.split_once("://") {
22        Some((scheme, rest)) => (Some(scheme), rest),
23        None => (None, spec),
24    }
25}
26
27/// An ssh endpoint, in the spelling `sshfs` and `rsync` both take on the
28/// command line — and the one `/proc/mounts` reports back, which is what
29/// lets the already-mounted check be a string compare.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SshTarget {
32    /// `[user@]host:/abs/path`.
33    pub remote: String,
34    /// Non-default ssh port, when the spec named one.
35    pub port: Option<u16>,
36}
37
38/// Parse `[user@]host[:port]/abs/path`, and the scp spelling
39/// `[user@]host:/abs/path` for the same thing — both sshfs and rsync
40/// take the second, so refusing it would be a gratuitous trap.
41pub fn parse_ssh_target(rest: &str) -> Result<SshTarget, &'static str> {
42    let (user, hostpart) = match rest.split_once('@') {
43        Some(("", _)) => return Err("empty user before `@`"),
44        Some((u, h)) => (Some(u), h),
45        None => (None, rest),
46    };
47
48    // Split host from path at whichever delimiter comes first. A `:`
49    // followed by digits is a port; a `:` followed by `/` is the scp
50    // separator — and a port may itself be followed by the scp colon
51    // (`host:2222:/abs/path`), which is exactly what the documented
52    // grammar `[user@]host[:port]:/abs/path` produces when both parts
53    // are used at once. Refusing that spelling would make the docs'
54    // own grammar a parse error precisely on the guardrail recipe's
55    // advice (a non-standard external port).
56    let colon = hostpart.find(':');
57    let slash = hostpart.find('/');
58    let (host, port, path) = match (colon, slash) {
59        (Some(c), s) if s.is_none_or(|s| c < s) => {
60            let after = &hostpart[c + 1..];
61            if after.starts_with('/') {
62                (&hostpart[..c], None, after)
63            } else {
64                let end = after.find('/').ok_or("no remote path")?;
65                let port_str = after[..end].strip_suffix(':').unwrap_or(&after[..end]);
66                let port = port_str
67                    .parse::<u16>()
68                    .map_err(|_| "port is not a number")?;
69                (&hostpart[..c], Some(port), &after[end..])
70            }
71        }
72        (_, Some(s)) => (&hostpart[..s], None, &hostpart[s..]),
73        (_, None) => return Err("no remote path"),
74    };
75    if host.is_empty() {
76        return Err("empty host");
77    }
78    if path.len() < 2 {
79        return Err("the remote path must be absolute");
80    }
81    Ok(SshTarget {
82        remote: match user {
83            Some(u) => format!("{u}@{host}:{path}"),
84            None => format!("{host}:{path}"),
85        },
86        port,
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn a_scheme_is_only_a_scheme_with_its_separator() {
96        assert_eq!(
97            split_scheme("sshfs://exa/data"),
98            (Some("sshfs"), "exa/data")
99        );
100        assert_eq!(split_scheme("/flodl/data"), (None, "/flodl/data"));
101        // A colon alone is not a scheme: `host:/path` is the scp
102        // spelling, and reading it as one would swallow the host.
103        assert_eq!(split_scheme("exa:/flodl/data"), (None, "exa:/flodl/data"));
104    }
105
106    #[test]
107    fn an_ssh_target_parses_all_four_spellings() {
108        assert_eq!(
109            parse_ssh_target("flodl@exa:/flodl/data").unwrap(),
110            SshTarget {
111                remote: "flodl@exa:/flodl/data".into(),
112                port: None
113            },
114        );
115        assert_eq!(
116            parse_ssh_target("exa/flodl/data").unwrap(),
117            SshTarget {
118                remote: "exa:/flodl/data".into(),
119                port: None
120            },
121        );
122        assert_eq!(
123            parse_ssh_target("flodl@exa:2222/flodl/data").unwrap(),
124            SshTarget {
125                remote: "flodl@exa:/flodl/data".into(),
126                port: Some(2222)
127            },
128        );
129        // What the documented grammar `[user@]host[:port]:/abs/path`
130        // literally produces with both parts in play — the spelling an
131        // operator on a non-standard port will type first.
132        assert_eq!(
133            parse_ssh_target("flodl@exa:2222:/flodl/data").unwrap(),
134            SshTarget {
135                remote: "flodl@exa:/flodl/data".into(),
136                port: Some(2222)
137            },
138        );
139    }
140
141    #[test]
142    fn an_ssh_target_says_why_it_refused() {
143        for (spec, why) in [
144            ("exa", "no remote path"),
145            ("exa:2222", "no remote path"),
146            ("exa:banana/data", "port is not a number"),
147            ("@exa:/flodl/data", "empty user before `@`"),
148            (":/flodl/data", "empty host"),
149            ("/flodl/data", "empty host"),
150            ("exa:/", "the remote path must be absolute"),
151        ] {
152            assert_eq!(parse_ssh_target(spec), Err(why), "for {spec}");
153        }
154    }
155}