Skip to main content

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}