Skip to main content

adler_core/
watchlist.rs

1//! Watchlist configuration model.
2//!
3//! This module is intentionally runtime-free: it models who should be
4//! watched and which registry scope each watched identity uses. CLI,
5//! Web, MCP, or a future scheduler can parse JSON/TOML/YAML into these
6//! serde-compatible structs and then call [`WatchlistConfig::scan_targets`]
7//! to get concrete `(username, SiteFilter)` work items.
8
9use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12
13use crate::{SiteFilter, Username};
14
15/// Current schema version for watchlist configuration documents.
16pub const WATCHLIST_CONFIG_SCHEMA_VERSION: u16 = 1;
17
18/// Top-level watchlist configuration.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct WatchlistConfig {
21    /// Schema version for tolerant future readers.
22    #[serde(default = "default_schema_version")]
23    pub schema_version: u16,
24    /// Optional default scan scope inherited by every target.
25    #[serde(default)]
26    pub default_scope: WatchScope,
27    /// Optional repeated-scan policy. Runtime surfaces decide how to execute it.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub schedule: Option<ScanSchedule>,
30    /// Watched identities.
31    #[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    /// Validate targets, aliases, and duplicate scan usernames.
48    ///
49    /// The same concrete username appearing twice is rejected even if it
50    /// arrives through aliases, because a later timeline cannot safely decide
51    /// which watched identity owns that scan artifact.
52    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    /// Expand watched identities into concrete scan targets.
80    ///
81    /// Each target yields one scan for its primary username and one scan per
82    /// alias. The returned scope is the merged default + per-target scope.
83    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/// Repeated scan policy for a watchlist.
110///
111/// This type intentionally contains no timers, tasks, or async runtime hooks.
112/// A caller can persist the last started scan timestamp, ask whether a plan is
113/// due, and then launch scans using its own scheduler.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ScanSchedule {
116    /// Seconds between repeated scans. Must be greater than zero.
117    pub every_secs: u64,
118    /// Optional Unix epoch millisecond timestamp before which the plan is not
119    /// due. Omit for an immediately due first scan.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub start_at_ms: Option<u64>,
122}
123
124impl ScanSchedule {
125    /// Validate interval bounds.
126    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    /// Millisecond timestamp when the next scan is due.
141    ///
142    /// `last_started_at_ms` is the timestamp of the previous scan start for
143    /// this schedule. `None` means the first scan has not run yet.
144    #[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    /// Whether the schedule is due at `now_ms`.
157    #[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/// One watched identity.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct WatchTarget {
166    /// Primary username / handle.
167    pub username: String,
168    /// Additional handles that should be tracked as the same identity.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub aliases: Vec<String>,
171    /// Optional scope overriding or extending the watchlist default.
172    #[serde(default)]
173    pub scope: WatchScope,
174}
175
176/// Site/tag scope for watchlist scans.
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178pub struct WatchScope {
179    /// Keep only sites whose name contains at least one term.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub only: Vec<String>,
182    /// Drop sites whose name contains any term.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub exclude: Vec<String>,
185    /// Keep only sites carrying at least one requested tag.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub tag: Vec<String>,
188    /// Drop sites carrying any of these tags.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub exclude_tag: Vec<String>,
191    /// Include `nsfw`-tagged sites. `None` means inherit the default scope.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub include_nsfw: Option<bool>,
194    /// Optional popularity-rank ceiling.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub top: Option<u32>,
197}
198
199impl WatchScope {
200    /// Merge a default scope with an overriding per-target scope.
201    ///
202    /// List fields are appended so a target can narrow a default scope with
203    /// extra include/exclude terms. Scalar fields override only when set.
204    #[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    /// Convert into the core registry filter.
223    #[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/// Concrete scan work item derived from a watchlist.
237#[derive(Debug, Clone)]
238pub struct WatchScanTarget {
239    /// Primary watched identity this scan contributes to.
240    pub identity: String,
241    /// Username/alias to scan.
242    pub username: String,
243    /// Registry scope for this scan.
244    pub scope: SiteFilter,
245}
246
247/// Watchlist validation error.
248#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
249pub enum WatchlistError {
250    /// Target had an empty username.
251    #[error("watch target at index {target_index} has an empty username")]
252    EmptyUsername {
253        /// Index in [`WatchlistConfig::targets`].
254        target_index: usize,
255    },
256    /// Alias was empty.
257    #[error("watch target {username:?} has an empty alias")]
258    EmptyAlias {
259        /// Primary username carrying the empty alias.
260        username: String,
261    },
262    /// Username failed core validation.
263    #[error("invalid username {username:?}: {reason}")]
264    InvalidUsername {
265        /// Username or alias that failed validation.
266        username: String,
267        /// Human-readable validation reason.
268        reason: String,
269    },
270    /// Same concrete username appeared more than once.
271    #[error("duplicate watch username or alias {username:?}")]
272    DuplicateUsername {
273        /// Duplicated username/alias.
274        username: String,
275    },
276    /// Schedule policy is invalid.
277    #[error("invalid watch schedule: {reason}")]
278    InvalidSchedule {
279        /// Human-readable validation reason.
280        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}