clipmem 0.4.2

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use anyhow::Result;

use crate::db::{Database, RetrievalFilters, SearchMode, SearchResults};
use crate::model::{build_item, build_representation, build_snapshot, CaptureContext};

use super::{
    analyze_query, fts_query, invalid_fts_message, is_simple_fts_query, literal_fts_match_query,
    normalise_file_path,
};

pub(in crate::db) fn fake_snapshot(
    change_count: i64,
    text: &str,
) -> crate::model::ClipboardSnapshot {
    build_snapshot(
        CaptureContext::new(change_count)
            .with_frontmost_app_name("Terminal")
            .with_frontmost_app_bundle_id("com.apple.Terminal"),
        vec![build_item(
            0,
            vec![build_representation(
                "public.utf8-plain-text".to_string(),
                None,
                text.as_bytes().to_vec(),
            )],
        )],
    )
}

pub(in crate::db) fn fake_file_snapshot(
    change_count: i64,
    file_url: &str,
) -> crate::model::ClipboardSnapshot {
    build_snapshot(
        CaptureContext::new(change_count)
            .with_frontmost_app_name("Finder")
            .with_frontmost_app_bundle_id("com.apple.finder"),
        vec![build_item(
            0,
            vec![build_representation(
                "public.file-url".to_string(),
                Some(file_url.to_string()),
                file_url.as_bytes().to_vec(),
            )],
        )],
    )
}

pub(in crate::db) fn unfiltered() -> RetrievalFilters {
    RetrievalFilters::default()
}

#[test]
pub(in crate::db) fn like_special_characters_skip_fts_mode() {
    assert!(analyze_query("50%").literal_preferred);
    assert!(analyze_query("config_test").literal_preferred);
    assert!(analyze_query(r"logs\2024").literal_preferred);
    assert!(analyze_query("https://example.com").literal_preferred);
    assert!(analyze_query("com.apple.Terminal").literal_preferred);
    assert!(analyze_query("~/path/with/slashes").literal_preferred);
    assert!(analyze_query("foo:bar").literal_preferred);
    assert!(analyze_query("git commit -m").literal_preferred);
    assert!(!analyze_query("git clone").literal_preferred);
}

#[test]
pub(in crate::db) fn query_analysis_preserves_exact_phrase_without_forcing_literal() {
    let analysis = analyze_query("\"launchctl bootstrap\"");
    assert_eq!(
        analysis.exact_phrase.as_deref(),
        Some("launchctl bootstrap")
    );
    assert!(!analysis.literal_preferred);
}

#[test]
pub(in crate::db) fn simple_fts_query_skips_per_row_phrase_scoring() {
    assert!(is_simple_fts_query(&analyze_query("git")));
    assert!(!is_simple_fts_query(&analyze_query("\"git clone\"")));
    assert!(!is_simple_fts_query(&analyze_query("git clone")));
    assert!(!is_simple_fts_query(&analyze_query("com.apple.Terminal")));

    let simple_sql = fts_query(false, false, true);
    let phrase_sql = fts_query(false, false, false);

    assert!(!simple_sql.contains("Phrase match in best text"));
    assert!(!simple_sql.contains("THEN 0.08"));
    assert!(phrase_sql.contains("Phrase match in best text"));
    assert!(phrase_sql.contains("THEN 0.08"));
}

#[test]
pub(in crate::db) fn invalid_fts_messages_are_narrowly_classified() {
    assert!(invalid_fts_message("fts5: syntax error near \"%\""));
    assert!(invalid_fts_message("malformed MATCH expression"));
    assert!(invalid_fts_message("no such column: https"));
    assert!(!invalid_fts_message("database disk image is malformed"));
}

#[test]
pub(in crate::db) fn literal_fts_match_query_skips_like_escaped_inputs() {
    assert!(literal_fts_match_query(&analyze_query("50%")).is_none());
    assert!(literal_fts_match_query(&analyze_query("config_test")).is_none());
    assert!(literal_fts_match_query(&analyze_query(r"logs\2024")).is_none());
    assert_eq!(
        literal_fts_match_query(&analyze_query("/tmp/repo/42/Cargo.toml")).as_deref(),
        Some("\"/tmp/repo/42/cargo.toml\"")
    );
    assert_eq!(
        analyze_query("~/path/with/slashes")
            .path_fragment
            .as_deref(),
        Some("path/with/slashes")
    );
    assert_eq!(
        analyze_query("file:///tmp/repo/42/Cargo.toml")
            .path_fragment
            .as_deref(),
        Some("/tmp/repo/42/cargo.toml")
    );
}

