1use 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
124pub enum Repo {
125 Git { owner: String, name: String },
127 None,
129}
130
131#[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 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 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 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#[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 other: Vec<String>,
261}
262
263impl SessionUri {
264 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 pub fn other(&self) -> &[String] {
277 &self.other
278 }
279
280 pub fn is_other(&self) -> bool {
282 !self.other.is_empty()
283 }
284
285 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 pub fn with_lead(mut self, lead: bool) -> Self {
338 self.lead = lead;
339 self
340 }
341
342 pub fn lead(&self) -> bool {
344 self.lead
345 }
346
347 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 pub fn moved(mut self, place: Place) -> Self {
369 self.place = place;
370 self
371 }
372
373 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 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 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 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
460pub 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
475pub 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#[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
496pub const CHAT: &str = "chat";
498
499impl ThreadName {
500 pub fn chat() -> Self {
506 Self(CHAT.to_owned())
507 }
508
509 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 pub fn is_direct(&self) -> bool {
527 self.0.starts_with(DIRECT_PREFIX)
528 }
529
530 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 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 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
614macro_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}