choir_cli/join.rs
1//! The first five minutes: the invite link, and what this machine is.
2//!
3//! An invite is one link, because a link is what survives being pasted
4//! into a chat window. The node mints it in exactly one place and it
5//! looks like `https://node.example/join?i=<id>&k=<secret>`. Everything
6//! `choir join` needs is inside it — which node, and the credential that
7//! redeems one account there — so a person holding the link should never
8//! have to take it apart by hand into an API base, an invite file and a
9//! key path. [`Link::parse`] is that taking-apart, kept pure so the
10//! whole of it is checkable without a node.
11//!
12//! The second half is [`Role`]: the one-line answer to "what is this
13//! machine set up as", which `choir doctor` prints first and a bare
14//! `choir` prints instead of the index. It is computed from paths that
15//! are passed in rather than read here, so the caller's own resolution
16//! is what gets reported and the logic is testable against a temporary
17//! directory.
18//!
19//! # Examples
20//!
21//! ```
22//! let link = choir_cli::join::Link::parse("https://node.example/join?i=abc&k=s3cret")
23//! .expect("an invite link");
24//! assert_eq!(link.api, "https://node.example");
25//! assert_eq!(link.id, "abc");
26//! assert_eq!(link.secret, "s3cret");
27//! ```
28
29/// An invite link taken apart into the three things redeeming one needs.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Link {
32 /// The node's API base: scheme and authority, no path, no trailing
33 /// slash. The same string every other command takes as `<api>`.
34 pub api: String,
35 /// The invite's id, which is the user half of the credential that
36 /// redeems it.
37 pub id: String,
38 /// The invite's secret, which is the password half.
39 pub secret: String,
40}
41
42impl Link {
43 /// Whether a string is worth handing to [`Link::parse`].
44 ///
45 /// Used by the argument matcher to tell `choir join <link>` from
46 /// `choir join <api> <invite-file> <key-file>`, whose first argument
47 /// is also a URL. Only the path decides, so a mistyped link still
48 /// reaches `parse` and gets `parse`'s message rather than being
49 /// silently read as the three-argument form with two arguments
50 /// missing.
51 #[must_use]
52 pub fn looks_like(argument: &str) -> bool {
53 let Some((scheme, rest)) = argument.split_once("://") else {
54 return false;
55 };
56 (scheme == "http" || scheme == "https") && rest.contains("/join?")
57 }
58
59 /// Recovers the node and the invite from the link the operator sent.
60 ///
61 /// Hand-written rather than pulled from a URL crate: the grammar is
62 /// one path and two query parameters, both of which the node mints
63 /// itself, and this workspace adds dependencies reluctantly.
64 ///
65 /// # Errors
66 ///
67 /// Returns the sentence to print when the string is not an invite
68 /// link: no scheme, a scheme with no API base behind it, no `/join`
69 /// path, or either query parameter missing or empty. Every one of
70 /// them ends with the `next:` line a reader can act on.
71 pub fn parse(link: &str) -> Result<Link, String> {
72 let bad = |why: &str| {
73 format!(
74 "that does not look like an invite link ({why}).\n\
75 An invite is one link, like https://node.example/join?i=…&k=…\n\
76 next: paste the whole link, quoted: choir join '<link>'"
77 )
78 };
79 let (scheme, rest) = link.split_once("://").ok_or_else(|| bad("no scheme"))?;
80 if scheme != "http" && scheme != "https" {
81 return Err(bad("not an http(s) URL"));
82 }
83 let (authority, path) = rest.split_once('/').ok_or_else(|| bad("no /join path"))?;
84 // Everything before the last `@` is userinfo. A link should
85 // carry none, and one that does must not leave its tail in the
86 // host.
87 let host = authority
88 .rsplit_once('@')
89 .map_or(authority, |(_, host)| host);
90 if host.is_empty() {
91 return Err(bad("no host"));
92 }
93 let (route, query) = path.split_once('?').ok_or_else(|| bad("no invite in it"))?;
94 if route.trim_end_matches('/') != "join" {
95 return Err(bad("its path is not /join"));
96 }
97 // A shell that ate the `&` leaves `?i=<id>` alone and drops the
98 // rest, which is the single most likely way this arrives broken.
99 // Naming the quoting is more use than naming the missing field.
100 let mut id = None;
101 let mut secret = None;
102 for pair in query.split('&') {
103 match pair.split_once('=') {
104 Some(("i", value)) if !value.is_empty() => id = Some(value),
105 Some(("k", value)) if !value.is_empty() => secret = Some(value),
106 _ => {}
107 }
108 }
109 let (Some(id), Some(secret)) = (id, secret) else {
110 return Err(format!(
111 "that invite link is missing half of itself.\n\
112 A shell eats the `&` in an unquoted URL, which leaves exactly this.\n\
113 next: quote it: choir join '{link}&k=…'"
114 ));
115 };
116 Ok(Link {
117 api: format!("{scheme}://{host}"),
118 id: id.to_string(),
119 secret: secret.to_string(),
120 })
121 }
122}
123
124/// What one machine is set up as, in the vocabulary the documentation
125/// uses.
126///
127/// Three personas, and a machine is at most one of them: a reviewer
128/// never opens a terminal, so no local state can say "reviewer" and the
129/// enum does not pretend otherwise.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Role {
132 /// Nothing has been set up here yet.
133 Nothing,
134 /// Holds a credential for somebody else's node.
135 Contributor,
136 /// Runs a node: `choir init` has written a repository root here.
137 Operator,
138}
139
140impl Role {
141 /// Reads the role off the two paths that distinguish it.
142 ///
143 /// `state` is `~/.choir`. The operator test is the repository root
144 /// `choir init` creates, not the credential — an operator's node
145 /// issues itself one too, so the credential alone cannot tell the
146 /// two apart, and the root is the thing only an operator has.
147 #[must_use]
148 pub fn of(state: &std::path::Path, auth_file: Option<&str>) -> Role {
149 if state.join("repos").is_dir() {
150 return Role::Operator;
151 }
152 match auth_file {
153 Some(path) if std::path::Path::new(path).exists() => Role::Contributor,
154 _ => Role::Nothing,
155 }
156 }
157
158 /// The line `choir doctor` prints above its checks.
159 #[must_use]
160 pub fn line(self) -> &'static str {
161 match self {
162 Role::Nothing => "nothing configured yet, run `choir join <link>` or `choir init`",
163 Role::Contributor => "contributor: a credential for somebody else's node",
164 Role::Operator => "operator: this machine runs a node",
165 }
166 }
167}
168
169/// The whole of `choir` for a machine that has nothing set up.
170///
171/// Six lines, because the index is forty-eight commands long and a
172/// newcomer's question is not "what can this do". A reader who wants the
173/// index asks for it by name, which is what the last line says.
174///
175/// The install step a contributor also needs is deliberately not here.
176/// Anybody reading this already ran it — the binary printing the line is
177/// the evidence — and naming a release host this workspace does not yet
178/// have would be inventing one.
179#[must_use]
180pub fn orientation(style: crate::style::Style) -> String {
181 let row = |who: &str, command: &str, what: &str| {
182 format!(
183 " {} {} {}\n",
184 style.dim(&format!("{who:11}")),
185 style.cyan(&format!("{command:24}")),
186 style.dim(what)
187 )
188 };
189 let mut out = format!(
190 "{}\n\n",
191 style.bold("choir: one order over one repository, worked on by many agents at once.")
192 );
193 out.push_str(&row(
194 "contributor",
195 "choir join '<link>'",
196 "redeem the invite you were sent",
197 ));
198 out.push_str(&row("operator", "choir init", "run a node on this machine"));
199 out.push_str(&row(
200 "reviewer",
201 "(nothing to install)",
202 "open the link in a browser",
203 ));
204 out.push_str(&format!(
205 " {}\n",
206 style.dim("docs choir --help every command, and its arguments")
207 ));
208 out
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn a_link_with_a_port_keeps_it() {
217 let link = Link::parse("http://127.0.0.1:8417/join?i=a&k=b").expect("parses");
218 assert_eq!(link.api, "http://127.0.0.1:8417");
219 }
220
221 #[test]
222 fn the_three_argument_form_is_not_mistaken_for_a_link() {
223 assert!(!Link::looks_like("https://node.example"));
224 assert!(Link::looks_like("https://node.example/join?i=a&k=b"));
225 }
226
227 #[test]
228 fn a_link_the_shell_truncated_says_so() {
229 let error = Link::parse("https://node.example/join?i=a").expect_err("half a link");
230 assert!(error.contains("missing half of itself"), "{error}");
231 assert!(error.contains("next:"), "{error}");
232 }
233
234 #[test]
235 fn every_refusal_names_a_next_step() {
236 for bad in ["node.example/join?i=a&k=b", "ssh://x/join?i=a&k=b"] {
237 let error = Link::parse(bad).expect_err("refused");
238 assert!(error.contains("next:"), "{bad}: {error}");
239 }
240 }
241
242 #[test]
243 fn the_orientation_is_at_most_six_lines() {
244 let text = orientation(crate::style::Style::plain());
245 assert!(
246 text.lines().count() <= 6,
247 "orientation is {} lines:\n{text}",
248 text.lines().count()
249 );
250 }
251}