lucisearch 0.8.1

Embeddable, in-process search engine — the SQLite/DuckDB of search
Documentation
//! Integration tests for geo_shape field type and query.

use luci::index::Index;
use luci::mapping::{FieldType, Mapping};
use serde_json::json;

fn search(
    index: &mut luci::index::Index,
    query: serde_json::Value,
    size: usize,
) -> luci::search::results::SearchResults {
    let expr = luci::search::expression::parse_search(query, size).unwrap();
    index.search(&expr).unwrap()
}

fn test_dir(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!("luci_geo_shape_{}_{name}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    dir
}

fn cleanup(path: &std::path::Path) {
    let _ = std::fs::remove_dir_all(path);
}

fn build_shape_index(name: &str) -> (std::path::PathBuf, Index) {
    let path = test_dir(name);
    let schema = Mapping::builder()
        .field("name", FieldType::Keyword)
        .field("boundary", FieldType::GeoShape)
        .build();
    let index = Index::create_with_mapping(&path, schema).unwrap();

    // Doc 0: NYC area (polygon around Manhattan)
    index
        .add(json!({
            "name": "nyc",
            "boundary": {
                "type": "Polygon",
                "coordinates": [[
                    [-74.05, 40.68], [-73.90, 40.68], [-73.90, 40.82],
                    [-74.05, 40.82], [-74.05, 40.68]
                ]]
            }
        }))
        .unwrap();

    // Doc 1: London area
    index
        .add(json!({
            "name": "london",
            "boundary": {
                "type": "Polygon",
                "coordinates": [[
                    [-0.30, 51.40], [0.10, 51.40], [0.10, 51.60],
                    [-0.30, 51.60], [-0.30, 51.40]
                ]]
            }
        }))
        .unwrap();

    // Doc 2: Paris area
    index
        .add(json!({
            "name": "paris",
            "boundary": {
                "type": "Polygon",
                "coordinates": [[
                    [2.20, 48.80], [2.50, 48.80], [2.50, 48.92],
                    [2.20, 48.92], [2.20, 48.80]
                ]]
            }
        }))
        .unwrap();

    // Doc 3: A point geometry (Empire State Building)
    index
        .add(json!({
            "name": "empire_state",
            "boundary": {
                "type": "Point",
                "coordinates": [-73.9857, 40.7484]
            }
        }))
        .unwrap();

    (path, index)
}

/// geo_shape intersects: polygon query overlapping NYC
#[test]
fn geo_shape_intersects() {
    let (path, mut index) = build_shape_index("intersects");

    // Query polygon overlaps NYC and the Empire State point
    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "Polygon",
                            "coordinates": [[
                                [-74.00, 40.74], [-73.96, 40.74], [-73.96, 40.76],
                                [-74.00, 40.76], [-74.00, 40.74]
                            ]]
                        },
                        "relation": "intersects"
                    }
                }
            }
        }),
        10,
    );

    let names: Vec<String> = results
        .iter()
        .filter_map(|h| h.source()?.get("name")?.as_str().map(String::from))
        .collect();
    assert!(
        names.contains(&"nyc".to_string()),
        "NYC should intersect: {names:?}"
    );
    assert!(
        names.contains(&"empire_state".to_string()),
        "Empire State point should intersect: {names:?}"
    );
    assert!(
        !names.contains(&"london".to_string()),
        "London should NOT intersect: {names:?}"
    );
    assert!(
        !names.contains(&"paris".to_string()),
        "Paris should NOT intersect: {names:?}"
    );

    cleanup(&path);
}

