use serde::{Deserialize, Serialize};
use smos_domain::{FactId, MemoryKey};
#[derive(Debug, Clone, PartialEq)]
pub struct SearchHit {
pub id: FactId,
pub document: String,
pub memory_key: MemoryKey,
pub metadata: SearchHitMetadata,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchHitMetadata {
pub status: String,
pub confidence: f32,
pub valid_until: Option<String>,
pub heat_base: f32,
pub last_access_at: f32,
pub distance: Option<f32>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub conflicts_with: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_metadata() -> SearchHitMetadata {
SearchHitMetadata {
status: "accepted".into(),
confidence: 0.85,
valid_until: None,
heat_base: 1.0,
last_access_at: 1_700_000_000.0,
distance: Some(0.12),
created_at: Some("2025-06-18T12:00:00Z".into()),
conflicts_with: vec!["fact_deadbeefdeadbee".into()],
}
}
#[test]
fn metadata_roundtrips_through_serde() {
let meta = sample_metadata();
let json = serde_json::to_string(&meta).unwrap();
let back: SearchHitMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(meta, back);
}
#[test]
fn metadata_serialises_optional_valid_until_as_null_when_absent() {
let meta = sample_metadata();
let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
assert_eq!(v["valid_until"], serde_json::Value::Null);
}
#[test]
fn metadata_serialises_optional_distance_as_number_when_present() {
let meta = sample_metadata();
let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
let got = v["distance"].as_f64().unwrap_or(f64::NAN);
assert!((got - 0.12).abs() < 1e-5, "got {got}");
}
#[test]
fn metadata_supports_tombstoned_fact() {
let meta = SearchHitMetadata {
status: "accepted".into(),
confidence: 0.9,
valid_until: Some("2027-01-01T00:00:00Z".into()),
heat_base: 0.4,
last_access_at: 1_700_000_050.0,
distance: None,
created_at: None,
conflicts_with: Vec::new(),
};
let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
assert_eq!(v["valid_until"], "2027-01-01T00:00:00Z");
assert_eq!(v["distance"], serde_json::Value::Null);
}
#[test]
fn metadata_roundtrips_created_at_and_conflicts_with() {
let meta = SearchHitMetadata {
status: "accepted".into(),
confidence: 0.9,
valid_until: None,
heat_base: 1.0,
last_access_at: 1_700_000_000.0,
distance: Some(0.05),
created_at: Some("2025-06-18T12:00:00Z".into()),
conflicts_with: vec![
"fact_aaaaaaaaaaaaaaaa".into(),
"fact_bbbbbbbbbbbbbbbb".into(),
],
};
let json = serde_json::to_string(&meta).unwrap();
let back: SearchHitMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(back.created_at.as_deref(), Some("2025-06-18T12:00:00Z"));
assert_eq!(
back.conflicts_with,
vec!["fact_aaaaaaaaaaaaaaaa", "fact_bbbbbbbbbbbbbbbb"]
);
}
#[test]
fn metadata_deserialises_legacy_payload_missing_new_fields() {
let legacy = serde_json::json!({
"status": "accepted",
"confidence": 0.8,
"valid_until": null,
"heat_base": 1.0,
"last_access_at": 1700000000.0,
"distance": 0.1
});
let meta: SearchHitMetadata = serde_json::from_value(legacy).unwrap();
assert!(meta.created_at.is_none());
assert!(meta.conflicts_with.is_empty());
}
}