Skip to main content

lcsa_core/
topology.rs

1use std::env;
2use std::fmt;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6
7use crate::signals::StructuralSignal;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Platform {
12    Linux,
13    MacOs,
14    Windows,
15    Ios,
16    Android,
17    Browser,
18    Unknown,
19}
20
21impl Platform {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Platform::Linux => "linux",
25            Platform::MacOs => "macos",
26            Platform::Windows => "windows",
27            Platform::Ios => "ios",
28            Platform::Android => "android",
29            Platform::Browser => "browser",
30            Platform::Unknown => "unknown",
31        }
32    }
33}
34
35impl fmt::Display for Platform {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum DeviceClass {
44    Desktop,
45    Laptop,
46    Tablet,
47    Phone,
48    Server,
49    Browser,
50    Unknown,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct DeviceContext {
55    pub device_id: String,
56    pub device_name: String,
57    pub platform: Platform,
58    pub device_class: DeviceClass,
59    pub os_version: Option<String>,
60}
61
62impl Default for DeviceContext {
63    fn default() -> Self {
64        let platform = current_platform();
65        let device_name = env::var("HOSTNAME")
66            .or_else(|_| env::var("COMPUTERNAME"))
67            .unwrap_or_else(|_| "local-device".to_string());
68        let platform_label = platform.as_str();
69
70        Self {
71            device_id: format!("{platform_label}:{device_name}"),
72            device_name,
73            platform,
74            device_class: DeviceClass::Desktop,
75            os_version: None,
76        }
77    }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ApplicationContext {
82    pub app_id: String,
83    pub app_name: String,
84    pub app_version: Option<String>,
85}
86
87impl Default for ApplicationContext {
88    fn default() -> Self {
89        Self {
90            app_id: "lcsa-client".to_string(),
91            app_name: "lcsa-client".to_string(),
92            app_version: None,
93        }
94    }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum SignalSource {
100    Clipboard,
101    Filesystem,
102    Selection,
103    Focus,
104    Terminal,
105    Browser,
106}
107
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct SignalEnvelope {
110    pub signal_id: String,
111    pub emitted_at: SystemTime,
112    pub source: SignalSource,
113    pub device: DeviceContext,
114    pub application: ApplicationContext,
115    pub payload: StructuralSignal,
116}
117
118impl SignalEnvelope {
119    pub fn new(
120        source: SignalSource,
121        device: DeviceContext,
122        application: ApplicationContext,
123        payload: StructuralSignal,
124    ) -> Self {
125        let micros = SystemTime::now()
126            .duration_since(UNIX_EPOCH)
127            .map(|duration| duration.as_micros())
128            .unwrap_or_default();
129
130        Self {
131            signal_id: format!("{source:?}-{micros}").to_ascii_lowercase(),
132            emitted_at: SystemTime::now(),
133            source,
134            device,
135            application,
136            payload,
137        }
138    }
139
140    pub fn from_signal(
141        device: DeviceContext,
142        application: ApplicationContext,
143        payload: StructuralSignal,
144    ) -> Self {
145        let source = payload.source();
146        Self::new(source, device, application, payload)
147    }
148}
149
150fn current_platform() -> Platform {
151    #[cfg(target_os = "linux")]
152    {
153        Platform::Linux
154    }
155    #[cfg(target_os = "macos")]
156    {
157        Platform::MacOs
158    }
159    #[cfg(target_os = "windows")]
160    {
161        Platform::Windows
162    }
163    #[cfg(target_os = "ios")]
164    {
165        Platform::Ios
166    }
167    #[cfg(target_os = "android")]
168    {
169        Platform::Android
170    }
171    #[cfg(not(any(
172        target_os = "linux",
173        target_os = "macos",
174        target_os = "windows",
175        target_os = "ios",
176        target_os = "android"
177    )))]
178    {
179        Platform::Unknown
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::signals::{ClipboardSignal, StructuralSignal};
187
188    #[test]
189    fn default_device_context_has_platform() {
190        let device = DeviceContext::default();
191        assert_ne!(device.platform, Platform::Unknown);
192    }
193
194    #[test]
195    fn envelope_wraps_payload_with_identity() {
196        let envelope = SignalEnvelope::from_signal(
197            DeviceContext::default(),
198            ApplicationContext::default(),
199            StructuralSignal::Clipboard(ClipboardSignal::text(
200                "cargo test",
201                "terminal".to_string(),
202            )),
203        );
204
205        assert_eq!(envelope.source, SignalSource::Clipboard);
206        assert!(matches!(envelope.payload, StructuralSignal::Clipboard(_)));
207        assert!(envelope.signal_id.starts_with("clipboard-"));
208    }
209
210    #[test]
211    fn envelope_infers_selection_source() {
212        let envelope = SignalEnvelope::from_signal(
213            DeviceContext::default(),
214            ApplicationContext::default(),
215            StructuralSignal::Selection(crate::signals::SelectionSignal::text(
216                "hello",
217                "editor".to_string(),
218                true,
219            )),
220        );
221
222        assert_eq!(envelope.source, SignalSource::Selection);
223        assert!(envelope.signal_id.starts_with("selection-"));
224    }
225}