cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use std::collections::hash_map::Entry;

use chrono::{DateTime, Duration, Utc};
use cueward_core::{ScanTargetState, State};
use serde::Serialize;
use sha2::{Digest, Sha256};

use crate::MacosError;

pub const NORMAL_MIN_SCAN_INTERVAL: Duration = Duration::minutes(30);
pub const BACKOFF_SCAN_INTERVAL: Duration = Duration::hours(6);
pub const STALE_SKIP_AFTER: Duration = Duration::days(3);
pub const STALE_RECHECK_INTERVAL: Duration = Duration::hours(24);
pub const CONTENT_MAX_AGE_DAYS: i64 = 30;
pub const MIN_CONTENT_WORDS: usize = 5;
pub const MIN_CONTENT_CHARS: usize = 20;

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScanStatus {
    Fresh,
    Unchanged,
    Skipped,
    Warning,
    Deleted,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ScanEnvelope<T> {
    pub provider: String,
    pub target_url: String,
    pub status: ScanStatus,
    pub no_change_count: u32,
    pub consecutive_not_found_count: u32,
    pub deleted: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_checked_at: Option<DateTime<Utc>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_changed_at: Option<DateTime<Utc>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub warning: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<T>,
}

pub fn build_scan_key(provider: &str, target_url: &str) -> String {
    format!("{provider}:{target_url}")
}

pub fn load_state() -> State {
    State::load()
}

pub fn stored_entry<'a>(
    state: &'a State,
    key: &str,
) -> Result<&'a ScanTargetState, MacosError> {
    state
        .scan_target(key)
        .ok_or_else(|| MacosError::Other("scan state entry not found".to_string()))
}

pub fn save_state_warning(state: &State) -> Option<String> {
    state
        .save()
        .err()
        .map(|error| format!("failed to save scan state: {error}"))
}

pub fn entry_for<'a>(
    state: &'a mut State,
    key: &str,
    provider: &str,
    target_url: &str,
) -> &'a mut ScanTargetState {
    match state.scan_targets.entry(key.to_string()) {
        Entry::Occupied(entry) => entry.into_mut(),
        Entry::Vacant(entry) => entry.insert(ScanTargetState {
            provider: provider.to_string(),
            target_url: target_url.to_string(),
            ..ScanTargetState::default()
        }),
    }
}

pub fn skip_reason(entry: &ScanTargetState, now: DateTime<Utc>) -> Option<String> {
    if entry.deleted {
        return Some("deleted_target".to_string());
    }
    let last_checked_at = entry.last_checked_at?;
    if let Some(last_changed_at) = entry.last_changed_at {
        if now - last_changed_at >= STALE_SKIP_AFTER
            && now - last_checked_at < STALE_RECHECK_INTERVAL
        {
            return Some("stale_target_backoff".to_string());
        }
    }
    let min_interval = if entry.no_change_count >= 2 {
        BACKOFF_SCAN_INTERVAL
    } else {
        NORMAL_MIN_SCAN_INTERVAL
    };
    if now - last_checked_at < min_interval {
        return Some("backoff_interval_not_elapsed".to_string());
    }
    None
}

pub fn record_success(
    entry: &mut ScanTargetState,
    fingerprint: String,
    now: DateTime<Utc>,
) -> ScanStatus {
    entry.last_checked_at = Some(now);
    let status = if entry.last_fingerprint.as_deref() == Some(fingerprint.as_str()) {
        entry.no_change_count += 1;
        ScanStatus::Unchanged
    } else {
        entry.last_changed_at = Some(now);
        entry.last_fingerprint = Some(fingerprint);
        entry.no_change_count = 0;
        ScanStatus::Fresh
    };
    entry.consecutive_not_found_count = 0;
    entry.deleted = false;
    status
}

pub fn record_not_found(entry: &mut ScanTargetState, now: DateTime<Utc>) -> ScanStatus {
    entry.last_checked_at = Some(now);
    entry.consecutive_not_found_count += 1;
    if entry.consecutive_not_found_count >= 2 {
        entry.deleted = true;
        ScanStatus::Deleted
    } else {
        ScanStatus::Warning
    }
}

pub fn make_envelope<T>(
    entry: &ScanTargetState,
    status: ScanStatus,
    reason: Option<String>,
    warning: Option<String>,
    data: Option<T>,
) -> ScanEnvelope<T> {
    ScanEnvelope {
        provider: entry.provider.clone(),
        target_url: entry.target_url.clone(),
        status,
        no_change_count: entry.no_change_count,
        consecutive_not_found_count: entry.consecutive_not_found_count,
        deleted: entry.deleted,
        last_checked_at: entry.last_checked_at,
        last_changed_at: entry.last_changed_at,
        reason,
        warning,
        data,
    }
}

pub fn fingerprint_json<T: Serialize>(value: &T) -> Result<String, MacosError> {
    let json = serde_json::to_vec(value)
        .map_err(|error| MacosError::Other(format!("failed to serialize fingerprint payload: {error}")))?;
    let mut hasher = Sha256::new();
    hasher.update(json);
    Ok(format!("{:x}", hasher.finalize()))
}

pub fn is_bot_like_author(author: &str) -> bool {
    let normalized = author.trim().to_ascii_lowercase();
    matches!(
        normalized.as_str(),
        "automoderator" | "[deleted]" | "[removed]"
    )
}

pub fn is_too_short(text: &str) -> bool {
    let trimmed = text.trim();
    trimmed.chars().count() < MIN_CONTENT_CHARS
        && trimmed.split_whitespace().count() < MIN_CONTENT_WORDS
}

