Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-korp/src/search.rs

//! **facett-korp · SearchPane** — the korp **global object search** control surface
//! as a facett-Elm component (the Palantir P5 / S-1 move: one box → ranked objects).
//!
//! korp's warehouse/ontology objects had no one-box search. This pane extracts that
//! control surface: a query box, a **Search** action, and a ranked results list the
//! analyst clicks to select an object. The pane holds only the query + the
//! host-fed result rows; the actual ranking runs in the korp host over
//! `korp::search::SearchState` (which owns the [`korp_ontology::search::ObjectIndex`]).
//! The pane emits a [`SearchEffect::Query`] the host turns into a real
//! `SearchState::search(query)` and feeds the ranked hits back via
//! [`SearchPane::sync_results`].
//!
//! ## The Elm seam (why there is no `korp` dependency)
//!
//! korp *depends on this crate* to render its Search controls, so this crate must
//! NOT depend on `korp` (cycle). It stays a pure leaf: it mirrors a hit as its own
//! serializable [`SearchRow`] and emits **effects as data** — the index + search is
//! a host concern. It implements [`facett_core::Elm`];
//! [`facett_core::impl_facet_via_elm!`] bridges it to
//! [`facett_core::Facet`]/[`facett_core::Panel`], so the headless robot harness
//! drives it through `update_json` and asserts `state_json`.

use egui::{Color32, Ui};
use facett_core::Elm;
use serde::{Deserialize, Serialize};

/// One ranked result row — the facett-side mirror of a `korp_ontology::search::Hit`
/// (kept local so this crate never depends on `korp-ontology`). `id` is the
/// canonical `type/key` ObjectId the query resolved to.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchRow {
    /// Canonical `ObjectId` string (`Company/5560160680`).
    pub id: String,
    /// The object type (`Company`).
    pub type_name: String,
    /// Display name (or the id key when the object is unnamed).
    pub name: String,
    /// Ranking score (higher = stronger match).
    pub score: u32,
    /// Which field matched (`name` / `type` / `id` / `property:<key>`).
    pub field: String,
}

impl SearchRow {
    pub fn new(
        id: impl Into<String>,
        type_name: impl Into<String>,
        name: impl Into<String>,
        score: u32,
        field: impl Into<String>,
    ) -> Self {
        Self {
            id: id.into(),
            type_name: type_name.into(),
            name: name.into(),
            score,
            field: field.into(),
        }
    }

    /// A short display caption: `name — type (field)`.
    #[must_use]
    pub fn caption(&self) -> String {
        let head = if self.name.is_empty() { self.id.clone() } else { self.name.clone() };
        format!("{head}  —  {} ({})", self.type_name, self.field)
    }
}

/// The complete observable state of the Search pane (FC-1/FC-3).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SearchModel {
    pub title: String,
    /// The query the box holds / the last one searched (host-synced).
    pub query: String,
    /// The ranked result rows (host-fed via [`SearchPane::sync_results`]).
    pub results: Vec<SearchRow>,
    /// Total loaded objects the host has indexed (host-fed) — surfaced so a robot
    /// can prove the search ran over a real object set.
    pub indexed: usize,
    /// Index into `results` of the selected hit, if any.
    pub selected: Option<usize>,
    /// Human note describing the last action (the queued search).
    pub last_action: Option<String>,
}

/// Every input the pane accepts (FC-2). Externally-tagged serde so the robot /
/// `update_json` seam sends `{"set_query":"volvo"}` / `{"select":0}` and the bare
/// strings `"run"` / `"clear"`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchMsg {
    SetQuery(String),
    Run,
    Select(usize),
    Clear,
}

/// Side work as data (FC-8): the korp host turns this into a real
/// `SearchState::search(query)` and syncs the ranked hits back.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchEffect {
    /// Run the global object search for this query.
    Query(String),
}

/// The korp Search-controls pane.
#[derive(Clone, Debug, PartialEq)]
pub struct SearchPane {
    model: SearchModel,
}

impl Default for SearchPane {
    fn default() -> Self {
        Self::new("Search")
    }
}

