1use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12
13use crate::{SiteFilter, Username};
14
15pub const WATCHLIST_CONFIG_SCHEMA_VERSION: u16 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct WatchlistConfig {
21 #[serde(default = "default_schema_version")]
23 pub schema_version: u16,
24 #[serde(default)]
26 pub default_scope: WatchScope,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub schedule: Option<ScanSchedule>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub targets: Vec<WatchTarget>,
33}
34
35impl Default for WatchlistConfig {
36 fn default() -> Self {
37 Self {
38 schema_version: WATCHLIST_CONFIG_SCHEMA_VERSION,
39 default_scope: WatchScope::default(),
40 schedule: None,
41 targets: Vec::new(),
42 }
43 }
44}
45
46impl WatchlistConfig {
47 pub fn validate(&self) -> Result<(), WatchlistError> {
53 if let Some(schedule) = &self.schedule {
54 schedule.validate()?;
55 }
56
57 let mut seen = HashSet::new();
58 for (index, target) in self.targets.iter().enumerate() {
59 if target.username.trim().is_empty() {
60 return Err(WatchlistError::EmptyUsername {
61 target_index: index,
62 });
63 }
64 validate_username(&target.username)?;
65 insert_unique(&mut seen, &target.username)?;
66 for alias in &target.aliases {
67 if alias.trim().is_empty() {
68 return Err(WatchlistError::EmptyAlias {
69 username: target.username.clone(),
70 });
71 }
72 validate_username(alias)?;
73 insert_unique(&mut seen, alias)?;
74 }
75 }
76 Ok(())
77 }
78
79 pub fn scan_targets(&self) -> Result<Vec<WatchScanTarget>, WatchlistError> {
84 self.validate()?;
85 let mut out = Vec::new();
86 for target in &self.targets {
87 let scope = self.default_scope.merged(&target.scope).to_site_filter();
88 out.push(WatchScanTarget {
89 identity: target.username.clone(),
90 username: target.username.clone(),
91 scope: scope.clone(),
92 });
93 for alias in &target.aliases {
94 out.push(WatchScanTarget {
95 identity: target.username.clone(),
96 username: alias.clone(),
97 scope: scope.clone(),
98 });
99 }
100 }
101 Ok(out)
102 }
103}
104
105const fn default_schema_version() -> u16 {
106 WATCHLIST_CONFIG_SCHEMA_VERSION
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ScanSchedule {
116 pub every_secs: u64,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub start_at_ms: Option<u64>,
122}
123
124impl ScanSchedule {
125 pub fn validate(&self) -> Result<(), WatchlistError> {
127 if self.every_secs == 0 {
128 return Err(WatchlistError::InvalidSchedule {
129 reason: "every_secs must be greater than zero".to_owned(),
130 });
131 }
132 if self.every_secs > u64::MAX / 1_000 {
133 return Err(WatchlistError::InvalidSchedule {
134 reason: "every_secs is too large to convert to milliseconds".to_owned(),
135 });
136 }
137 Ok(())
138 }
139
140 #[must_use]
145 pub fn next_due_ms(&self, last_started_at_ms: Option<u64>) -> u64 {
146 let interval_ms = self.every_secs.saturating_mul(1_000);
147 let due_after_last = last_started_at_ms.map(|last| last.saturating_add(interval_ms));
148 match (due_after_last, self.start_at_ms) {
149 (Some(due), Some(start_at)) => due.max(start_at),
150 (Some(due), None) => due,
151 (None, Some(start_at)) => start_at,
152 (None, None) => 0,
153 }
154 }
155
156 #[must_use]
158 pub fn is_due(&self, last_started_at_ms: Option<u64>, now_ms: u64) -> bool {
159 self.next_due_ms(last_started_at_ms) <= now_ms
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct WatchTarget {
166 pub username: String,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub aliases: Vec<String>,
171 #[serde(default)]
173 pub scope: WatchScope,
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178pub struct WatchScope {
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub only: Vec<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub exclude: Vec<String>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub tag: Vec<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub exclude_tag: Vec<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub include_nsfw: Option<bool>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub top: Option<u32>,
197}
198
199impl WatchScope {
200 #[must_use]
205 pub fn merged(&self, override_scope: &Self) -> Self {
206 let mut merged = self.clone();
207 merged.only.extend(override_scope.only.clone());
208 merged.exclude.extend(override_scope.exclude.clone());
209 merged.tag.extend(override_scope.tag.clone());
210 merged
211 .exclude_tag
212 .extend(override_scope.exclude_tag.clone());
213 if override_scope.include_nsfw.is_some() {
214 merged.include_nsfw = override_scope.include_nsfw;
215 }
216 if override_scope.top.is_some() {
217 merged.top = override_scope.top;
218 }
219 merged
220 }
221
222 #[must_use]
224 pub fn to_site_filter(&self) -> SiteFilter {
225 SiteFilter {
226 include: self.only.clone(),
227 exclude: self.exclude.clone(),
228 tags: self.tag.clone(),
229 exclude_tags: self.exclude_tag.clone(),
230 include_nsfw: self.include_nsfw.unwrap_or(false),
231 top: self.top,
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct WatchScanTarget {
239 pub identity: String,
241 pub username: String,
243 pub scope: SiteFilter,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
249pub enum WatchlistError {
250 #[error("watch target at index {target_index} has an empty username")]
252 EmptyUsername {
253 target_index: usize,
255 },
256 #[error("watch target {username:?} has an empty alias")]
258 EmptyAlias {
259 username: String,
261 },
262 #[error("invalid username {username:?}: {reason}")]
264 InvalidUsername {
265 username: String,
267 reason: String,
269 },
270 #[error("duplicate watch username or alias {username:?}")]
272 DuplicateUsername {
273 username: String,
275 },
276 #[error("invalid watch schedule: {reason}")]
278 InvalidSchedule {
279 reason: String,
281 },
282}
283
284fn validate_username(username: &str) -> Result<(), WatchlistError> {
285 Username::new(username.to_owned()).map_err(|err| WatchlistError::InvalidUsername {
286 username: username.to_owned(),
287 reason: err.to_string(),
288 })?;
289 Ok(())
290}
291
292fn insert_unique(seen: &mut HashSet<String>, username: &str) -> Result<(), WatchlistError> {
293 let key = username.to_ascii_lowercase();
294 if !seen.insert(key) {
295 return Err(WatchlistError::DuplicateUsername {
296 username: username.to_owned(),
297 });
298 }
299 Ok(())
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn defaults_schema_version_and_empty_targets() {
308 let cfg: WatchlistConfig = serde_json::from_str("{}").unwrap();
309
310 assert_eq!(cfg.schema_version, WATCHLIST_CONFIG_SCHEMA_VERSION);
311 assert!(cfg.targets.is_empty());
312 assert!(cfg.validate().is_ok());
313 }
314
315 #[test]
316 fn serializes_compact_scope() {
317 let cfg = WatchlistConfig {
318 default_scope: WatchScope {
319 tag: vec!["social".into()],
320 exclude_tag: vec!["bot-protected".into()],
321 top: Some(100),
322 ..WatchScope::default()
323 },
324 schedule: Some(ScanSchedule {
325 every_secs: 86_400,
326 start_at_ms: Some(1_800_000_000_000),
327 }),
328 targets: vec![WatchTarget {
329 username: "alice".into(),
330 aliases: vec!["alice_dev".into()],
331 scope: WatchScope::default(),
332 }],
333 ..WatchlistConfig::default()
334 };
335
336 let json = serde_json::to_value(&cfg).unwrap();
337 assert_eq!(json["schema_version"], WATCHLIST_CONFIG_SCHEMA_VERSION);
338 assert_eq!(json["default_scope"]["tag"][0], "social");
339 assert_eq!(json["schedule"]["every_secs"], 86_400);
340 assert_eq!(json["targets"][0]["username"], "alice");
341 assert_eq!(json["targets"][0]["aliases"][0], "alice_dev");
342 assert!(json["targets"][0].get("scope").is_some());
343 }
344
345 #[test]
346 fn schedule_is_due_immediately_without_start_or_previous_run() {
347 let schedule = ScanSchedule {
348 every_secs: 60,
349 start_at_ms: None,
350 };
351
352 assert_eq!(schedule.next_due_ms(None), 0);
353 assert!(schedule.is_due(None, 1));
354 }
355
356 #[test]
357 fn schedule_uses_start_and_last_run_for_next_due() {
358 let schedule = ScanSchedule {
359 every_secs: 60,
360 start_at_ms: Some(10_000),
361 };
362
363 assert_eq!(schedule.next_due_ms(None), 10_000);
364 assert_eq!(schedule.next_due_ms(Some(12_000)), 72_000);
365 assert!(!schedule.is_due(Some(12_000), 71_999));
366 assert!(schedule.is_due(Some(12_000), 72_000));
367 }
368
369 #[test]
370 fn validate_rejects_zero_schedule_interval() {
371 let cfg = WatchlistConfig {
372 schedule: Some(ScanSchedule {
373 every_secs: 0,
374 start_at_ms: None,
375 }),
376 ..WatchlistConfig::default()
377 };
378
379 let err = cfg.validate().unwrap_err();
380 assert!(matches!(err, WatchlistError::InvalidSchedule { .. }));
381 }
382
383 #[test]
384 fn expands_aliases_with_merged_scope() {
385 let cfg = WatchlistConfig {
386 default_scope: WatchScope {
387 tag: vec!["social".into()],
388 exclude_tag: vec!["bot-protected".into()],
389 top: Some(500),
390 ..WatchScope::default()
391 },
392 targets: vec![WatchTarget {
393 username: "alice".into(),
394 aliases: vec!["alice_dev".into(), "alice-osint".into()],
395 scope: WatchScope {
396 only: vec!["Git".into()],
397 tag: vec!["dev".into()],
398 top: Some(50),
399 ..WatchScope::default()
400 },
401 }],
402 ..WatchlistConfig::default()
403 };
404
405 let targets = cfg.scan_targets().unwrap();
406
407 assert_eq!(targets.len(), 3);
408 assert_eq!(targets[0].identity, "alice");
409 assert_eq!(targets[1].username, "alice_dev");
410 assert_eq!(targets[2].username, "alice-osint");
411 assert_eq!(targets[0].scope.include, ["Git"]);
412 assert_eq!(targets[0].scope.tags, ["social", "dev"]);
413 assert_eq!(targets[0].scope.exclude_tags, ["bot-protected"]);
414 assert_eq!(targets[0].scope.top, Some(50));
415 }
416
417 #[test]
418 fn rejects_duplicate_aliases_case_insensitively() {
419 let cfg = WatchlistConfig {
420 targets: vec![WatchTarget {
421 username: "alice".into(),
422 aliases: vec!["Alice".into()],
423 scope: WatchScope::default(),
424 }],
425 ..WatchlistConfig::default()
426 };
427
428 let err = cfg.validate().unwrap_err();
429 assert!(matches!(err, WatchlistError::DuplicateUsername { .. }));
430 }
431
432 #[test]
433 fn rejects_invalid_alias_username() {
434 let cfg = WatchlistConfig {
435 targets: vec![WatchTarget {
436 username: "alice".into(),
437 aliases: vec!["bad space".into()],
438 scope: WatchScope::default(),
439 }],
440 ..WatchlistConfig::default()
441 };
442
443 let err = cfg.validate().unwrap_err();
444 assert!(matches!(err, WatchlistError::InvalidUsername { .. }));
445 }
446}