Skip to main content

adler_server/
state.rs

1//! Shared application state: registry, sites cache, HTTP client, scans.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use adler_core::{Client, Registry, Site};
8use tokio::sync::RwLock;
9
10use crate::scan::{ScanHandle, ScanId};
11
12/// State shared across all axum handlers.
13///
14/// Cheap to clone — every field is an [`Arc`] or a small primitive.
15/// axum requires `State<T>` to be `Clone`, hence this design.
16#[derive(Clone)]
17pub struct AppState {
18    /// Pre-filtered site list (registry + workspace flags applied at
19    /// startup). Held as an `Arc<[Site]>` to avoid re-cloning the
20    /// 2.5k-entry vector on every scan dispatch.
21    pub sites: Arc<[Site]>,
22    /// Shared HTTP client (connection pool, throttle, etc.).
23    pub client: Arc<Client>,
24    /// In-flight + recently-finished scans, keyed by ID.
25    pub scans: Arc<RwLock<HashMap<ScanId, ScanHandle>>>,
26    /// Maximum number of scans retained in memory. Beyond this, the
27    /// oldest finished scan is evicted on the next insertion (a tiny
28    /// LRU — we never need more than ~dozens of recent scans in a
29    /// human-driven web session).
30    pub scan_capacity: usize,
31    /// Directory where finished scans are persisted as JSON. `None`
32    /// disables persistence (used by tests and ephemeral runs).
33    pub scans_dir: Option<Arc<PathBuf>>,
34}
35
36impl AppState {
37    /// Build initial state from a registry + a pre-built HTTP client.
38    ///
39    /// The full registry is filtered with the supplied predicate; the
40    /// result is materialised into an `Arc<[Site]>` once so handler
41    /// dispatch is a pointer copy. Persistence is off by default —
42    /// chain [`Self::with_scans_dir`] to enable.
43    #[must_use]
44    pub fn new(sites: Vec<Site>, client: Client, scan_capacity: usize) -> Self {
45        Self {
46            sites: Arc::from(sites.into_boxed_slice()),
47            client: Arc::new(client),
48            scans: Arc::new(RwLock::new(HashMap::new())),
49            scan_capacity: scan_capacity.max(1),
50            scans_dir: None,
51        }
52    }
53
54    /// Convenience: build state from a [`Registry`] using the
55    /// "no filter, NSFW excluded" default. The web UI exposes
56    /// per-scan filters anyway, so the initial site list is the full
57    /// non-NSFW set.
58    #[must_use]
59    pub fn from_registry(registry: &Registry, client: Client, scan_capacity: usize) -> Self {
60        let sites = registry.filter(&[], &[], &[], &[], false);
61        Self::new(sites, client, scan_capacity)
62    }
63
64    /// Enable on-disk persistence of finished scans under `dir`. Files
65    /// are written as `<scan_id>.json` after each scan completes;
66    /// startup reads them back so history survives server restarts.
67    #[must_use]
68    pub fn with_scans_dir(mut self, dir: PathBuf) -> Self {
69        self.scans_dir = Some(Arc::new(dir));
70        self
71    }
72
73    /// Insert a fresh scan handle, evicting the oldest finished entry
74    /// (or the oldest entry overall, if none has finished) when we are
75    /// at capacity.
76    pub async fn insert_scan(&self, id: ScanId, handle: ScanHandle) {
77        let mut scans = self.scans.write().await;
78        if scans.len() >= self.scan_capacity {
79            let mut finished_candidate: Option<(ScanId, std::time::Duration)> = None;
80            let mut any_candidate: Option<(ScanId, std::time::Duration)> = None;
81            for (k, v) in scans.iter() {
82                let age = v.elapsed();
83                if v.is_finished_now()
84                    && finished_candidate
85                        .as_ref()
86                        .is_none_or(|(_, prev)| age > *prev)
87                {
88                    finished_candidate = Some((k.clone(), age));
89                }
90                if any_candidate.as_ref().is_none_or(|(_, prev)| age > *prev) {
91                    any_candidate = Some((k.clone(), age));
92                }
93            }
94            if let Some((victim, _)) = finished_candidate.or(any_candidate) {
95                scans.remove(&victim);
96            }
97        }
98        scans.insert(id, handle);
99    }
100
101    /// Look up a scan by ID, cloning the handle (cheap — `Arc` inside).
102    pub async fn get_scan(&self, id: &ScanId) -> Option<ScanHandle> {
103        self.scans.read().await.get(id).cloned()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::scan::{FinishedScan, Summary};
111
112    fn client() -> Client {
113        Client::builder().build().expect("default client")
114    }
115
116    #[tokio::test]
117    async fn evicts_oldest_finished_when_over_capacity() {
118        let state = AppState::new(Vec::new(), client(), 2);
119
120        let id_a = ScanId::from("aaaaaaaaaaaa".to_owned());
121        let handle_a = ScanHandle::new("a", 0, 4);
122        handle_a
123            .publish(FinishedScan {
124                summary: Summary::default(),
125                outcomes: Vec::new(),
126                elapsed_ms: 0,
127            })
128            .await;
129        state.insert_scan(id_a.clone(), handle_a).await;
130
131        let id_b = ScanId::from("bbbbbbbbbbbb".to_owned());
132        state
133            .insert_scan(id_b.clone(), ScanHandle::new("b", 0, 4))
134            .await;
135
136        // Capacity is 2; both fit.
137        assert!(state.get_scan(&id_a).await.is_some());
138        assert!(state.get_scan(&id_b).await.is_some());
139
140        // Inserting a third evicts the finished one (a) over the
141        // running one (b).
142        let id_c = ScanId::from("cccccccccccc".to_owned());
143        state
144            .insert_scan(id_c.clone(), ScanHandle::new("c", 0, 4))
145            .await;
146
147        assert!(
148            state.get_scan(&id_a).await.is_none(),
149            "finished scan should be evicted first"
150        );
151        assert!(state.get_scan(&id_b).await.is_some());
152        assert!(state.get_scan(&id_c).await.is_some());
153    }
154}