pub fn is_too_old(created_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
    now - created_at > Duration::days(CONTENT_MAX_AGE_DAYS)
}

#[cfg(test)]
mod tests {
    use chrono::{Duration, TimeZone, Utc};
    use cueward_core::State;
    use serde::Serialize;

    use super::{
        BACKOFF_SCAN_INTERVAL, NORMAL_MIN_SCAN_INTERVAL, STALE_RECHECK_INTERVAL, ScanStatus,
        STALE_SKIP_AFTER,
        build_scan_key, entry_for, fingerprint_json, is_bot_like_author, is_too_old, is_too_short,
        make_envelope, record_not_found, record_success, skip_reason,
    };

    #[derive(Serialize)]
    struct FingerprintPayload<'a> {
        id: &'a str,
        content: &'a str,
    }

    #[test]
    fn build_scan_key_combines_provider_and_target() {
        assert_eq!(
            build_scan_key("reddit", "https://old.reddit.com/r/rust.json?limit=20"),
            "reddit:https://old.reddit.com/r/rust.json?limit=20"
        );
    }

    #[test]
    fn skip_reason_honors_backoff_and_stale_targets() {
        let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
        let mut state = State::default();
        let entry = entry_for(&mut state, "x:https://x.com/home", "x", "https://x.com/home");
        entry.last_checked_at = Some(now - NORMAL_MIN_SCAN_INTERVAL + Duration::minutes(5));
        entry.no_change_count = 0;
        assert_eq!(
            skip_reason(entry, now).as_deref(),
            Some("backoff_interval_not_elapsed")
        );

        entry.last_checked_at = Some(now - BACKOFF_SCAN_INTERVAL + Duration::minutes(5));
        entry.no_change_count = 2;
        assert_eq!(
            skip_reason(entry, now).as_deref(),
            Some("backoff_interval_not_elapsed")
        );

        entry.last_checked_at = Some(now - Duration::hours(12));
        entry.last_changed_at = Some(now - STALE_SKIP_AFTER - Duration::hours(1));
        assert_eq!(
            skip_reason(entry, now).as_deref(),
            Some("stale_target_backoff")
        );

        entry.last_checked_at = Some(now - STALE_RECHECK_INTERVAL - Duration::hours(1));
        assert_eq!(skip_reason(entry, now), None);
    }

    #[test]
    fn record_not_found_requires_two_strikes() {
        let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
        let mut state = State::default();
        let entry = entry_for(
            &mut state,
            "reddit:https://old.reddit.com/r/rust/comments/abc.json?limit=500",
            "reddit",
            "https://old.reddit.com/r/rust/comments/abc.json?limit=500",
        );

        assert_eq!(record_not_found(entry, now), ScanStatus::Warning);
        assert_eq!(entry.consecutive_not_found_count, 1);
        assert!(!entry.deleted);

        assert_eq!(record_not_found(entry, now), ScanStatus::Deleted);
        assert_eq!(entry.consecutive_not_found_count, 2);
        assert!(entry.deleted);
    }

    #[test]
    fn fingerprint_json_is_stable_for_same_payload() {
        let left = fingerprint_json(&FingerprintPayload {
            id: "1",
            content: "same",
        })
        .unwrap();
        let right = fingerprint_json(&FingerprintPayload {
            id: "1",
            content: "same",
        })
        .unwrap();
        let changed = fingerprint_json(&FingerprintPayload {
            id: "1",
            content: "different",
        })
        .unwrap();

        assert_eq!(left, right);
        assert_ne!(left, changed);
    }

    #[test]
    fn record_success_tracks_fresh_and_unchanged() {
        let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
        let mut state = State::default();
        let entry = entry_for(&mut state, "x:https://x.com/home", "x", "https://x.com/home");

        assert_eq!(record_success(entry, "abc".to_string(), now), ScanStatus::Fresh);
        assert_eq!(entry.no_change_count, 0);
        assert_eq!(record_success(entry, "abc".to_string(), now), ScanStatus::Unchanged);
        assert_eq!(entry.no_change_count, 1);
    }

    #[test]
    fn filters_detect_deleted_bot_short_and_old_content() {
        let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
        let old = now - Duration::days(31);

        assert!(is_bot_like_author("AutoModerator"));
        assert!(is_bot_like_author("[deleted]"));
        assert!(is_too_short("too short"));
        assert!(!is_too_short("這是一段足夠長但沒有空白的中文內容用來驗證長度判斷"));
        assert!(is_too_old(old, now));
        assert!(!is_too_old(now, now));
    }

    #[test]
    fn make_envelope_copies_entry_metadata() {
        let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
        let mut state = State::default();
        let entry = entry_for(&mut state, "x:https://x.com/home", "x", "https://x.com/home");
        entry.last_checked_at = Some(now);
        entry.last_changed_at = Some(now);
        entry.no_change_count = 2;

        let envelope = make_envelope(
            entry,
            ScanStatus::Skipped,
            Some("backoff_interval_not_elapsed".to_string()),
            None,
            Some(vec!["placeholder"]),
        );

        assert_eq!(envelope.provider, "x");
        assert_eq!(envelope.no_change_count, 2);
        assert_eq!(envelope.status, ScanStatus::Skipped);
        assert_eq!(envelope.reason.as_deref(), Some("backoff_interval_not_elapsed"));
        assert_eq!(envelope.data, Some(vec!["placeholder"]));
    }
}