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}