selection-capture 0.1.3

Sync, cancellable selected-text capture engine with strategy-aware fallbacks
Documentation
use crate::profile::{AppProfile, TriState};
use crate::types::{CaptureMethod, PlatformAttemptResult};
use std::collections::{HashMap, VecDeque};
use std::sync::{Mutex, OnceLock};

const ADAPTIVE_HISTORY_WINDOW: usize = 32;
const ADAPTIVE_RECENT_WINDOW: usize = 8;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct MethodAttemptSnapshot {
    method: CaptureMethod,
    success: bool,
}

fn adaptive_history() -> &'static Mutex<HashMap<String, VecDeque<MethodAttemptSnapshot>>> {
    static HISTORY: OnceLock<Mutex<HashMap<String, VecDeque<MethodAttemptSnapshot>>>> =
        OnceLock::new();
    HISTORY.get_or_init(|| Mutex::new(HashMap::new()))
}

pub(crate) fn record_method_outcome(
    app_bundle_id: &str,
    method: CaptureMethod,
    result: &PlatformAttemptResult,
) {
    let success = matches!(result, PlatformAttemptResult::Success(_));
    let snapshot = MethodAttemptSnapshot { method, success };
    if let Ok(mut by_app) = adaptive_history().lock() {
        let history = by_app
            .entry(app_bundle_id.to_string())
            .or_insert_with(VecDeque::new);
        if history.len() >= ADAPTIVE_HISTORY_WINDOW {
            history.pop_front();
        }
        history.push_back(snapshot);
    }
}

pub(crate) fn prioritize_profile_method(
    mut methods: Vec<CaptureMethod>,
    profile: Option<&AppProfile>,
) -> Vec<CaptureMethod> {
    let Some(profile) = profile else {
        return methods;
    };

    let mut scored = methods
        .iter()
        .copied()
        .enumerate()
        .map(|(index, method)| (index, method, score_method_for_profile(method, profile)))
        .collect::<Vec<_>>();
    scored.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
    methods = scored.into_iter().map(|(_, method, _)| method).collect();
    methods
}

fn score_method_for_profile(method: CaptureMethod, profile: &AppProfile) -> i32 {
    let mut score = 0;
    if profile.last_success_method == Some(method) {
        score += 100;
    }

    match (method.is_ax(), profile.ax_supported) {
        (true, TriState::Yes) => score += 20,
        (true, TriState::No) => score -= 40,
        _ => {}
    }

    match (method.is_clipboard(), profile.clipboard_borrow_supported) {
        (true, TriState::Yes) => score += 15,
        (true, TriState::No) => score -= 35,
        _ => {}
    }

    if let Some(last_failure) = profile.last_failure_kind {
        use crate::types::FailureKind;
        match last_failure {
            FailureKind::PermissionDenied => {
                if method.is_ax() && profile.ax_supported != TriState::Yes {
                    score -= 10;
                }
                if method.is_clipboard() && profile.clipboard_borrow_supported != TriState::Yes {
                    score -= 8;
                }
            }
            FailureKind::ClipboardAmbiguous => {
                if method.is_clipboard() {
                    score -= 12;
                }
            }
            FailureKind::EmptySelection => {
                if profile.last_success_method == Some(method) {
                    score -= 5;
                }
            }
            FailureKind::AppBlocked | FailureKind::TimedOut | FailureKind::Cancelled => {}
        }
    }

    score += score_method_from_recent_history(method, &profile.bundle_id);

    score
}

fn score_method_from_recent_history(method: CaptureMethod, bundle_id: &str) -> i32 {
    let Ok(by_app) = adaptive_history().lock() else {
        return 0;
    };
    let Some(history) = by_app.get(bundle_id) else {
        return 0;
    };

    let mut method_successes = 0;
    let mut method_failures = 0;
    let mut recent_successes = 0;
    let mut recent_failures = 0;

    for (idx, attempt) in history
        .iter()
        .rev()
        .take(ADAPTIVE_RECENT_WINDOW)
        .enumerate()
    {
        if attempt.method != method {
            continue;
        }
        if attempt.success {
            method_successes += 1;
            if idx < 4 {
                recent_successes += 1;
            }
        } else {
            method_failures += 1;
            if idx < 4 {
                recent_failures += 1;
            }
        }
    }

    (method_successes * 6) - (method_failures * 4) + (recent_successes * 4) - (recent_failures * 3)
}

