1#![warn(missing_docs)]
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub use grain_id::GrainId;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ManagedBy {
24 Service,
26 Spawned,
28}
29
30mod config;
31pub use config::{
32 ConfigEntry, ConfigFieldError, ConfigFieldErrors, ConfigGetParams, ConfigListParams,
33 ConfigListResult, ConfigSchemaResult, ConfigSetParams, ConfigUnsetParams, ConfigWriteResult,
34};
35pub mod settings;
36pub use settings::{Apply, Entry, EntryValue, Kind, Origin, Scope, SecretState, SettingDef, Value};
37
38mod embed;
39pub use embed::{
40 ApiKey, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, DeviceSettings, EmbedDeviceSetParams,
41 EmbedKeySetParams, EmbedModelSetParams, EmbedNote, EmbedRequest, EmbedSettingsResult,
42 LOCAL_MODEL, LocalModel, MAX_TOKENS, ModelSettings, ModelSource, QType, RemoteModel, Slot,
43 SlotModel, describe,
44};
45
46mod external;
47pub use external::{
48 ExternalDeviceAddParams, ExternalDeviceAuthenticateParams, ExternalDeviceAuthenticateResult,
49 ExternalDeviceInfo, ExternalDeviceListResult, ExternalDeviceOutcome, ExternalDeviceRequest,
50 ExternalDeviceSelectParams, ExternalDeviceSetAppsParams, ExternalDeviceTokenResult,
51 describe_external_device, describe_external_outcome,
52};
53
54#[cfg(feature = "cli")]
55mod cli;
56#[cfg(feature = "cli")]
57pub use cli::{
58 ConfigCommand, EmbeddingCommand, ExternalDeviceCommand, KeyCommand, LocalCommand,
59 RemoteCommand, ScopeFlags, Switch, describe_entries, parse_entries, read_key, read_secret,
60};
61
62pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
68
69const fn parse_major(s: &str) -> u32 {
71 let bytes = s.as_bytes();
72 let mut i = 0;
73 let mut n = 0u32;
74 while i < bytes.len() {
75 n = n * 10 + (bytes[i] - b'0') as u32;
76 i += 1;
77 }
78 n
79}
80
81pub const BRIDGE_NAME: &str = "bridge";
83pub const BRIDGE_DATA_NAME: &str = "bridge-data";
85pub const ALPN: &[u8] = b"sapphire/ws/1";
87
88pub const REGISTER: &str = "bridge.register";
90pub const UNREGISTER: &str = "bridge.unregister";
92pub const PEERS: &str = "bridge.peers";
94pub const STATUS: &str = "bridge.status";
96pub const INCOMING: &str = "bridge.incoming";
98pub const INVITE: &str = "bridge.invite";
100pub const JOIN: &str = "bridge.join";
102pub const WORKSPACES: &str = "bridge.workspaces";
104pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
106pub const DEVICE_RETIRE: &str = "bridge.device_retire";
108pub const EMBED_INFO: &str = "embed.info";
110pub const EMBED: &str = "embed.embed";
112pub const EMBED_IMAGES: &str = "embed.embed_images";
115pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
117pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
119pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
121pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
123pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
125pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
127pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
129pub const EMBED_SETTINGS: &str = "embed.settings";
131pub const EMBED_MODEL_SET: &str = "embed.model_set";
133pub const EMBED_DEVICE_SET: &str = "embed.device_set";
135pub const EMBED_KEY_SET: &str = "embed.key_set";
137pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
139
140pub const CONFIG_SCHEMA: &str = "config.schema";
142pub const CONFIG_LIST: &str = "config.list";
144pub const CONFIG_GET: &str = "config.get";
146pub const CONFIG_SET: &str = "config.set";
148pub const CONFIG_UNSET: &str = "config.unset";
150pub const RESTART: &str = "bridge.restart";
152
153pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
155
156pub const BRIDGE_NOT_RUNNING: i32 = -32001;
164
165pub const RELAYED: &[&str] = &[
167 STATUS,
168 PEERS,
169 WORKSPACES,
170 WORKGROUP_CREATE,
171 INVITE,
172 JOIN,
173 DEVICE_RETIRE,
174 DEVICE_PRIORITY_SET,
175 EMBED_SETTINGS,
176 EMBED_MODEL_SET,
177 EMBED_DEVICE_SET,
178 EMBED_KEY_SET,
179 EMBED_KEY_CLEAR,
180 EXTERNAL_DEVICE_LIST,
181 EXTERNAL_DEVICE_ADD,
182 EXTERNAL_DEVICE_ROTATE,
183 EXTERNAL_DEVICE_RETIRE,
184 EXTERNAL_DEVICE_RESTORE,
185 EXTERNAL_DEVICE_SET_APPS,
186 RESTART,
187];
188
189pub const ANSWERED_BY_APP_SERVER: &[&str] = &[
192 CONFIG_SCHEMA,
193 CONFIG_LIST,
194 CONFIG_GET,
195 CONFIG_SET,
196 CONFIG_UNSET,
197];
198
199pub const NOT_RELAYED: &[&str] = &[
202 REGISTER,
203 UNREGISTER,
204 INCOMING,
205 EMBED_INFO,
206 EMBED,
207 EMBED_IMAGES,
208 EXTERNAL_DEVICE_AUTHENTICATE,
209];
210
211pub const DEFAULT_PRIORITY: u8 = 1;
214
215fn default_priority() -> u8 {
216 DEFAULT_PRIORITY
217}
218
219#[derive(Clone, Debug, Deserialize, Serialize)]
221pub struct DevicePrioritySetParams {
222 pub selector: String,
224 pub priority: u8,
226}
227
228#[derive(Clone, Debug, Deserialize, Serialize)]
230pub struct DevicePrioritySetResult {
231 pub device_id: GrainId,
233 pub name: String,
235 pub priority: u8,
237}
238
239#[derive(Clone, Debug, Deserialize, Serialize)]
241pub struct InviteParams {
242 pub name: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub ttl: Option<u64>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub workgroup: Option<String>,
250}
251
252#[derive(Clone, Debug, Deserialize, Serialize)]
254pub struct InviteResult {
255 pub ticket: String,
257}
258
259#[derive(Clone, Debug, Deserialize, Serialize)]
261pub struct WorkgroupCreateParams {
262 pub name: String,
264 pub device_name: String,
266}
267
268#[derive(Clone, Debug, Deserialize, Serialize)]
270pub struct WorkgroupCreateResult {
271 pub workgroup_id: GrainId,
273 pub name: String,
275 pub device_id: GrainId,
277}
278
279#[derive(Clone, Debug, Deserialize, Serialize)]
281pub struct DeviceRetireParams {
282 pub selector: String,
284}
285
286#[derive(Clone, Debug, Deserialize, Serialize)]
288pub struct DeviceRetireResult {
289 pub device_id: GrainId,
291 pub name: String,
293}
294
295#[derive(Clone, Debug, Deserialize, Serialize)]
297pub struct JoinParams {
298 pub ticket: String,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub device_name: Option<String>,
303}
304
305#[derive(Clone, Debug, Deserialize, Serialize)]
307pub struct JoinResult {
308 pub workgroup_id: GrainId,
310 pub workgroup_name: String,
312 pub device_id: GrainId,
314}
315
316#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
318pub struct WorkgroupWorkspaceInfo {
319 pub workspace_id: GrainId,
321 pub app_name: String,
323 pub name: String,
325}
326
327#[derive(Clone, Debug, Deserialize, Serialize)]
329pub struct WorkspacesResult {
330 pub workspaces: Vec<WorkgroupWorkspaceInfo>,
332}
333
334#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
336pub struct Ack {}
337
338#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
340pub struct WorkspaceRegistration {
341 pub workspace_id: GrainId,
343 pub root: PathBuf,
345}
346
347#[derive(Clone, Debug, Deserialize, Serialize)]
349pub struct RegisterParams {
350 pub app_name: String,
352 pub exe_path: PathBuf,
354 pub managed_by: ManagedBy,
356 pub workspaces: Vec<WorkspaceRegistration>,
358}
359
360#[derive(Clone, Debug, Deserialize, Serialize)]
362pub struct RegisterResult {
363 pub device_id: GrainId,
365 pub node_id: String,
367 pub workgroup_id: GrainId,
369}
370
371#[derive(Clone, Debug, Deserialize, Serialize)]
373pub struct UnregisterParams {
374 pub workspace_id: GrainId,
376}
377
378#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
380pub struct WorkspaceRoles {
381 pub workspace_id: GrainId,
383 #[serde(default)]
385 pub primary: Option<GrainId>,
386 #[serde(default)]
388 pub secondary: Option<GrainId>,
389}
390
391#[derive(Clone, Debug, Deserialize, Serialize)]
393pub struct PeerInfo {
394 pub device_id: GrainId,
396 pub name: String,
398 pub node_id: String,
400 pub connected: bool,
402 #[serde(default = "default_priority")]
404 pub priority: u8,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub availability: Option<u8>,
408}
409
410#[derive(Clone, Debug, Deserialize, Serialize)]
412pub struct PeersResult {
413 pub peers: Vec<PeerInfo>,
415 #[serde(default)]
418 pub roles: Vec<WorkspaceRoles>,
419}
420
421impl PeersResult {
422 pub fn roles_for(&self, workspace_id: GrainId) -> Option<&WorkspaceRoles> {
424 self.roles.iter().find(|r| r.workspace_id == workspace_id)
425 }
426}
427
428#[derive(Clone, Debug, Deserialize, Serialize)]
430pub struct RouteStatus {
431 pub workspace_id: GrainId,
433 pub app_name: String,
435 pub root: PathBuf,
437 pub owner_online: bool,
439}
440
441#[derive(Clone, Debug, Deserialize, Serialize)]
443pub struct WorkgroupStatus {
444 pub workgroup_id: GrainId,
446 pub name: String,
448 pub devices: usize,
450}
451
452#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
454pub struct EmbedModelInfo {
455 pub model: String,
457 pub dimension: u32,
459 pub template_version: u32,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub revision: Option<String>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub max_tokens: Option<u32>,
467 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
470 pub images: bool,
471}
472
473#[derive(Clone, Debug, Default, Deserialize, Serialize)]
475pub struct EmbedInfoResult {
476 pub enabled: bool,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub model: Option<EmbedModelInfo>,
481 #[serde(default)]
483 pub loaded: bool,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub note: Option<EmbedNote>,
487}
488
489#[derive(Clone, Debug, Deserialize, Serialize)]
491pub struct EmbedParams {
492 pub texts: Vec<String>,
494}
495
496#[derive(Clone, Debug, Deserialize, Serialize)]
498pub struct EmbedImagesParams {
499 pub images: Vec<String>,
501}
502
503impl EmbedImagesParams {
504 pub fn new<B: AsRef<[u8]>>(images: &[B]) -> EmbedImagesParams {
506 use base64::Engine as _;
507 EmbedImagesParams {
508 images: images
509 .iter()
510 .map(|i| base64::engine::general_purpose::STANDARD.encode(i))
511 .collect(),
512 }
513 }
514
515 pub fn decode(&self) -> Result<Vec<Vec<u8>>, String> {
517 use base64::Engine as _;
518 self.images
519 .iter()
520 .enumerate()
521 .map(|(i, b64)| {
522 base64::engine::general_purpose::STANDARD
523 .decode(b64)
524 .map_err(|e| format!("image {i}: {e}"))
525 })
526 .collect()
527 }
528}
529
530#[derive(Clone, Debug, Deserialize, Serialize)]
532pub struct EmbedResult {
533 pub model: String,
535 pub dimension: u32,
537 pub vectors: Vec<Vec<f32>>,
539}
540
541#[derive(Clone, Debug, Deserialize, Serialize)]
543pub struct StatusResult {
544 pub version: String,
546 pub node_id: String,
548 pub workgroup: Option<WorkgroupStatus>,
550 pub routes: Vec<RouteStatus>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub embedding: Option<EmbedInfoResult>,
555}
556
557#[derive(Clone, Debug, Deserialize, Serialize)]
559pub struct IncomingParams {
560 pub workspace_id: GrainId,
562 pub peer_device_id: GrainId,
564 pub ticket: String,
569}
570
571#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
573#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
574pub enum DataHeader {
575 Open {
577 workspace_id: GrainId,
579 device_id: GrainId,
581 },
582 Accept {
584 ticket: String,
586 },
587}
588
589#[derive(Clone, Debug, Deserialize, Serialize)]
591pub struct DataAck {
592 pub ok: bool,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub error: Option<String>,
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
604 serde_json::from_value(serde_json::to_value(v).unwrap()).unwrap()
605 }
606
607 fn id() -> GrainId {
608 GrainId::random()
609 }
610
611 #[test]
612 fn embed_info_round_trips() {
613 let info = EmbedInfoResult {
614 enabled: true,
615 model: Some(EmbedModelInfo {
616 model: "m".into(),
617 dimension: 384,
618 template_version: 1,
619 revision: None,
620 max_tokens: None,
621 images: false,
622 }),
623 loaded: true,
624 note: Some(EmbedNote::KeyMissing),
625 };
626 let back = round_trip(&info);
627 assert_eq!(back.note, Some(EmbedNote::KeyMissing));
628 assert!(back.enabled && back.loaded);
629 assert_eq!(back.model, info.model);
630 let off = serde_json::to_value(EmbedInfoResult::default()).unwrap();
631 assert_eq!(off, serde_json::json!({"enabled": false, "loaded": false}));
632 }
633
634 #[test]
635 fn status_without_embedding_deserializes() {
636 let json = serde_json::json!({
637 "version": "1", "node_id": "n", "workgroup": null, "routes": []
638 });
639 let status: StatusResult = serde_json::from_value(json).unwrap();
640 assert!(status.embedding.is_none());
641 }
642
643 #[test]
644 fn embed_params_wire_shape() {
645 let v = serde_json::to_value(EmbedParams {
646 texts: vec!["a".into()],
647 })
648 .unwrap();
649 assert_eq!(v, serde_json::json!({"texts": ["a"]}));
650 let r = round_trip(&EmbedResult {
651 model: "m".into(),
652 dimension: 2,
653 vectors: vec![vec![1.0, 2.0]],
654 });
655 assert_eq!(r.vectors, vec![vec![1.0, 2.0]]);
656 }
657
658 #[test]
659 fn registration_round_trips() {
660 let params = RegisterParams {
661 app_name: "sapphire-journal".into(),
662 exe_path: "/usr/bin/sapphire-journal".into(),
663 managed_by: ManagedBy::Spawned,
664 workspaces: vec![WorkspaceRegistration {
665 workspace_id: id(),
666 root: "/home/me/journal".into(),
667 }],
668 };
669 let back = round_trip(¶ms);
670 assert_eq!(back.app_name, "sapphire-journal");
671 assert_eq!(back.workspaces.len(), 1);
672 }
673
674 #[test]
675 fn invite_params_carry_the_cli_arguments() {
676 let params = InviteParams {
677 name: "phone".into(),
678 ttl: Some(600),
679 workgroup: None,
680 };
681 let json = serde_json::to_value(¶ms).unwrap();
683 assert_eq!(json["name"], "phone");
684 assert_eq!(json["ttl"], 600);
685 assert!(json.get("workgroup").is_none());
686 }
687
688 #[test]
689 fn workspaces_result_round_trips() {
690 let raw = serde_json::json!({ "workspaces": [] });
691 let parsed: WorkspacesResult = serde_json::from_value(raw).unwrap();
692 assert!(parsed.workspaces.is_empty());
693 }
694
695 #[test]
696 fn an_open_header_round_trips_and_is_tagged() {
697 let header = DataHeader::Open {
698 workspace_id: id(),
699 device_id: id(),
700 };
701 let value = serde_json::to_value(&header).unwrap();
702 assert_eq!(value["kind"], "open");
703 assert_eq!(round_trip(&header), header);
704 }
705
706 #[test]
707 fn an_accept_header_round_trips_and_is_tagged() {
708 let header = DataHeader::Accept {
709 ticket: "t-123".into(),
710 };
711 let value = serde_json::to_value(&header).unwrap();
712 assert_eq!(value["kind"], "accept");
713 assert_eq!(round_trip(&header), header);
714 }
715
716 #[test]
717 fn an_unknown_header_kind_is_refused() {
718 let value = serde_json::json!({ "kind": "sideways" });
719 assert!(serde_json::from_value::<DataHeader>(value).is_err());
720 }
721
722 #[test]
723 fn a_data_ack_carries_its_reason_when_it_fails() {
724 let ack = DataAck {
725 ok: false,
726 error: Some("no such workspace".into()),
727 };
728 assert_eq!(round_trip(&ack).error.as_deref(), Some("no such workspace"));
729 }
730
731 #[test]
732 fn method_names_are_namespaced() {
733 for name in [
734 REGISTER, UNREGISTER, PEERS, STATUS, INCOMING, INVITE, JOIN, WORKSPACES,
735 ] {
736 assert!(name.starts_with("bridge."), "{name}");
737 }
738 }
739
740 #[test]
741 fn config_method_names_are_namespaced() {
742 for name in [
743 CONFIG_SCHEMA,
744 CONFIG_LIST,
745 CONFIG_GET,
746 CONFIG_SET,
747 CONFIG_UNSET,
748 ] {
749 assert!(name.starts_with("config."), "{name}");
750 }
751 assert!(RESTART.starts_with("bridge."));
752 }
753
754 #[test]
755 fn every_method_is_relayed_answered_or_not() {
756 let all: std::collections::BTreeSet<&str> = [
757 REGISTER,
758 UNREGISTER,
759 PEERS,
760 STATUS,
761 INCOMING,
762 INVITE,
763 JOIN,
764 WORKSPACES,
765 WORKGROUP_CREATE,
766 DEVICE_RETIRE,
767 EMBED_INFO,
768 EMBED,
769 EMBED_IMAGES,
770 EXTERNAL_DEVICE_LIST,
771 EXTERNAL_DEVICE_ADD,
772 EXTERNAL_DEVICE_RETIRE,
773 EXTERNAL_DEVICE_RESTORE,
774 EXTERNAL_DEVICE_ROTATE,
775 EXTERNAL_DEVICE_SET_APPS,
776 EXTERNAL_DEVICE_AUTHENTICATE,
777 EMBED_SETTINGS,
778 EMBED_MODEL_SET,
779 EMBED_DEVICE_SET,
780 EMBED_KEY_SET,
781 EMBED_KEY_CLEAR,
782 CONFIG_SCHEMA,
783 CONFIG_LIST,
784 CONFIG_GET,
785 CONFIG_SET,
786 CONFIG_UNSET,
787 RESTART,
788 DEVICE_PRIORITY_SET,
789 ]
790 .into_iter()
791 .collect();
792 let relayed: std::collections::BTreeSet<&str> = RELAYED.iter().copied().collect();
793 let not_relayed: std::collections::BTreeSet<&str> = NOT_RELAYED.iter().copied().collect();
794 let answered: std::collections::BTreeSet<&str> =
795 ANSWERED_BY_APP_SERVER.iter().copied().collect();
796 assert_eq!(
797 answered.len(),
798 ANSWERED_BY_APP_SERVER.len(),
799 "ANSWERED_BY_APP_SERVER repeats a method"
800 );
801 assert!(relayed.is_disjoint(&answered));
802 assert!(not_relayed.is_disjoint(&answered));
803 assert_eq!(relayed.len(), RELAYED.len(), "RELAYED repeats a method");
804 assert_eq!(
805 not_relayed.len(),
806 NOT_RELAYED.len(),
807 "NOT_RELAYED repeats a method"
808 );
809 assert!(relayed.is_disjoint(¬_relayed));
810 let union: std::collections::BTreeSet<&str> = relayed
811 .union(¬_relayed)
812 .chain(answered.iter())
813 .copied()
814 .collect();
815 assert_eq!(union, all);
816 }
817
818 #[test]
819 fn the_crate_stays_free_of_the_workspace_and_network_stacks() {
820 let manifest = include_str!("../Cargo.toml");
821 for forbidden in [
822 "iroh",
823 "sapphire-framework-workspace",
824 "sapphire-framework-retrieve",
825 "sapphire-framework-backend",
826 ] {
827 assert!(
828 !manifest.contains(forbidden),
829 "sapphire-framework-bridge-api must not depend on {forbidden}"
830 );
831 }
832 }
833
834 #[test]
835 fn the_api_version_is_the_crate_major() {
836 let major: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
837 assert_eq!(API_VERSION, major);
838 assert_eq!(API_VERSION, 3);
839 }
840
841 #[test]
842 fn workgroup_create_and_device_retire_wire_shapes() {
843 let p = WorkgroupCreateParams {
844 name: "home".into(),
845 device_name: "desk".into(),
846 };
847 assert_eq!(
848 serde_json::to_value(&p).unwrap(),
849 serde_json::json!({ "name": "home", "device_name": "desk" })
850 );
851 let p = DeviceRetireParams {
852 selector: "laptop".into(),
853 };
854 assert_eq!(
855 serde_json::to_value(&p).unwrap(),
856 serde_json::json!({ "selector": "laptop" })
857 );
858 let id = GrainId::random();
859 let r = WorkgroupCreateResult {
860 workgroup_id: id,
861 name: "home".into(),
862 device_id: id,
863 };
864 assert_eq!(round_trip(&r).name, "home");
865 assert_eq!(WORKGROUP_CREATE, "bridge.workgroup_create");
866 assert_eq!(DEVICE_RETIRE, "bridge.device_retire");
867 }
868
869 #[test]
870 fn a_2_0_peers_answer_reads_with_no_roles_and_default_priority() {
871 let id = GrainId::random();
872 let old = serde_json::json!({
873 "peers": [{ "device_id": id, "name": "desk", "node_id": "", "connected": true }]
874 });
875
876 let read: PeersResult = serde_json::from_value(old).unwrap();
877
878 assert!(read.roles.is_empty());
879 assert_eq!(read.peers[0].priority, DEFAULT_PRIORITY);
880 assert_eq!(read.peers[0].availability, None);
881 assert!(read.roles_for(id).is_none());
882 }
883
884 #[test]
885 fn roles_for_finds_the_workspace() {
886 let ws = GrainId::random();
887 let d = GrainId::random();
888 let result = PeersResult {
889 peers: Vec::new(),
890 roles: vec![WorkspaceRoles {
891 workspace_id: ws,
892 primary: Some(d),
893 secondary: None,
894 }],
895 };
896
897 assert_eq!(result.roles_for(ws).unwrap().primary, Some(d));
898 }
899
900 #[test]
901 fn device_priority_set_params_are_selector_and_priority() {
902 let p = DevicePrioritySetParams {
903 selector: "desk".into(),
904 priority: 0,
905 };
906 assert_eq!(
907 serde_json::to_value(&p).unwrap(),
908 serde_json::json!({ "selector": "desk", "priority": 0 })
909 );
910 }
911}