Skip to main content

opsis_core/
subscription.rs

1use serde::{Deserialize, Serialize};
2use ulid::Ulid;
3
4use crate::event::OpsisEvent;
5use crate::spatial::Bbox;
6use crate::state::StateDomain;
7
8/// Unique client identifier (ULID by default).
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct ClientId(pub String);
11
12impl Default for ClientId {
13    fn default() -> Self {
14        Self(Ulid::new().to_string())
15    }
16}
17
18/// A subscription filter — decides which [`OpsisEvent`]s a client receives.
19///
20/// All non-empty filter fields are combined with AND logic.  An empty filter
21/// (e.g. `Subscription::all()`) matches everything.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Subscription {
24    /// Only match events in these domains.  Empty = any domain.
25    pub domains: Vec<StateDomain>,
26    /// Only match events whose location falls within this box.
27    pub bbox: Option<Bbox>,
28    /// Minimum severity threshold (events below this are skipped).
29    pub severity_threshold: f32,
30    /// Only match events whose tags contain any of these keywords.
31    /// Empty = no keyword filter.
32    pub keywords: Vec<String>,
33}
34
35impl Subscription {
36    /// A subscription that matches everything.
37    pub fn all() -> Self {
38        Self {
39            domains: Vec::new(),
40            bbox: None,
41            severity_threshold: 0.0,
42            keywords: Vec::new(),
43        }
44    }
45
46    /// Returns `true` if the given event matches all active filters.
47    pub fn matches(&self, event: &OpsisEvent) -> bool {
48        // Domain filter — skip if event has no domain and we're filtering by domain.
49        if !self.domains.is_empty() {
50            match &event.domain {
51                Some(domain) if self.domains.contains(domain) => {}
52                _ => return false,
53            }
54        }
55
56        // Severity filter — events without severity are treated as 0.0.
57        let severity = event.severity.unwrap_or(0.0);
58        if severity < self.severity_threshold {
59            return false;
60        }
61
62        // Bbox filter
63        if let Some(ref bbox) = self.bbox {
64            match event.location {
65                Some(ref loc) => {
66                    if !bbox.contains(loc) {
67                        return false;
68                    }
69                }
70                None => return false,
71            }
72        }
73
74        // Keyword filter — match against tags.
75        if !self.keywords.is_empty() {
76            let has_keyword = self.keywords.iter().any(|kw| {
77                let kw_lower = kw.to_lowercase();
78                event
79                    .tags
80                    .iter()
81                    .any(|t| t.to_lowercase().contains(&kw_lower))
82            });
83            if !has_keyword {
84                return false;
85            }
86        }
87
88        true
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::clock::WorldTick;
96    use crate::event::{EventId, EventSource, OpsisEventKind};
97    use crate::feed::{FeedSource, SchemaKey};
98    use crate::spatial::GeoPoint;
99    use chrono::Utc;
100
101    fn sample_event() -> OpsisEvent {
102        OpsisEvent {
103            id: EventId::default(),
104            tick: WorldTick(1),
105            timestamp: Utc::now(),
106            source: EventSource::Feed(FeedSource::new("test")),
107            kind: OpsisEventKind::WorldObservation {
108                summary: "Stock market surge".into(),
109            },
110            location: Some(GeoPoint::new(5.0, 5.0)),
111            domain: Some(StateDomain::Finance),
112            severity: Some(0.7),
113            schema_key: SchemaKey::new("test.v1"),
114            tags: vec!["finance".into(), "market".into()],
115        }
116    }
117
118    #[test]
119    fn all_matches_everything() {
120        let sub = Subscription::all();
121        assert!(sub.matches(&sample_event()));
122    }
123
124    #[test]
125    fn domain_filter() {
126        let sub = Subscription {
127            domains: vec![StateDomain::Weather],
128            ..Subscription::all()
129        };
130        assert!(!sub.matches(&sample_event()));
131
132        let sub = Subscription {
133            domains: vec![StateDomain::Finance],
134            ..Subscription::all()
135        };
136        assert!(sub.matches(&sample_event()));
137    }
138
139    #[test]
140    fn domain_filter_no_domain_event() {
141        let mut evt = sample_event();
142        evt.domain = None;
143        let sub = Subscription {
144            domains: vec![StateDomain::Finance],
145            ..Subscription::all()
146        };
147        assert!(!sub.matches(&evt));
148    }
149
150    #[test]
151    fn severity_filter() {
152        let sub = Subscription {
153            severity_threshold: 0.9,
154            ..Subscription::all()
155        };
156        assert!(!sub.matches(&sample_event()));
157
158        let sub = Subscription {
159            severity_threshold: 0.5,
160            ..Subscription::all()
161        };
162        assert!(sub.matches(&sample_event()));
163    }
164
165    #[test]
166    fn severity_filter_none_severity() {
167        let mut evt = sample_event();
168        evt.severity = None;
169        let sub = Subscription {
170            severity_threshold: 0.1,
171            ..Subscription::all()
172        };
173        assert!(!sub.matches(&evt));
174    }
175
176    #[test]
177    fn bbox_filter() {
178        let bbox = Bbox::new(GeoPoint::new(0.0, 0.0), GeoPoint::new(10.0, 10.0));
179        let sub = Subscription {
180            bbox: Some(bbox),
181            ..Subscription::all()
182        };
183        assert!(sub.matches(&sample_event()));
184
185        let bbox_far = Bbox::new(GeoPoint::new(20.0, 20.0), GeoPoint::new(30.0, 30.0));
186        let sub = Subscription {
187            bbox: Some(bbox_far),
188            ..Subscription::all()
189        };
190        assert!(!sub.matches(&sample_event()));
191    }
192
193    #[test]
194    fn keyword_filter() {
195        let sub = Subscription {
196            keywords: vec!["finance".into()],
197            ..Subscription::all()
198        };
199        assert!(sub.matches(&sample_event()));
200
201        let sub = Subscription {
202            keywords: vec!["earthquake".into()],
203            ..Subscription::all()
204        };
205        assert!(!sub.matches(&sample_event()));
206
207        // Match via tag
208        let sub = Subscription {
209            keywords: vec!["market".into()],
210            ..Subscription::all()
211        };
212        assert!(sub.matches(&sample_event()));
213    }
214}