duckduckgo-core 0.1.6

DuckDuckGo search client library for duckduckgo-cli
Documentation
use std::env;
use std::time::Duration;

use time::OffsetDateTime;

/// Tunable constants for the spacing/in-flight guard. Defaults match the
/// 2026-05-08 first-party field report (`docs/en/ddgr.md`); they may be
/// overridden via environment variables for advanced users that have
/// measured a different egress profile.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
    pub base_spacing: Duration,
    pub slow_spacing: Duration,
    pub slowdown_duration: Duration,
    pub base_cooldown: Duration,
    pub max_cooldown: Duration,
    pub jitter: Duration,
}

impl Limits {
    /// Fast-spacing preset for deterministic tests and controlled harnesses.
    pub fn test_fast(spacing_ms: u64, slow_spacing_ms: u64, cooldown_secs: u64) -> Self {
        Self {
            base_spacing: Duration::from_millis(spacing_ms),
            slow_spacing: Duration::from_millis(slow_spacing_ms.max(spacing_ms)),
            slowdown_duration: Duration::from_secs(cooldown_secs.saturating_mul(10)),
            base_cooldown: Duration::from_secs(cooldown_secs.max(1)),
            max_cooldown: Duration::from_secs(cooldown_secs.max(1).saturating_mul(5)),
            jitter: Duration::from_millis(0),
        }
    }

    /// Load default limits and apply supported environment overrides.
    pub fn from_env() -> Self {
        let mut limits = Self::default();
        if let Some(ms) = env_u64("DUCKDUCKGO_BASE_SPACING_MS").filter(|v| *v <= 60_000) {
            limits.base_spacing = Duration::from_millis(ms);
        }
        if let Some(ms) = env_u64("DUCKDUCKGO_SLOW_SPACING_MS").filter(|v| *v <= 120_000) {
            let candidate = Duration::from_millis(ms);
            limits.slow_spacing = candidate.max(limits.base_spacing);
        }
        if let Some(secs) = env_u64("DUCKDUCKGO_BASE_COOLDOWN_S").filter(|v| (1..=600).contains(v))
        {
            limits.base_cooldown = Duration::from_secs(secs);
            limits.max_cooldown = limits.max_cooldown.max(limits.base_cooldown);
        }
        limits
    }
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            base_spacing: Duration::from_millis(2_000),
            slow_spacing: Duration::from_millis(4_000),
            slowdown_duration: Duration::from_secs(600),
            base_cooldown: Duration::from_secs(60),
            max_cooldown: Duration::from_secs(300),
            jitter: Duration::from_millis(300),
        }
    }
}

impl Limits {
    /// Compute the cooldown after the *n*-th consecutive block.
    /// `consecutive_blocks` is 1-indexed (1 = first block in a streak).
    pub fn cooldown_for(&self, consecutive_blocks: u32) -> Duration {
        let exp = consecutive_blocks.saturating_sub(1).min(8);
        let scaled = self.base_cooldown.saturating_mul(2_u32.saturating_pow(exp));
        scaled.min(self.max_cooldown)
    }

    /// Spacing in effect at `now`: `slow_spacing` while the slowdown window
    /// covers the moment, otherwise `base_spacing`.
    pub fn spacing(&self, slowdown_until: Option<OffsetDateTime>, now: OffsetDateTime) -> Duration {
        if slowdown_until.is_some_and(|t| t > now) {
            self.slow_spacing
        } else {
            self.base_spacing
        }
    }
}

/// Environment overrides:
/// - `DUCKDUCKGO_BASE_SPACING_MS` — integer ms, clamped to ≤ 60 000.
/// - `DUCKDUCKGO_SLOW_SPACING_MS` — integer ms, clamped to ≤ 120 000 and to ≥ base.
/// - `DUCKDUCKGO_BASE_COOLDOWN_S` — integer seconds, clamped to 1..=600.
///
fn env_u64(key: &str) -> Option<u64> {
    env::var(key).ok().and_then(|v| v.parse::<u64>().ok())
}

#[cfg(test)]
mod tests {
    use super::Limits;
    use std::time::Duration;

    #[test]
    fn cooldown_doubles_then_caps() {
        let limits = Limits::default();
        assert_eq!(limits.cooldown_for(0), Duration::from_secs(60));
        assert_eq!(limits.cooldown_for(1), Duration::from_secs(60));
        assert_eq!(limits.cooldown_for(2), Duration::from_secs(120));
        assert_eq!(limits.cooldown_for(3), Duration::from_secs(240));
        assert_eq!(limits.cooldown_for(4), Duration::from_secs(300));
        assert_eq!(limits.cooldown_for(8), Duration::from_secs(300));
        assert_eq!(limits.cooldown_for(99), Duration::from_secs(300));
    }

    #[test]
    fn spacing_picks_slow_inside_window() {
        let limits = Limits::default();
        let now = time::OffsetDateTime::now_utc();
        let in_future = now + Duration::from_secs(60);
        let in_past = now - Duration::from_secs(60);
        assert_eq!(
            limits.spacing(Some(in_future), now),
            Duration::from_millis(4_000)
        );
        assert_eq!(
            limits.spacing(Some(in_past), now),
            Duration::from_millis(2_000)
        );
        assert_eq!(limits.spacing(None, now), Duration::from_millis(2_000));
    }
}