impl SearchPane {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            model: SearchModel {
                title: title.into(),
                query: String::new(),
                results: Vec::new(),
                indexed: 0,
                selected: None,
                last_action: None,
            },
        }
    }

    /// Host feedback: replace the ranked result rows + the indexed-object count the
    /// analyst picks from. Called by the korp host after it runs
    /// `SearchState::search`. Drops a selection that fell out of range.
    pub fn sync_results(&mut self, query: impl Into<String>, results: Vec<SearchRow>, indexed: usize) {
        self.model.query = query.into();
        let n = results.len();
        self.model.results = results;
        self.model.indexed = indexed;
        if let Some(i) = self.model.selected && i >= n {
            self.model.selected = None;
        }
    }

    pub fn model(&self) -> &SearchModel {
        &self.model
    }

    /// The `ObjectId` string of the selected hit, if any.
    pub fn selected_id(&self) -> Option<&str> {
        self.model.selected.and_then(|i| self.model.results.get(i)).map(|r| r.id.as_str())
    }
}

impl Elm for SearchPane {
    type Model = SearchModel;
    type Msg = SearchMsg;
    type Effect = SearchEffect;

    fn title(&self) -> &str {
        &self.model.title
    }

    fn state(&self) -> &Self::Model {
        &self.model
    }

    fn update(&mut self, msg: Self::Msg) -> Vec<Self::Effect> {
        match msg {
            SearchMsg::SetQuery(s) => {
                self.model.query = s;
                Vec::new()
            }
            SearchMsg::Run => {
                let q = self.model.query.trim().to_string();
                if q.is_empty() {
                    self.model.last_action = Some("empty query".into());
                    Vec::new()
                } else {
                    self.model.last_action = Some(format!("searching “{q}”"));
                    vec![SearchEffect::Query(q)]
                }
            }
            SearchMsg::Select(i) => {
                if i < self.model.results.len() {
                    self.model.selected = Some(i);
                }
                Vec::new()
            }
            SearchMsg::Clear => {
                self.model.query.clear();
                self.model.results.clear();
                self.model.selected = None;
                self.model.last_action = None;
                Vec::new()
            }
        }
    }

    fn view(&self, ui: &mut Ui) -> Vec<Self::Msg> {
        let mut msgs = Vec::new();
        ui.heading("korp · Search");
        ui.label(format!("{} objects indexed", self.model.indexed)); // plain numbers
        ui.horizontal(|ui| {
            let mut q = self.model.query.clone();
            let resp = ui.add(egui::TextEdit::singleline(&mut q).hint_text("name / type / property…"));
            if resp.changed() {
                msgs.push(SearchMsg::SetQuery(q));
            }
            if ui.button("🔎 Search").clicked() || (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
                msgs.push(SearchMsg::Run);
            }
            if ui.button("✕ Clear").clicked() {
                msgs.push(SearchMsg::Clear);
            }
        });
        ui.separator();
        if self.model.results.is_empty() {
            // The empty-results state carries the stable UI error code facet-korp-2
            // (facett_core::errcode) on its a11y atom so tests + consumers react to the
            // CODE, not a matched string.
            let resp = ui.label("(no results — type a query and Search)");
            resp.widget_info(|| {
                facett_core::a11y::Semantics::error("(no results — type a query and Search)")
                    .error_code("facet-korp-2")
                    .widget_info()
            });
        } else {
            ui.label(format!("{} hits", self.model.results.len())); // plain numbers
            for (i, row) in self.model.results.iter().enumerate() {
                let selected = self.model.selected == Some(i);
                if ui.selectable_label(selected, row.caption()).clicked() {
                    msgs.push(SearchMsg::Select(i));
                }
            }
        }
        if let Some(a) = &self.model.last_action {
            ui.separator();
            ui.colored_label(Color32::from_rgb(120, 200, 120), a);
        }
        msgs
    }
}

