use std::path::{Path, PathBuf};
use graphforge_core::GfError;
pub const SEARCH_MANIFEST_VERSION: u32 = 1;
pub const MAX_SEARCH_MANIFEST_BYTES: usize = 64 * 1024;
pub const MAX_SEARCH_SELECTOR_BYTES: usize = 128;
pub const MAX_SEARCH_ARTIFACT_KEY_BYTES: usize = 384;
#[derive(Debug, thiserror::Error)]
pub enum SearchArtifactError {
#[error("invalid search {field}: {reason}")]
InvalidSelector {
field: &'static str,
reason: String,
},
#[error("search artifact is missing at {}", path.display())]
Missing {
path: PathBuf,
},
#[error("corrupt search manifest at {}: {reason}", path.display())]
CorruptManifest {
path: PathBuf,
reason: String,
},
#[error("corrupt derived search index at {}: {reason}", path.display())]
CorruptDerivedIndex {
path: PathBuf,
reason: String,
},
#[error(
"incompatible search manifest at {}: version {found}, supported {supported}",
path.display()
)]
IncompatibleManifest {
path: PathBuf,
found: u64,
supported: u32,
},
#[error("stale search artifact: {reason}")]
Stale {
reason: String,
},
#[error("corrupt primary vector data at {}: {reason}", path.display())]
CorruptPrimaryVectors {
path: PathBuf,
reason: String,
},
#[error("graph changed during both search publication attempts")]
ConcurrentMutation,
#[error("search writer lock failed at {}: {reason}", path.display())]
Lock {
path: PathBuf,
reason: String,
},
#[error("search operation cancelled")]
Cancelled,
#[error("search resource limit exceeded for {resource}: limit {limit}")]
ResourceExhausted {
resource: &'static str,
limit: u64,
},
#[error("search artifact build failed: {0}")]
Build(String),
#[error("search source snapshot failed: {reason}")]
SourceSnapshot {
reason: String,
},
#[error("search storage {operation} failed at {}: {source}", path.display())]
Io {
operation: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl From<SearchArtifactError> for GfError {
fn from(error: SearchArtifactError) -> Self {
let message = error.to_string();
match error {
SearchArtifactError::InvalidSelector { .. } => Self::Validation(message),
SearchArtifactError::Cancelled
| SearchArtifactError::ResourceExhausted { .. }
| SearchArtifactError::Build(_) => Self::Execution(message),
SearchArtifactError::ConcurrentMutation => Self::Lifecycle(message),
SearchArtifactError::Missing { .. }
| SearchArtifactError::CorruptManifest { .. }
| SearchArtifactError::CorruptDerivedIndex { .. }
| SearchArtifactError::IncompatibleManifest { .. }
| SearchArtifactError::Stale { .. }
| SearchArtifactError::CorruptPrimaryVectors { .. }
| SearchArtifactError::SourceSnapshot { .. }
| SearchArtifactError::Lock { .. }
| SearchArtifactError::Io { .. } => Self::Storage(message),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SearchIndexKind {
Text,
Vector,
}
impl SearchIndexKind {
fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Vector => "vector",
}
}
fn parse(value: &str, path: &Path) -> Result<Self, SearchArtifactError> {
match value {
"text" => Ok(Self::Text),
"vector" => Ok(Self::Vector),
other => Err(corrupt(path, format!("unknown index_kind {other:?}"))),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtifactKey {
kind: SearchIndexKind,
label: String,
properties: Option<Vec<String>>,
space: Option<String>,
}
impl SearchArtifactKey {
pub fn text<I, S>(label: &str, properties: I) -> Result<Self, SearchArtifactError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let label = normalize_selector("label", label)?;
let mut properties = properties
.into_iter()
.map(|property| normalize_selector("property", property.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
properties.sort_unstable();
properties.dedup();
if properties.is_empty() {
return Err(invalid("properties", "at least one property is required"));
}
let key = Self {
kind: SearchIndexKind::Text,
label,
properties: Some(properties),
space: None,
};
key.validate_total_size()?;
Ok(key)
}
pub fn vector(label: &str, space: &str) -> Result<Self, SearchArtifactError> {
let key = Self {
kind: SearchIndexKind::Vector,
label: normalize_selector("label", label)?,
properties: None,
space: Some(normalize_selector("space", space)?),
};
key.validate_total_size()?;
Ok(key)
}
#[must_use]
pub const fn kind(&self) -> SearchIndexKind {
self.kind
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn properties(&self) -> Option<&[String]> {
self.properties.as_deref()
}
#[must_use]
pub fn space(&self) -> Option<&str> {
self.space.as_deref()
}
#[must_use]
pub fn artifact_root(&self, project_dir: &Path) -> PathBuf {
match self.kind {
SearchIndexKind::Text => {
let mut path = project_dir.join("indexes").join("search").join("text");
push_encoded(&mut path, "label", self.label.as_bytes());
let properties = self
.properties
.as_deref()
.expect("text keys always carry properties");
push_encoded(&mut path, "properties", &encode_sequence(properties));
path
}
SearchIndexKind::Vector => {
let mut path = project_dir.join("embeddings");
push_encoded(
&mut path,
"space",
self.space
.as_deref()
.expect("vector keys always carry a space")
.as_bytes(),
);
push_encoded(&mut path, "label", self.label.as_bytes());
path
}
}
}
fn validate_total_size(&self) -> Result<(), SearchArtifactError> {
let property_bytes = self
.properties
.as_deref()
.unwrap_or_default()
.iter()
.map(String::len)
.sum::<usize>();
let bytes = self.label.len() + property_bytes + self.space.as_deref().map_or(0, str::len);
if bytes > MAX_SEARCH_ARTIFACT_KEY_BYTES {
return Err(invalid(
"artifact key",
format!("{bytes} normalized bytes exceeds {MAX_SEARCH_ARTIFACT_KEY_BYTES}"),
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchSourceSnapshot {
pub generation: u64,
pub fingerprint: String,
}
impl SearchSourceSnapshot {
pub fn capture(
project_dir: &Path,
parts: &[SearchSourcePart<'_>],
) -> Result<Self, SearchArtifactError> {
let generation =
crate::generation::read_search_generation(project_dir).map_err(|error| {
SearchArtifactError::SourceSnapshot {
reason: error.to_string(),
}
})?;
let fingerprint = canonical_source_fingerprint(parts)?;
Ok(Self {
generation,
fingerprint,
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct SearchSourcePart<'a> {
pub name: &'a str,
pub bytes: &'a [u8],
}
pub fn canonical_source_fingerprint(
parts: &[SearchSourcePart<'_>],
) -> Result<String, SearchArtifactError> {
let mut ordered = parts.to_vec();
ordered.sort_unstable_by(|left, right| left.name.as_bytes().cmp(right.name.as_bytes()));
for pair in ordered.windows(2) {
if pair[0].name == pair[1].name {
return Err(invalid(
"source fingerprint",
format!("duplicate source part {:?}", pair[0].name),
));
}
}
let mut states = [
0xcbf2_9ce4_8422_2325_u64,
0x8422_2325_cbf2_9ce4_u64,
0x9e37_79b9_7f4a_7c15_u64,
0xd6e8_feb8_6659_fd93_u64,
];
for part in ordered {
let name = normalize_source_name(part.name)?;
hash_frame(&mut states, name.as_bytes());
hash_frame(&mut states, part.bytes);
}
Ok(format!(
"gf-fnv1a256:{:016x}{:016x}{:016x}{:016x}",
states[0], states[1], states[2], states[3]
))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchManifest {
pub manifest_version: u32,
pub index_kind: SearchIndexKind,
pub backend_version: String,
pub label: String,
pub properties: Option<Vec<String>>,
pub space: Option<String>,
pub dimension: Option<u32>,
pub source_generation: u64,
pub source_fingerprint: String,
pub contract_version: String,
pub completed: bool,
}
impl SearchManifest {
pub fn for_key(
key: &SearchArtifactKey,
backend_version: &str,
contract_version: &str,
dimension: Option<u32>,
source: &SearchSourceSnapshot,
completed: bool,
) -> Result<Self, SearchArtifactError> {
let backend_version = normalize_version("backend_version", backend_version)?;
let contract_version = normalize_version("contract_version", contract_version)?;
if !canonical_fingerprint(&source.fingerprint) {
return Err(invalid(
"source_fingerprint",
"must be gf-fnv1a256 followed by 64 lowercase hexadecimal digits",
));
}
match (key.kind, dimension) {
(SearchIndexKind::Text, None) => {}
(SearchIndexKind::Vector, Some(value)) if value > 0 => {}
(SearchIndexKind::Text, Some(_)) => {
return Err(invalid("dimension", "text manifests omit dimension"));
}
(SearchIndexKind::Vector, _) => {
return Err(invalid(
"dimension",
"vector manifests require a non-zero dimension",
));
}
}
Ok(Self {
manifest_version: SEARCH_MANIFEST_VERSION,
index_kind: key.kind,
backend_version,
label: key.label.clone(),
properties: key.properties.clone(),
space: key.space.clone(),
dimension,
source_generation: source.generation,
source_fingerprint: source.fingerprint.clone(),
contract_version,
completed,
})
}
pub fn to_canonical_json(&self) -> Result<Vec<u8>, SearchArtifactError> {
let mut value = serde_json::Map::new();
value.insert(
"manifest_version".to_owned(),
serde_json::Value::from(self.manifest_version),
);
value.insert(
"index_kind".to_owned(),
serde_json::Value::from(self.index_kind.as_str()),
);
value.insert(
"backend_version".to_owned(),
serde_json::Value::from(self.backend_version.clone()),
);
value.insert(
"label".to_owned(),
serde_json::Value::from(self.label.clone()),
);
if let Some(properties) = &self.properties {
value.insert(
"properties".to_owned(),
serde_json::Value::Array(
properties
.iter()
.cloned()
.map(serde_json::Value::from)
.collect(),
),
);
}
if let Some(space) = &self.space {
value.insert("space".to_owned(), serde_json::Value::from(space.clone()));
}
if let Some(dimension) = self.dimension {
value.insert("dimension".to_owned(), serde_json::Value::from(dimension));
}
value.insert(
"source_generation".to_owned(),
serde_json::Value::from(self.source_generation),
);
value.insert(
"source_fingerprint".to_owned(),
serde_json::Value::from(self.source_fingerprint.clone()),
);
value.insert(
"contract_version".to_owned(),
serde_json::Value::from(self.contract_version.clone()),
);
value.insert(
"completed".to_owned(),
serde_json::Value::from(self.completed),
);
serde_json::to_vec(&serde_json::Value::Object(value)).map_err(|error| {
SearchArtifactError::CorruptManifest {
path: PathBuf::from("<memory>"),
reason: error.to_string(),
}
})
}
pub fn from_json(path: &Path, bytes: &[u8]) -> Result<Self, SearchArtifactError> {
if bytes.len() > MAX_SEARCH_MANIFEST_BYTES {
return Err(SearchArtifactError::ResourceExhausted {
resource: "manifest_bytes",
limit: MAX_SEARCH_MANIFEST_BYTES as u64,
});
}
let value: serde_json::Value =
serde_json::from_slice(bytes).map_err(|error| corrupt(path, error.to_string()))?;
let object = value
.as_object()
.ok_or_else(|| corrupt(path, "expected a JSON object"))?;
let found = required_u64(object, "manifest_version", path)?;
if found != u64::from(SEARCH_MANIFEST_VERSION) {
return Err(SearchArtifactError::IncompatibleManifest {
path: path.to_path_buf(),
found,
supported: SEARCH_MANIFEST_VERSION,
});
}
let kind = SearchIndexKind::parse(required_str(object, "index_kind", path)?, path)?;
let label = required_str(object, "label", path)?;
let properties = optional_strings(object, "properties", path)?;
let space = optional_str(object, "space", path)?;
let key = match kind {
SearchIndexKind::Text => SearchArtifactKey::text(
label,
properties
.as_deref()
.ok_or_else(|| corrupt(path, "text manifest omits properties"))?,
)
.map_err(|error| corrupt(path, error.to_string()))?,
SearchIndexKind::Vector => SearchArtifactKey::vector(
label,
space.ok_or_else(|| corrupt(path, "vector manifest omits space"))?,
)
.map_err(|error| corrupt(path, error.to_string()))?,
};
if key.label() != label || key.properties() != properties.as_deref() || key.space() != space
{
return Err(corrupt(
path,
"artifact selectors are not in canonical normalized order",
));
}
let backend_version = required_str(object, "backend_version", path)?;
let contract_version = required_str(object, "contract_version", path)?;
let dimension = optional_u64(object, "dimension", path)?
.map(|value| {
u32::try_from(value).map_err(|_| corrupt(path, "dimension exceeds u32 range"))
})
.transpose()?;
let source = SearchSourceSnapshot {
generation: required_u64(object, "source_generation", path)?,
fingerprint: required_str(object, "source_fingerprint", path)?.to_owned(),
};
let manifest = Self::for_key(
&key,
backend_version,
contract_version,
dimension,
&source,
required_bool(object, "completed", path)?,
)
.map_err(|error| corrupt(path, error.to_string()))?;
if manifest.backend_version != backend_version
|| manifest.contract_version != contract_version
{
return Err(corrupt(path, "version fields are not normalized"));
}
Ok(manifest)
}
pub fn verify_fresh(
&self,
key: &SearchArtifactKey,
backend_version: &str,
contract_version: &str,
dimension: Option<u32>,
source: &SearchSourceSnapshot,
) -> Result<(), SearchArtifactError> {
let expected = Self::for_key(
key,
backend_version,
contract_version,
dimension,
source,
true,
)?;
let reason = if !self.completed {
Some("manifest is incomplete".to_owned())
} else if self.index_kind != expected.index_kind
|| self.label != expected.label
|| self.properties != expected.properties
|| self.space != expected.space
{
Some("normalized artifact key changed".to_owned())
} else if self.backend_version != expected.backend_version {
Some("backend version changed".to_owned())
} else if self.contract_version != expected.contract_version {
Some("search contract version changed".to_owned())
} else if self.dimension != expected.dimension {
Some("vector dimension changed".to_owned())
} else if self.source_generation != expected.source_generation {
Some(format!(
"source generation changed from {} to {}",
self.source_generation, expected.source_generation
))
} else if self.source_fingerprint != expected.source_fingerprint {
Some("source fingerprint changed".to_owned())
} else {
None
};
match reason {
Some(reason) => Err(SearchArtifactError::Stale { reason }),
None => Ok(()),
}
}
}
fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
SearchArtifactError::InvalidSelector {
field,
reason: reason.into(),
}
}
fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
SearchArtifactError::CorruptManifest {
path: path.to_path_buf(),
reason: reason.into(),
}
}
fn normalize_selector(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
let value = value.trim();
if value.is_empty() {
return Err(invalid(field, "must not be empty"));
}
if value.chars().any(char::is_control) {
return Err(invalid(field, "must not contain control characters"));
}
if value.len() > MAX_SEARCH_SELECTOR_BYTES {
return Err(invalid(
field,
format!(
"{} UTF-8 bytes exceeds {MAX_SEARCH_SELECTOR_BYTES}",
value.len()
),
));
}
Ok(value.to_owned())
}
fn normalize_source_name(value: &str) -> Result<&str, SearchArtifactError> {
if value.is_empty() || value.chars().any(char::is_control) {
return Err(invalid(
"source fingerprint",
"logical part names must be non-empty and control-free",
));
}
Ok(value)
}
fn normalize_version(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
normalize_selector(field, value)
}
fn canonical_fingerprint(value: &str) -> bool {
value.strip_prefix("gf-fnv1a256:").is_some_and(|digest| {
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
})
}
fn encode_sequence(values: &[String]) -> Vec<u8> {
let mut bytes = Vec::new();
let value_count =
u32::try_from(values.len()).expect("validated search keys contain a bounded item count");
bytes.extend_from_slice(&value_count.to_be_bytes());
for value in values {
let value_len =
u32::try_from(value.len()).expect("validated search selectors have bounded lengths");
bytes.extend_from_slice(&value_len.to_be_bytes());
bytes.extend_from_slice(value.as_bytes());
}
bytes
}
fn push_encoded(path: &mut PathBuf, field: &str, bytes: &[u8]) {
path.push(field);
let encoded = hex(bytes);
for (index, chunk) in encoded.as_bytes().chunks(120).enumerate() {
let chunk = std::str::from_utf8(chunk).expect("hex is always UTF-8");
path.push(format!("{index:04}-{chunk}"));
}
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
encoded
}
fn hash_frame(states: &mut [u64; 4], bytes: &[u8]) {
for state in &mut *states {
for byte in (bytes.len() as u64).to_be_bytes().iter().chain(bytes) {
*state ^= u64::from(*byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
}
for (index, state) in states.iter_mut().enumerate() {
*state ^= (index as u64 + 1) * 0x9e37_79b9;
}
}
fn required_str<'a>(
object: &'a serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<&'a str, SearchArtifactError> {
object
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| corrupt(path, format!("missing or non-string {field}")))
}
fn optional_str<'a>(
object: &'a serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<Option<&'a str>, SearchArtifactError> {
object
.get(field)
.map(|value| {
value
.as_str()
.ok_or_else(|| corrupt(path, format!("{field} must be a string")))
})
.transpose()
}
fn required_u64(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<u64, SearchArtifactError> {
object
.get(field)
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| corrupt(path, format!("missing or non-u64 {field}")))
}
fn optional_u64(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<Option<u64>, SearchArtifactError> {
object
.get(field)
.map(|value| {
value
.as_u64()
.ok_or_else(|| corrupt(path, format!("{field} must be a u64")))
})
.transpose()
}
fn required_bool(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<bool, SearchArtifactError> {
object
.get(field)
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| corrupt(path, format!("missing or non-boolean {field}")))
}
fn optional_strings(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
path: &Path,
) -> Result<Option<Vec<String>>, SearchArtifactError> {
object
.get(field)
.map(|value| {
let values = value
.as_array()
.ok_or_else(|| corrupt(path, format!("{field} must be an array")))?;
values
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| corrupt(path, format!("{field} must contain strings")))
})
.collect()
})
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> SearchSourceSnapshot {
SearchSourceSnapshot {
generation: 7,
fingerprint: format!("gf-fnv1a256:{:064x}", 7),
}
}
#[test]
fn key_normalization_and_paths_are_stable_and_safe() {
let key = SearchArtifactKey::text(" Person ", [" bio ", "name", "bio"]).unwrap();
assert_eq!(key.label(), "Person");
assert_eq!(
key.properties().unwrap(),
&["bio".to_owned(), "name".to_owned()]
);
let path = key.artifact_root(Path::new("/project"));
let rendered = path.to_string_lossy();
assert!(rendered.starts_with("/project/indexes/search/text/label/"));
assert!(!rendered.contains("Person"));
assert!(!rendered.contains("bio"));
let vector = SearchArtifactKey::vector("Person", " model/v1 ").unwrap();
let rendered = vector
.artifact_root(Path::new("/project"))
.to_string_lossy()
.into_owned();
assert!(rendered.starts_with("/project/embeddings/space/"));
assert!(!rendered.contains("model/v1"));
}
#[test]
fn key_encoding_is_collision_free_for_framed_properties() {
let left = SearchArtifactKey::text("L", ["ab", "c"]).unwrap();
let right = SearchArtifactKey::text("L", ["a", "bc"]).unwrap();
assert_ne!(
left.artifact_root(Path::new("/p")),
right.artifact_root(Path::new("/p"))
);
}
#[test]
fn invalid_selectors_and_key_budget_are_structured() {
assert!(matches!(
SearchArtifactKey::vector("", "space"),
Err(SearchArtifactError::InvalidSelector { field: "label", .. })
));
assert!(matches!(
SearchArtifactKey::vector("Person", "bad\nspace"),
Err(SearchArtifactError::InvalidSelector { field: "space", .. })
));
let long = "x".repeat(MAX_SEARCH_SELECTOR_BYTES + 1);
assert!(SearchArtifactKey::text("Person", [&long]).is_err());
}
#[test]
fn fingerprint_is_order_independent_and_content_sensitive() {
let a = SearchSourcePart {
name: "properties/Person",
bytes: b"alice",
};
let b = SearchSourcePart {
name: "topology/nodes",
bytes: b"uuid",
};
assert_eq!(
canonical_source_fingerprint(&[a, b]).unwrap(),
canonical_source_fingerprint(&[b, a]).unwrap()
);
assert_ne!(
canonical_source_fingerprint(&[a]).unwrap(),
canonical_source_fingerprint(&[SearchSourcePart {
name: a.name,
bytes: b"bob",
}])
.unwrap()
);
assert!(canonical_source_fingerprint(&[a, a]).is_err());
}
#[test]
fn source_snapshot_rejects_corrupt_generation_and_noncanonical_fingerprint() {
let dir = tempfile::TempDir::new().unwrap();
let generation = crate::generation::generation_path(dir.path());
std::fs::create_dir_all(generation.parent().unwrap()).unwrap();
std::fs::write(&generation, b"corrupt").unwrap();
assert!(matches!(
SearchSourceSnapshot::capture(dir.path(), &[]),
Err(SearchArtifactError::SourceSnapshot { .. })
));
let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
let source = SearchSourceSnapshot {
generation: 1,
fingerprint: "not-canonical".to_owned(),
};
assert!(matches!(
SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source, true),
Err(SearchArtifactError::InvalidSelector {
field: "source_fingerprint",
..
})
));
}
#[test]
fn text_manifest_round_trips_canonical_json() {
let key = SearchArtifactKey::text("Person", ["name", "bio"]).unwrap();
let manifest = SearchManifest::for_key(
&key,
"tantivy-0.25",
"graphforge_text_v1",
None,
&source(),
true,
)
.unwrap();
let bytes = manifest.to_canonical_json().unwrap();
let parsed = SearchManifest::from_json(Path::new("manifest.json"), &bytes).unwrap();
assert_eq!(parsed, manifest);
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains(r#""properties":["bio","name"]"#));
assert!(!json.contains(r#""space""#));
assert!(!json.contains(r#""dimension""#));
let noncanonical = json.replace(r#""label":"Person""#, r#""label":" Person ""#);
assert!(matches!(
SearchManifest::from_json(Path::new("manifest.json"), noncanonical.as_bytes()),
Err(SearchArtifactError::CorruptManifest { .. })
));
}
#[test]
fn vector_manifest_requires_dimension_and_omits_properties() {
let key = SearchArtifactKey::vector("Person", "semantic").unwrap();
assert!(
SearchManifest::for_key(&key, "exact-cosine-v1", "vector-v1", None, &source(), true)
.is_err()
);
let manifest = SearchManifest::for_key(
&key,
"exact-cosine-v1",
"vector-v1",
Some(3),
&source(),
true,
)
.unwrap();
let json = String::from_utf8(manifest.to_canonical_json().unwrap()).unwrap();
assert!(json.contains(r#""dimension":3"#));
assert!(json.contains(r#""space":"semantic""#));
assert!(!json.contains(r#""properties""#));
assert!(matches!(
manifest.verify_fresh(&key, "exact-cosine-v1", "vector-v1", Some(4), &source(),),
Err(SearchArtifactError::Stale { .. })
));
}
#[test]
fn parsing_distinguishes_corrupt_incompatible_and_oversized() {
let path = Path::new("manifest.json");
assert!(matches!(
SearchManifest::from_json(path, b"no"),
Err(SearchArtifactError::CorruptManifest { .. })
));
assert!(matches!(
SearchManifest::from_json(path, br#"{"manifest_version":99}"#),
Err(SearchArtifactError::IncompatibleManifest { found: 99, .. })
));
assert!(matches!(
SearchManifest::from_json(path, &vec![b' '; MAX_SEARCH_MANIFEST_BYTES + 1]),
Err(SearchArtifactError::ResourceExhausted {
resource: "manifest_bytes",
..
})
));
}
#[test]
fn freshness_checks_completion_versions_generation_and_fingerprint() {
let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
let mut manifest =
SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source(), true)
.unwrap();
manifest
.verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source())
.unwrap();
manifest.completed = false;
assert!(matches!(
manifest.verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source()),
Err(SearchArtifactError::Stale { .. })
));
manifest.completed = true;
let changed = SearchSourceSnapshot {
generation: 8,
fingerprint: source().fingerprint,
};
assert!(
manifest
.verify_fresh(&key, "tantivy-0.25", "text-v1", None, &changed)
.is_err()
);
}
}