#[test]
pub(in crate::db) fn file_urls_decode_to_paths() {
    assert_eq!(
        normalise_file_path("file:///Users/test/Documents/hello%20world.txt"),
        "/Users/test/Documents/hello world.txt"
    );
    assert_eq!(
        normalise_file_path("/Users/test/Documents/hello world.txt"),
        "/Users/test/Documents/hello world.txt"
    );
}

#[test]
pub(in crate::db) fn search_auto_uses_literal_matching_for_wildcard_queries() -> Result<()> {
    let mut db = Database::open_in_memory()?;

    db.store_capture(&fake_snapshot(1, "Discount: 50 percent off"))?;
    db.store_capture(&fake_snapshot(2, "Discount: 50%"))?;

    let results = db.search_auto("50%", 10, &unfiltered())?;
    assert_eq!(results.mode_used(), SearchMode::Literal);
    assert_eq!(results.hits().len(), 1);
    assert_eq!(results.hits()[0].preview_text(), "Discount: 50%");
    Ok(())
}

#[test]
pub(in crate::db) fn file_path_search_matches_paths_with_spaces() -> Result<()> {
    let mut db = Database::open_in_memory()?;

    db.store_capture(&fake_file_snapshot(
        1,
        "file:///Users/test/work/foo-bar.txt",
    ))?;
    let spaced = db.store_capture(&fake_file_snapshot(
        2,
        "file:///Users/test/work/foo%20bar.txt",
    ))?;

    let results = db.search_auto("/Users/test/work/foo bar.txt", 10, &unfiltered())?;

    assert_eq!(results.mode_used(), SearchMode::Literal);
    assert_eq!(results.hits().len(), 1);
    assert_eq!(results.hits()[0].snapshot_id(), spaced.snapshot_id());
    assert_eq!(
        results.hits()[0].file_paths(),
        &["/Users/test/work/foo bar.txt".to_string()]
    );
    Ok(())
}

#[test]
pub(in crate::db) fn file_path_search_matches_percent_encoded_path_characters() -> Result<()> {
    let mut db = Database::open_in_memory()?;

    let stored = db.store_capture(&fake_file_snapshot(
        1,
        "file:///Users/test/work/release%231.txt",
    ))?;

    let results = db.search_auto("/Users/test/work/release#1.txt", 10, &unfiltered())?;

    assert_eq!(results.mode_used(), SearchMode::Literal);
    assert_eq!(results.hits().len(), 1);
    assert_eq!(results.hits()[0].snapshot_id(), stored.snapshot_id());
    assert_eq!(
        results.hits()[0].file_paths(),
        &["/Users/test/work/release#1.txt".to_string()]
    );
    Ok(())
}

#[test]
pub(in crate::db) fn find_snapshot_hydrates_nested_items_and_recent_events() -> Result<()> {
    let mut db = Database::open_in_memory()?;
    let snapshot = build_snapshot(
        CaptureContext::new(1)
            .with_frontmost_app_name("Terminal")
            .with_frontmost_app_bundle_id("com.apple.Terminal"),
        vec![build_item(
            0,
            vec![build_representation(
                "public.utf8-plain-text".to_string(),
                None,
                b"git status".to_vec(),
            )],
        )],
    );
    let stored = db.store_capture(&snapshot)?;
    db.store_capture(&snapshot)?;

    let details = db
        .find_snapshot(stored.snapshot_id(), 10)?
        .expect("snapshot exists");
    assert_eq!(details.items().len(), 1);
    assert_eq!(details.recent_events().len(), 2);
    Ok(())
}

#[test]
pub(in crate::db) fn empty_fts_results_fall_back_to_literal_matches() -> Result<()> {
    let mut db = Database::open_in_memory()?;
    db.store_capture(&fake_snapshot(1, "https://example.com/repo"))?;

    let results: SearchResults = db.search_auto("https://example.com/repo", 10, &unfiltered())?;
    assert_eq!(results.mode_used(), SearchMode::Literal);
    assert_eq!(results.hits().len(), 1);
    Ok(())
}