facett_core::impl_facet_via_elm!(SearchPane, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "korp-search",
            "title": self.model.title,
            "query": self.model.query,
            "indexed": self.model.indexed,
            "hits": self.model.results.len(),
            "selected": self.model.selected,
            "selected_id": self.selected_id(),
            "last_action": self.model.last_action,
            "results": self.model.results,
            // The stable UI ERROR CODE for the empty-results state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            "error_code": if self.model.results.is_empty() {
                serde_json::json!("facet-korp-2")
            } else {
                serde_json::Value::Null
            },
        })
    }

    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the search has no
    /// results to display (the facet-korp-2 empty state), the Info floor otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.model.results.is_empty() {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<SearchMsg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::Panel;

    /// A pane seeded with a host-fed result roster (as korp does after a search).
    fn seeded() -> SearchPane {
        let mut p = SearchPane::new("Search");
        p.sync_results(
            "volvo",
            vec![
                SearchRow::new("Company/5560160680", "Company", "Volvo", 100, "name"),
                SearchRow::new("Vessel/265547890", "Vessel", "Sea Star", 30, "property:owner"),
            ],
            7,
        );
        p
    }

    #[test]
    fn fresh_pane_opens_empty() {
        let p = SearchPane::default();
        assert!(p.model().query.is_empty());
        assert!(p.model().results.is_empty());
        assert!(p.model().last_action.is_none());
    }

    #[test]
    fn run_with_a_query_emits_the_query_effect() {
        let mut p = SearchPane::new("Search");
        p.update(SearchMsg::SetQuery("volvo".into()));
        let fx = p.update(SearchMsg::Run);
        assert_eq!(fx, vec![SearchEffect::Query("volvo".into())]);
        assert_eq!(p.model().last_action.as_deref(), Some("searching “volvo”"));
    }

    /// RED-when-broken: an empty query must NOT fire a search round-trip.
    #[test]
    fn empty_query_is_a_clean_noop() {
        let mut p = SearchPane::new("Search");
        p.update(SearchMsg::SetQuery("   ".into()));
        assert!(p.update(SearchMsg::Run).is_empty());
        assert_eq!(p.model().last_action.as_deref(), Some("empty query"));
    }

    #[test]
    fn select_pins_the_object_id() {
        let mut p = seeded();
        p.update(SearchMsg::Select(1));
        assert_eq!(p.selected_id(), Some("Vessel/265547890"));
    }

    /// A shrinking result set drops a now-out-of-range selection.
    #[test]
    fn sync_results_clamps_stale_selection() {
        let mut p = seeded();
        p.update(SearchMsg::Select(1));
        assert_eq!(p.model().selected, Some(1));
        p.sync_results("volvo", vec![SearchRow::new("Company/5560160680", "Company", "Volvo", 100, "name")], 7);
        assert_eq!(p.model().selected, None, "the out-of-range selection was dropped");
    }

    #[test]
    fn clear_resets_query_and_results() {
        let mut p = seeded();
        p.update(SearchMsg::Clear);
        assert!(p.model().query.is_empty());
        assert!(p.model().results.is_empty());
    }

    /// The **robot/remote-control seam driven IN CODE** — the exact `update_json`
    /// a headless robot sends — set a query + run, then assert the effect landed in
    /// `state_json` (via last_action) and a host-synced result carries its ObjectId.
    /// No pixels, self-driving.
    #[test]
    fn robot_json_drive_query_and_select() {
        let mut p = SearchPane::new("Search");
        Panel::update_json(&mut p, r#"{"set_query":"volvo"}"#);
        Panel::update_json(&mut p, "\"run\"");
        let st = Panel::state_json(&p);
        assert_eq!(st["kind"], "korp-search");
        assert_eq!(st["query"], "volvo");
        assert_eq!(st["last_action"], "searching “volvo”");

        // Host feeds ranked results back, then the robot selects the top hit.
        p.sync_results(
            "volvo",
            vec![SearchRow::new("Company/5560160680", "Company", "Volvo", 100, "name")],
            7,
        );
        Panel::update_json(&mut p, r#"{"select":0}"#);
        let st = Panel::state_json(&p);
        assert_eq!(st["hits"], 1);
        assert_eq!(st["indexed"], 7);
        assert_eq!(st["selected_id"], "Company/5560160680");

        // malformed / unknown messages are ignored, not a panic.
        Panel::update_json(&mut p, "not json [[[");
        Panel::update_json(&mut p, r#"{"nonsense":true}"#);
        assert_eq!(Panel::state_json(&p)["selected_id"], "Company/5560160680");
    }

    /// Emit the test-matrix cell so `nornir test --features testmatrix` SEES the
    /// P5/S-1 search-drive is covered. No-op without `testmatrix`.
    #[test]
    fn testmatrix_cell_search_drive() {
        let mut p = SearchPane::new("Search");
        Panel::update_json(&mut p, r#"{"set_query":"volvo"}"#);
        Panel::update_json(&mut p, "\"run\"");
        let ok = Panel::state_json(&p)["last_action"] == "searching “volvo”";
        facett_core::testmatrix::emit(
            "facett-korp/search",
            "global_object_search_s1_drive",
            ok,
            "set_query→run via update_json emits the Query effect (P5 S-1)",
        );
        assert!(ok);
    }
}