/// geo_shape disjoint: shapes NOT overlapping the query
#[test]
fn geo_shape_disjoint() {
    let (path, mut index) = build_shape_index("disjoint");

    // Small query polygon inside NYC — disjoint returns everything that DOESN'T overlap
    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "Polygon",
                            "coordinates": [[
                                [-74.00, 40.74], [-73.96, 40.74], [-73.96, 40.76],
                                [-74.00, 40.76], [-74.00, 40.74]
                            ]]
                        },
                        "relation": "disjoint"
                    }
                }
            }
        }),
        10,
    );

    let names: Vec<String> = results
        .iter()
        .filter_map(|h| h.source()?.get("name")?.as_str().map(String::from))
        .collect();
    assert!(
        names.contains(&"london".to_string()),
        "London should be disjoint: {names:?}"
    );
    assert!(
        names.contains(&"paris".to_string()),
        "Paris should be disjoint: {names:?}"
    );
    assert!(
        !names.contains(&"nyc".to_string()),
        "NYC should NOT be disjoint: {names:?}"
    );

    cleanup(&path);
}

/// geo_shape within: doc shape entirely inside query shape
#[test]
fn geo_shape_within() {
    let (path, mut index) = build_shape_index("within");

    // Large query polygon covering all of Western Europe + Eastern US
    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "Polygon",
                            "coordinates": [[
                                [-75.0, 40.0], [-75.0, 52.0], [3.0, 52.0],
                                [3.0, 40.0], [-75.0, 40.0]
                            ]]
                        },
                        "relation": "within"
                    }
                }
            }
        }),
        10,
    );

    let names: Vec<String> = results
        .iter()
        .filter_map(|h| h.source()?.get("name")?.as_str().map(String::from))
        .collect();
    // All shapes should be within this large polygon
    assert!(
        names.contains(&"nyc".to_string()),
        "NYC should be within: {names:?}"
    );
    assert!(
        names.contains(&"london".to_string()),
        "London should be within: {names:?}"
    );
    assert!(
        names.contains(&"paris".to_string()),
        "Paris should be within: {names:?}"
    );
    assert!(
        names.contains(&"empire_state".to_string()),
        "Empire State should be within: {names:?}"
    );

    cleanup(&path);
}

/// geo_shape contains: doc shape entirely encloses query shape
#[test]
fn geo_shape_contains() {
    let (path, mut index) = build_shape_index("contains");

    // Tiny query shape inside NYC
    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "Point",
                            "coordinates": [-73.98, 40.75]
                        },
                        "relation": "contains"
                    }
                }
            }
        }),
        10,
    );

    let names: Vec<String> = results
        .iter()
        .filter_map(|h| h.source()?.get("name")?.as_str().map(String::from))
        .collect();
    assert!(
        names.contains(&"nyc".to_string()),
        "NYC polygon should contain the point: {names:?}"
    );
    assert!(
        !names.contains(&"london".to_string()),
        "London should NOT contain it: {names:?}"
    );

    cleanup(&path);
}

/// geo_shape with envelope (bounding box shorthand)
#[test]
fn geo_shape_envelope() {
    let (path, mut index) = build_shape_index("envelope");

    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "envelope",
                            "coordinates": [[-74.1, 40.85], [-73.85, 40.65]]
                        },
                        "relation": "intersects"
                    }
                }
            }
        }),
        10,
    );

    let names: Vec<String> = results
        .iter()
        .filter_map(|h| h.source()?.get("name")?.as_str().map(String::from))
        .collect();
    assert!(
        names.contains(&"nyc".to_string()),
        "NYC should intersect envelope: {names:?}"
    );
    assert!(
        names.contains(&"empire_state".to_string()),
        "Empire State should intersect envelope: {names:?}"
    );

    cleanup(&path);
}

/// Default relation is intersects
#[test]
fn geo_shape_default_relation() {
    let (path, mut index) = build_shape_index("default_relation");

    // No explicit "relation" — should default to intersects
    let results = search(
        &mut index,
        json!({
            "query": {
                "geo_shape": {
                    "boundary": {
                        "shape": {
                            "type": "Point",
                            "coordinates": [-73.98, 40.75]
                        }
                    }
                }
            }
        }),
        10,
    );

    // The point is inside NYC and coincides with empire_state
    assert!(results.total_hits().value >= 1);

    cleanup(&path);
}