Skip to main content

riff_core/
name.rs

1//! Session URIs and thread names.
2//!
3//! # Session URIs
4//!
5//! A session URI shows who a session is, where it works, and what it
6//! works on:
7//!
8//! ```text
9//! riff://mike@pangolin/como-technologies/riff?session=a6cf&claim=issue-6#pr-23
10//!        └─┬┘ └──┬───┘ └─────────┬────────┘ └────┬─────┘ └────┬────┘ └─┬─┘
11//!        user   host        owner/repo        session ID    claim    worktree
12//! ```
13//!
14//! | Part | Kind | Source |
15//! |---|---|---|
16//! | user | who | The sign-in. |
17//! | session | who | The session ID of the agent tool. A person has none. |
18//! | host | where | The machine name, without its domain. |
19//! | owner/repo | where | The `origin` remote of the git repository. |
20//! | worktree | where | The directory name of a linked worktree. The main worktree has none. |
21//! | lead | what | `lead=true` when the session is the lead of its user in its repository. |
22//! | claim | what | One part for each claim that the session holds. |
23//!
24//! *Who* ([`Who`]) never changes. *Where* ([`Place`]) changes when the
25//! session moves. *What* changes with each claim, and when the session
26//! becomes the lead or stops being the lead. The server keys each
27//! session by its [`Who`].
28//!
29//! Outside git, the repository part is `-` and the worktree part is the
30//! directory name: `riff://mike@pangolin/-?session=a6cf#notes`. A person
31//! who posts from the command line has no session and no place:
32//! `riff://mike@pangolin`.
33//!
34//! Each part holds only ASCII letters, digits, `-`, `_`, `.` and `~`.
35//! [`sanitize`] makes any text fit.
36//!
37//! The *short form* is for people: `mike@pangolin:riff#pr-23`. It drops
38//! the owner, the session and the claims, so it is not unique.
39//!
40//! # Thread names
41//!
42//! A thread name is free text without spaces, for example
43//! `como-technologies/riff`. The names that start with `dm:` belong to
44//! direct-message threads. Only [`ThreadName::direct`] makes them.
45
46use std::fmt;
47use std::str::FromStr;
48
49use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
50use serde::{Deserialize, Serialize};
51
52const SCHEME: &str = "riff://";
53
54/// Why a name did not parse or validate.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct NameError(String);
57
58impl NameError {
59    pub fn new(message: impl Into<String>) -> Self {
60        Self(message.into())
61    }
62}
63
64impl fmt::Display for NameError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70impl std::error::Error for NameError {}
71
72/// Who a session is: a user, and the session ID of the agent tool.
73/// A person on the command line has no session ID.
74///
75/// ```
76/// use riff_core::name::Who;
77///
78/// let agent = Who::new("mike", Some("a6cf"))?;
79/// assert_eq!(agent.to_string(), "mike/a6cf");
80/// assert_eq!(Who::new("mike", None)?.to_string(), "mike");
81/// # Ok::<(), riff_core::name::NameError>(())
82/// ```
83#[derive(
84    Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
85)]
86pub struct Who {
87    user: String,
88    session: Option<String>,
89}
90
91impl Who {
92    pub fn new(user: &str, session: Option<&str>) -> Result<Self, NameError> {
93        check("user", user)?;
94        if let Some(session) = session {
95            check("session", session)?;
96        }
97        Ok(Self {
98            user: user.into(),
99            session: session.map(Into::into),
100        })
101    }
102
103    pub fn user(&self) -> &str {
104        &self.user
105    }
106
107    pub fn session(&self) -> Option<&str> {
108        self.session.as_deref()
109    }
110}
111
112impl fmt::Display for Who {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(&self.user)?;
115        if let Some(session) = &self.session {
116            write!(f, "/{session}")?;
117        }
118        Ok(())
119    }
120}
121
122/// The repository part of a place.
123#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
124pub enum Repo {
125    /// A git repository, named by its `origin` remote.
126    Git { owner: String, name: String },
127    /// No git repository (`-`).
128    None,
129}
130
131/// Where a session works: a host, a repository and a worktree.
132///
133/// ```
134/// use riff_core::name::{Place, Repo};
135///
136/// let repo = Repo::Git { owner: "como-technologies".into(), name: "riff".into() };
137/// let place = Place::new("pangolin", repo, Some("pr-23"))?;
138/// assert_eq!(place.repo_text(), "como-technologies/riff");
139/// assert_eq!(place.default_thread().unwrap().to_string(), "como-technologies/riff");
140///
141/// // A person has a host and nothing else.
142/// assert!(Place::host_only("pangolin")?.default_thread().is_none());
143/// # Ok::<(), riff_core::name::NameError>(())
144/// ```
145#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
146pub struct Place {
147    host: String,
148    repo: Repo,
149    worktree: Option<String>,
150}
151
152impl Place {
153    pub fn new(host: &str, repo: Repo, worktree: Option<&str>) -> Result<Self, NameError> {
154        check("host", host)?;
155        if let Repo::Git { owner, name } = &repo {
156            check("owner", owner)?;
157            check("repo", name)?;
158        }
159        if let Some(worktree) = worktree {
160            check("worktree", worktree)?;
161        }
162        Ok(Self {
163            host: host.into(),
164            repo,
165            worktree: worktree.map(Into::into),
166        })
167    }
168
169    /// The place of a person on the command line: a host only.
170    pub fn host_only(host: &str) -> Result<Self, NameError> {
171        Self::new(host, Repo::None, None)
172    }
173
174    pub fn host(&self) -> &str {
175        &self.host
176    }
177
178    pub fn repo(&self) -> &Repo {
179        &self.repo
180    }
181
182    pub fn worktree(&self) -> Option<&str> {
183        self.worktree.as_deref()
184    }
185
186    /// `OWNER/REPO`, or `-` outside git.
187    pub fn repo_text(&self) -> String {
188        match &self.repo {
189            Repo::Git { owner, name } => format!("{owner}/{name}"),
190            Repo::None => "-".into(),
191        }
192    }
193
194    /// The thread that a session joins in this place: OWNER/REPO.
195    /// Outside git there is none.
196    pub fn default_thread(&self) -> Option<ThreadName> {
197        match &self.repo {
198            Repo::Git { .. } => Some(ThreadName(self.repo_text())),
199            Repo::None => None,
200        }
201    }
202
203    fn has_path(&self) -> bool {
204        self.repo != Repo::None || self.worktree.is_some()
205    }
206}
207
208/// The URI of one session: who, where and what.
209///
210/// It serializes as its full URI. Two URIs of one session differ when
211/// the session moves or claims.
212///
213/// ```
214/// use riff_core::name::SessionUri;
215///
216/// let text = "riff://mike@pangolin/como-technologies/riff?session=a6cf&claim=issue-6#pr-23";
217/// let uri: SessionUri = text.parse()?;
218/// assert_eq!(uri.to_string(), text);
219/// assert_eq!(uri.short(), "mike@pangolin:riff#pr-23");
220/// assert_eq!(uri.who().session(), Some("a6cf"));
221/// assert_eq!(uri.claims(), ["issue-6"]);
222/// assert!(!uri.lead());
223///
224/// let lead: SessionUri = "riff://mike@pangolin/como-technologies/riff?session=a6cf&lead=true".parse()?;
225/// assert!(lead.lead());
226/// # Ok::<(), riff_core::name::NameError>(())
227/// ```
228///
229/// # A query part of a later build (01M3XYYSY536AEJVERBPTQFQYX)
230///
231/// The set of query parts can grow, as `lead` and `claim` came. A URI
232/// in JSON keeps each query part that this build does not know
233/// ([`SessionUri::other`]), as it came. The session is the same
234/// session: its user, its session ID and its place. The part gives no
235/// mark: no lead and no claim. A URI that a person types
236/// ([`FromStr`]) takes only the parts of this build, and
237/// `riff-server` refuses a call of a caller with such a part.
238///
239/// ```
240/// use riff_core::name::SessionUri;
241///
242/// let known: SessionUri = "riff://ann@heron/acme/app?session=s1".parse()?;
243/// let json = r#""riff://ann@heron/acme/app?session=s1&wave=17&lead=maybe""#;
244/// let later: SessionUri = serde_json::from_str(json).unwrap();
245/// assert_eq!((later.who(), later.place()), (known.who(), known.place()));
246/// assert!(later.is_other() && !later.lead() && later.claims().is_empty());
247/// assert_eq!(later.other(), ["wave=17", "lead=maybe"]);
248/// assert_eq!(serde_json::to_string(&later).unwrap(), json);
249/// assert!(json.trim_matches('"').parse::<SessionUri>().is_err());
250/// # Ok::<(), riff_core::name::NameError>(())
251/// ```
252#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
253#[serde(try_from = "String", into = "String")]
254pub struct SessionUri {
255    who: Who,
256    place: Place,
257    lead: bool,
258    claims: Vec<String>,
259    /// Each query part that this build does not know, as it came.
260    other: Vec<String>,
261}
262
263impl SessionUri {
264    /// A URI with no claims. It is not the lead.
265    pub fn new(who: Who, place: Place) -> Self {
266        Self {
267            who,
268            place,
269            lead: false,
270            claims: Vec::new(),
271            other: Vec::new(),
272        }
273    }
274
275    /// Each query part that this build does not know, as it came.
276    pub fn other(&self) -> &[String] {
277        &self.other
278    }
279
280    /// True for a URI with a query part that this build does not know.
281    pub fn is_other(&self) -> bool {
282        !self.other.is_empty()
283    }
284
285    /// Reads a URI. With `later`, it keeps each query part that this
286    /// build does not know. With no `later`, such a part is an error.
287    fn read(s: &str, later: bool) -> Result<Self, NameError> {
288        let bad = || NameError(format!("not a session URI: {s}"));
289        let rest = s.strip_prefix(SCHEME).ok_or_else(bad)?;
290        let (rest, worktree) = match rest.split_once('#') {
291            Some((rest, worktree)) => (rest, Some(worktree)),
292            None => (rest, None),
293        };
294        let (rest, query) = match rest.split_once('?') {
295            Some((rest, query)) => (rest, Some(query)),
296            None => (rest, None),
297        };
298        let (authority, path) = match rest.split_once('/') {
299            Some((authority, path)) => (authority, Some(path)),
300            None => (rest, None),
301        };
302        let (user, host) = authority.split_once('@').ok_or_else(bad)?;
303        let repo = match path {
304            None | Some("-") => Repo::None,
305            Some(path) => {
306                let (owner, name) = path.split_once('/').ok_or_else(bad)?;
307                Repo::Git {
308                    owner: owner.into(),
309                    name: name.into(),
310                }
311            }
312        };
313        let mut session = None;
314        let mut lead = false;
315        let mut claims = Vec::new();
316        let mut other = Vec::new();
317        for pair in query.into_iter().flat_map(|q| q.split('&')) {
318            match pair.split_once('=') {
319                Some(("session", value)) if session.is_none() => session = Some(value),
320                Some(("lead", "true")) => lead = true,
321                Some(("claim", value)) if !later || check("claim", value).is_ok() => {
322                    check("claim", value)?;
323                    claims.push(value.to_owned());
324                }
325                _ if later => other.push(pair.to_owned()),
326                _ => return Err(bad()),
327            }
328        }
329        let who = Who::new(user, session)?;
330        let place = Place::new(host, repo, worktree)?;
331        let mut uri = Self::new(who, place).with_lead(lead).with_claims(claims);
332        uri.other = other;
333        Ok(uri)
334    }
335
336    /// The same URI, as the lead or not.
337    pub fn with_lead(mut self, lead: bool) -> Self {
338        self.lead = lead;
339        self
340    }
341
342    /// True when the session is the lead of its user in its repository.
343    pub fn lead(&self) -> bool {
344        self.lead
345    }
346
347    /// The same URI with these claims, in sorted order.
348    pub fn with_claims(mut self, mut claims: Vec<String>) -> Self {
349        claims.sort();
350        claims.dedup();
351        self.claims = claims;
352        self
353    }
354
355    pub fn who(&self) -> &Who {
356        &self.who
357    }
358
359    pub fn place(&self) -> &Place {
360        &self.place
361    }
362
363    pub fn claims(&self) -> &[String] {
364        &self.claims
365    }
366
367    /// The same URI in a new place.
368    pub fn moved(mut self, place: Place) -> Self {
369        self.place = place;
370        self
371    }
372
373    /// The short form for people: `USER@HOST:REPO#WORKTREE`.
374    ///
375    /// ```
376    /// # use riff_core::name::SessionUri;
377    /// let main: SessionUri = "riff://mike@pangolin/como-technologies/riff?session=a6cf".parse()?;
378    /// assert_eq!(main.short(), "mike@pangolin:riff");
379    /// let notes: SessionUri = "riff://mike@pangolin/-?session=a6cf#notes".parse()?;
380    /// assert_eq!(notes.short(), "mike@pangolin:-#notes");
381    /// let person: SessionUri = "riff://mike@pangolin".parse()?;
382    /// assert_eq!(person.short(), "mike@pangolin");
383    /// # Ok::<(), riff_core::name::NameError>(())
384    /// ```
385    pub fn short(&self) -> String {
386        let mut out = format!("{}@{}", self.who.user, self.place.host);
387        if !self.place.has_path() {
388            return out;
389        }
390        out.push(':');
391        match &self.place.repo {
392            Repo::Git { name, .. } => out.push_str(name),
393            Repo::None => out.push('-'),
394        }
395        if let Some(worktree) = &self.place.worktree {
396            out.push('#');
397            out.push_str(worktree);
398        }
399        out
400    }
401
402    /// The thread that this session joins by default: OWNER/REPO.
403    pub fn default_thread(&self) -> Option<ThreadName> {
404        self.place.default_thread()
405    }
406}
407
408impl fmt::Display for SessionUri {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        write!(f, "{SCHEME}{}@{}", self.who.user, self.place.host)?;
411        let query: Vec<String> = self
412            .who
413            .session
414            .iter()
415            .map(|s| format!("session={s}"))
416            .chain(self.lead.then(|| "lead=true".to_owned()))
417            .chain(self.claims.iter().map(|c| format!("claim={c}")))
418            .chain(self.other.iter().cloned())
419            .collect();
420        if self.place.has_path() || !query.is_empty() {
421            write!(f, "/{}", self.place.repo_text())?;
422        }
423        if !query.is_empty() {
424            write!(f, "?{}", query.join("&"))?;
425        }
426        if let Some(worktree) = &self.place.worktree {
427            write!(f, "#{worktree}")?;
428        }
429        Ok(())
430    }
431}
432
433impl FromStr for SessionUri {
434    type Err = NameError;
435
436    /// Parses a URI that a person or an agent typed: only the query
437    /// parts of this build.
438    fn from_str(s: &str) -> Result<Self, Self::Err> {
439        Self::read(s, false)
440    }
441}
442
443impl TryFrom<String> for SessionUri {
444    type Error = NameError;
445
446    /// Reads a URI of JSON: a record of the log, or a call. It keeps
447    /// each query part that this build does not know
448    /// (01M3XYYSY536AEJVERBPTQFQYX).
449    fn try_from(s: String) -> Result<Self, Self::Error> {
450        Self::read(&s, true)
451    }
452}
453
454impl From<SessionUri> for String {
455    fn from(uri: SessionUri) -> Self {
456        uri.to_string()
457    }
458}
459
460/// Replaces each character that a URI part cannot hold with `-`.
461///
462/// ```
463/// assert_eq!(riff_core::name::sanitize("feat/login page"), "feat-login-page");
464/// ```
465pub fn sanitize(part: &str) -> String {
466    part.chars()
467        .map(|c| if allowed(c) { c } else { '-' })
468        .collect()
469}
470
471fn allowed(c: char) -> bool {
472    c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')
473}
474
475/// Checks that one URI part is not empty and holds only allowed
476/// characters. `what` names the part in the error.
477pub fn check(what: &str, part: &str) -> Result<(), NameError> {
478    if part.is_empty() {
479        return Err(NameError(format!("the {what} part is empty")));
480    }
481    if !part.chars().all(allowed) {
482        return Err(NameError(format!(
483            "the {what} part has a character that is not allowed: {part}"
484        )));
485    }
486    Ok(())
487}
488
489/// The name of a thread.
490#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
491#[serde(try_from = "String", into = "String")]
492pub struct ThreadName(String);
493
494const DIRECT_PREFIX: &str = "dm:";
495
496/// The name of the chat thread of `riff chat`.
497pub const CHAT: &str = "chat";
498
499impl ThreadName {
500    /// The chat thread of `riff chat`.
501    ///
502    /// ```
503    /// assert_eq!(riff_core::name::ThreadName::chat().to_string(), "chat");
504    /// ```
505    pub fn chat() -> Self {
506        Self(CHAT.to_owned())
507    }
508
509    /// The thread that holds the direct messages between two sessions.
510    /// The order of the two sessions does not matter.
511    ///
512    /// ```
513    /// use riff_core::name::{ThreadName, Who};
514    ///
515    /// let a = Who::new("mike", Some("a6cf"))?;
516    /// let b = Who::new("brett", Some("77e0"))?;
517    /// assert_eq!(ThreadName::direct(&a, &b), ThreadName::direct(&b, &a));
518    /// # Ok::<(), riff_core::name::NameError>(())
519    /// ```
520    pub fn direct(a: &Who, b: &Who) -> Self {
521        let (first, second) = if a <= b { (a, b) } else { (b, a) };
522        Self(format!("{DIRECT_PREFIX}{first}|{second}"))
523    }
524
525    /// True for a thread of direct messages.
526    pub fn is_direct(&self) -> bool {
527        self.0.starts_with(DIRECT_PREFIX)
528    }
529
530    /// The other session of a direct thread of `who`. `None` when this
531    /// is not a direct thread of `who`.
532    ///
533    /// ```
534    /// use riff_core::name::{ThreadName, Who};
535    ///
536    /// let a = Who::new("mike", Some("a6cf"))?;
537    /// let b = Who::new("brett", Some("77e0"))?;
538    /// let c = Who::new("mike", None)?;
539    /// let thread = ThreadName::direct(&a, &b);
540    /// assert_eq!(thread.peer(&a), Some(b.clone()));
541    /// assert_eq!(thread.peer(&b), Some(a.clone()));
542    /// assert_eq!(thread.peer(&c), None);
543    /// assert_eq!(ThreadName::direct(&c, &a).peer(&a), Some(c));
544    /// # Ok::<(), riff_core::name::NameError>(())
545    /// ```
546    pub fn peer(&self, who: &Who) -> Option<Who> {
547        let (first, second) = self.0.strip_prefix(DIRECT_PREFIX)?.split_once('|')?;
548        let parse = |text: &str| {
549            let (user, session) = match text.split_once('/') {
550                Some((user, session)) => (user, Some(session)),
551                None => (text, None),
552            };
553            Who::new(user, session).ok()
554        };
555        let (first, second) = (parse(first)?, parse(second)?);
556        if &first == who {
557            Some(second)
558        } else if &second == who {
559            Some(first)
560        } else {
561            None
562        }
563    }
564}
565
566impl fmt::Display for ThreadName {
567    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
568        f.write_str(&self.0)
569    }
570}
571
572impl FromStr for ThreadName {
573    type Err = NameError;
574
575    /// Parses a thread name that a person or an agent typed. Direct-message
576    /// threads come only from [`ThreadName::direct`].
577    ///
578    /// ```
579    /// use riff_core::name::ThreadName;
580    ///
581    /// assert!("como-technologies/riff".parse::<ThreadName>().is_ok());
582    /// assert!("two words".parse::<ThreadName>().is_err());
583    /// assert!("dm:someone".parse::<ThreadName>().is_err());
584    /// ```
585    fn from_str(s: &str) -> Result<Self, Self::Err> {
586        if s.is_empty() || s.chars().any(char::is_whitespace) {
587            return Err(NameError(format!("not a thread name: {s:?}")));
588        }
589        if s.starts_with(DIRECT_PREFIX) {
590            return Err(NameError("use tell for direct messages".into()));
591        }
592        Ok(Self(s.into()))
593    }
594}
595
596impl TryFrom<String> for ThreadName {
597    type Error = NameError;
598
599    /// Accepts every thread name on the wire, direct ones included.
600    fn try_from(s: String) -> Result<Self, Self::Error> {
601        if s.starts_with(DIRECT_PREFIX) {
602            return Ok(Self(s));
603        }
604        s.parse()
605    }
606}
607
608impl From<ThreadName> for String {
609    fn from(name: ThreadName) -> Self {
610        name.0
611    }
612}
613
614/// A session URI and a thread name go on the wire as a string.
615macro_rules! string_schema {
616    ($($t:ident),*) => {$(
617        impl JsonSchema for $t {
618            fn schema_name() -> std::borrow::Cow<'static, str> {
619                stringify!($t).into()
620            }
621
622            fn json_schema(_: &mut SchemaGenerator) -> Schema {
623                json_schema!({ "type": "string" })
624            }
625        }
626    )*};
627}
628
629string_schema!(SessionUri, ThreadName);
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    fn riff() -> Repo {
636        Repo::Git {
637            owner: "como-technologies".into(),
638            name: "riff".into(),
639        }
640    }
641
642    fn uri(session: Option<&str>, worktree: Option<&str>) -> SessionUri {
643        SessionUri::new(
644            Who::new("mike", session).unwrap(),
645            Place::new("pangolin", riff(), worktree).unwrap(),
646        )
647    }
648
649    #[test]
650    fn a_full_uri_round_trips() {
651        let text = "riff://mike@pangolin/como-technologies/riff?session=a6cf&lead=true&claim=a&claim=b#pr-23";
652        let parsed: SessionUri = text.parse().unwrap();
653        assert_eq!(parsed.to_string(), text);
654        assert_eq!(parsed.claims(), ["a", "b"]);
655        assert!(parsed.lead());
656        assert_eq!(parsed.place().worktree(), Some("pr-23"));
657    }
658
659    #[test]
660    fn the_main_worktree_has_no_fragment() {
661        let main = uri(Some("a6cf"), None);
662        assert_eq!(
663            main.to_string(),
664            "riff://mike@pangolin/como-technologies/riff?session=a6cf"
665        );
666        assert_eq!(main.short(), "mike@pangolin:riff");
667    }
668
669    #[test]
670    fn two_sessions_in_one_worktree_differ() {
671        assert_ne!(uri(Some("a"), None), uri(Some("b"), None));
672        assert_eq!(uri(Some("a"), None).short(), uri(Some("b"), None).short());
673    }
674
675    #[test]
676    fn a_uri_outside_git_uses_a_dash() {
677        let text = "riff://mike@pangolin/-?session=a6cf#notes";
678        let parsed: SessionUri = text.parse().unwrap();
679        assert_eq!(parsed.place().repo(), &Repo::None);
680        assert_eq!(parsed.to_string(), text);
681        assert!(parsed.default_thread().is_none());
682    }
683
684    #[test]
685    fn a_person_has_only_user_and_host() {
686        let person: SessionUri = "riff://mike@pangolin".parse().unwrap();
687        assert_eq!(person.who().session(), None);
688        assert_eq!(person.to_string(), "riff://mike@pangolin");
689    }
690
691    #[test]
692    fn claims_come_out_sorted_and_once() {
693        let claimed = uri(Some("a"), None).with_claims(vec!["b".into(), "a".into(), "b".into()]);
694        assert_eq!(claimed.claims(), ["a", "b"]);
695    }
696
697    #[test]
698    fn bad_uris_do_not_parse() {
699        for bad in [
700            "",
701            "mike@pangolin/o/r",
702            "riff://pangolin/o/r",
703            "riff://mike@pangolin/norepo",
704            "riff://mike@pan golin/o/r",
705            "riff://@pangolin/o/r",
706            "riff://mike@pangolin/o/r?other=x",
707            "riff://mike@pangolin/o/r?session=a&session=b",
708            "riff://mike@pangolin/o/r?claim=a b",
709            "riff://mike@pangolin/o/r?session=a&lead=false",
710            "riff://mike@pangolin/o/r?session=a&lead=yes",
711        ] {
712            assert!(bad.parse::<SessionUri>().is_err(), "{bad}");
713        }
714    }
715
716    #[test]
717    fn a_direct_thread_does_not_depend_on_order() {
718        let a = Who::new("mike", Some("a")).unwrap();
719        let b = Who::new("mike", Some("b")).unwrap();
720        let thread = ThreadName::direct(&a, &b);
721        assert_eq!(thread, ThreadName::direct(&b, &a));
722        assert!(thread.is_direct());
723        let wire: ThreadName =
724            serde_json::from_str(&serde_json::to_string(&thread).unwrap()).unwrap();
725        assert_eq!(wire, thread);
726    }
727
728    #[test]
729    fn a_typed_thread_name_cannot_be_direct() {
730        assert!("dm:x".parse::<ThreadName>().is_err());
731        assert!("two words".parse::<ThreadName>().is_err());
732        assert!("como-technologies/riff".parse::<ThreadName>().is_ok());
733    }
734}