Skip to main content

moq_room/
path.rs

1//! Room path convention: identity is everything before the last segment, and
2//! that last segment is the broadcast kind (`camera` or `screen`).
3//!
4//! `alice/camera`, `alice/camera.hang`, and `guest/uuid/screen` are all valid.
5//! A `.hang` suffix on the kind is optional and equivalent.
6
7use moq_net::{AsPath, Path, PathOwned};
8
9/// The two broadcasts each participant may publish.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum Kind {
12	/// Camera plus microphone.
13	Camera,
14	/// Screenshare; its announce/unannounce is the share lifecycle.
15	Screen,
16}
17
18impl Kind {
19	/// The path segment this kind publishes as, without a catalog-format suffix.
20	pub const fn as_str(self) -> &'static str {
21		match self {
22			Self::Camera => "camera",
23			Self::Screen => "screen",
24		}
25	}
26}
27
28/// An announced path split into identity and kind.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Parsed {
31	/// Path prefix identifying the participant; may be more than one segment.
32	pub identity: PathOwned,
33	/// `camera` (camera + mic) or `screen` (screenshare).
34	pub kind: Kind,
35}
36
37/// Strip an optional `.hang` catalog-format suffix from a path segment.
38pub fn kind_from_segment(segment: &str) -> Option<Kind> {
39	let kind = segment.strip_suffix(".hang").unwrap_or(segment);
40	match kind {
41		"camera" => Some(Kind::Camera),
42		"screen" => Some(Kind::Screen),
43		_ => None,
44	}
45}
46
47/// Split a room-relative broadcast path into identity and kind.
48///
49/// Returns `None` when there is no identity, or the last segment is not a kind.
50pub fn parse(path: impl AsPath) -> Option<Parsed> {
51	let path = path.as_path();
52	let mut parts: Vec<&str> = path.parts().collect();
53	let last = parts.pop()?;
54	if parts.is_empty() {
55		return None;
56	}
57	let kind = kind_from_segment(last)?;
58	let identity = Path::new(&parts.join("/")).to_owned();
59	Some(Parsed { identity, kind })
60}
61
62/// The broadcast path a participant publishes for `kind`.
63pub fn broadcast_path(identity: impl AsPath, kind: Kind) -> PathOwned {
64	identity.as_path().join(format!("{}.hang", kind.as_str()))
65}
66
67#[cfg(test)]
68mod tests {
69	use super::*;
70
71	fn p(s: &str) -> PathOwned {
72		Path::new(s).to_owned()
73	}
74
75	#[test]
76	fn parse_splits_identity_and_kind() {
77		assert_eq!(
78			parse(Path::new("alice/camera")),
79			Some(Parsed {
80				identity: p("alice"),
81				kind: Kind::Camera
82			})
83		);
84		assert_eq!(
85			parse(Path::new("alice/screen")),
86			Some(Parsed {
87				identity: p("alice"),
88				kind: Kind::Screen
89			})
90		);
91	}
92
93	#[test]
94	fn parse_accepts_hang_suffix() {
95		assert_eq!(
96			parse(Path::new("alice/camera.hang")).map(|p| p.kind),
97			Some(Kind::Camera)
98		);
99		assert_eq!(
100			parse(Path::new("alice/screen.hang")).map(|p| p.kind),
101			Some(Kind::Screen)
102		);
103	}
104
105	#[test]
106	fn parse_keeps_multi_segment_identity() {
107		assert_eq!(
108			parse(Path::new("guest/uuid/camera")),
109			Some(Parsed {
110				identity: p("guest/uuid"),
111				kind: Kind::Camera
112			})
113		);
114	}
115
116	#[test]
117	fn parse_rejects_unknown() {
118		assert_eq!(parse(Path::new("alice")), None);
119		assert_eq!(parse(Path::new("alice/chat")), None);
120		assert_eq!(parse(Path::new("camera")), None);
121		assert_eq!(parse(Path::empty()), None);
122	}
123
124	#[test]
125	fn broadcast_path_joins_with_hang_suffix() {
126		assert_eq!(broadcast_path(p("alice"), Kind::Camera).as_str(), "alice/camera.hang");
127		assert_eq!(
128			broadcast_path(p("guest/uuid"), Kind::Screen).as_str(),
129			"guest/uuid/screen.hang"
130		);
131	}
132}