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);
}
}