#[cfg(test)]
pub(crate) fn reset_adaptive_history_for_tests() {
    if let Ok(mut by_app) = adaptive_history().lock() {
        by_app.clear();
    }
}

#[cfg(test)]
pub(crate) fn adaptive_history_test_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::profile::{AppProfile, TriState};

    #[test]
    fn moves_profile_method_to_front_when_present() {
        let _guard = adaptive_history_test_lock()
            .lock()
            .expect("test lock poisoned");
        reset_adaptive_history_for_tests();
        let profile = AppProfile {
            bundle_id: "com.example".into(),
            ax_supported: TriState::Unknown,
            clipboard_borrow_supported: TriState::Unknown,
            last_success_method: Some(CaptureMethod::ClipboardBorrow),
            last_failure_kind: None,
        };

        let methods = prioritize_profile_method(
            vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
            Some(&profile),
        );

        assert_eq!(
            methods,
            vec![
                CaptureMethod::ClipboardBorrow,
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
            ]
        );
    }

    #[test]
    fn deprioritizes_ax_when_profile_marks_ax_unsupported() {
        let _guard = adaptive_history_test_lock()
            .lock()
            .expect("test lock poisoned");
        reset_adaptive_history_for_tests();
        let profile = AppProfile {
            bundle_id: "com.example".into(),
            ax_supported: TriState::No,
            clipboard_borrow_supported: TriState::Yes,
            last_success_method: None,
            last_failure_kind: None,
        };

        let methods = prioritize_profile_method(
            vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
            Some(&profile),
        );

        assert_eq!(
            methods,
            vec![
                CaptureMethod::ClipboardBorrow,
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
            ]
        );
    }

    #[test]
    fn keeps_last_success_on_top_when_capabilities_are_ambiguous() {
        let _guard = adaptive_history_test_lock()
            .lock()
            .expect("test lock poisoned");
        reset_adaptive_history_for_tests();
        let profile = AppProfile {
            bundle_id: "com.example".into(),
            ax_supported: TriState::Unknown,
            clipboard_borrow_supported: TriState::Unknown,
            last_success_method: Some(CaptureMethod::AccessibilityRange),
            last_failure_kind: Some(crate::types::FailureKind::EmptySelection),
        };

        let methods = prioritize_profile_method(
            vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
            Some(&profile),
        );

        assert_eq!(methods[0], CaptureMethod::AccessibilityRange);
    }

    #[test]
    fn deprioritizes_clipboard_after_ambiguous_failures() {
        let _guard = adaptive_history_test_lock()
            .lock()
            .expect("test lock poisoned");
        reset_adaptive_history_for_tests();
        let profile = AppProfile {
            bundle_id: "com.example".into(),
            ax_supported: TriState::Yes,
            clipboard_borrow_supported: TriState::Unknown,
            last_success_method: None,
            last_failure_kind: Some(crate::types::FailureKind::ClipboardAmbiguous),
        };

        let methods = prioritize_profile_method(
            vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
            Some(&profile),
        );

        assert_eq!(methods.last(), Some(&CaptureMethod::ClipboardBorrow));
    }

    #[test]
    fn recent_history_promotes_consistent_successful_method() {
        let _guard = adaptive_history_test_lock()
            .lock()
            .expect("test lock poisoned");
        reset_adaptive_history_for_tests();
        let profile = AppProfile {
            bundle_id: "com.history".into(),
            ax_supported: TriState::Unknown,
            clipboard_borrow_supported: TriState::Unknown,
            last_success_method: None,
            last_failure_kind: None,
        };

        for _ in 0..4 {
            record_method_outcome(
                "com.history",
                CaptureMethod::ClipboardBorrow,
                &PlatformAttemptResult::Success("ok".to_string()),
            );
        }
        for _ in 0..2 {
            record_method_outcome(
                "com.history",
                CaptureMethod::AccessibilityPrimary,
                &PlatformAttemptResult::EmptySelection,
            );
        }

        let methods = prioritize_profile_method(
            vec![
                CaptureMethod::AccessibilityPrimary,
                CaptureMethod::AccessibilityRange,
                CaptureMethod::ClipboardBorrow,
            ],
            Some(&profile),
        );

        assert_eq!(methods[0], CaptureMethod::ClipboardBorrow);
    }
}