use crate::error::Error;
use crate::slug::{KbSlug, TenantSlug};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEmbedding {
pub model: String,
pub dimensions: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_url_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct KbManifest {
pub notedthat_version: String,
pub manifest_version: u32,
pub tenant_slug: TenantSlug,
pub kb_slug: KbSlug,
pub display_name: String,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qdrant_collection: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<ManifestEmbedding>,
}
impl KbManifest {
pub const CURRENT_VERSION: u32 = 1;
pub fn new_v1(tenant: &TenantSlug, kb: &KbSlug, display_name: &str, created_at: i64) -> Self {
Self {
notedthat_version: env!("CARGO_PKG_VERSION").to_string(),
manifest_version: Self::CURRENT_VERSION,
tenant_slug: tenant.clone(),
kb_slug: kb.clone(),
display_name: display_name.to_string(),
created_at,
qdrant_collection: None,
embedding: None,
}
}
pub fn validate(&self) -> Result<(), Error> {
if self.manifest_version != Self::CURRENT_VERSION {
return Err(Error::InvalidInput {
message: format!(
"unsupported manifest_version {}; expected {}",
self.manifest_version,
Self::CURRENT_VERSION
),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Kb {
pub slug: KbSlug,
pub display_name: String,
}
impl Kb {
pub const DISPLAY_NAME_MAX: usize = 128;
pub fn new(slug: KbSlug, display_name: impl Into<String>) -> Result<Self, Error> {
let display_name = display_name.into();
if display_name.is_empty() {
return Err(Error::InvalidInput {
message: "display_name must not be empty".into(),
});
}
if display_name.chars().count() > Self::DISPLAY_NAME_MAX {
return Err(Error::InvalidInput {
message: format!("display_name exceeds {} characters", Self::DISPLAY_NAME_MAX),
});
}
Ok(Self { slug, display_name })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ObjectMeta {
pub key: String,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slug::{KbSlug, TenantSlug};
#[test]
fn test_kb_manifest_serde_round_trip() {
let manifest = KbManifest {
notedthat_version: "0.1.0".to_string(),
manifest_version: 1,
tenant_slug: TenantSlug::try_new("default").unwrap(),
kb_slug: KbSlug::try_new("my-notes").unwrap(),
display_name: "My Notes".to_string(),
created_at: 1_700_000_000_i64,
qdrant_collection: None,
embedding: None,
};
let json = serde_json::to_string(&manifest).unwrap();
let restored: KbManifest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.manifest_version, manifest.manifest_version);
assert_eq!(restored.display_name, manifest.display_name);
assert_eq!(restored.notedthat_version, manifest.notedthat_version);
}
#[test]
fn test_kb_manifest_json_fixture_all_required_keys() {
let json = serde_json::json!({
"notedthat_version": "0.1.0",
"manifest_version": 1,
"tenant_slug": "default",
"kb_slug": "my-notes",
"display_name": "My Notes",
"created_at": 1_700_000_000_i64
});
let manifest: KbManifest = serde_json::from_value(json).unwrap();
assert_eq!(manifest.manifest_version, 1);
assert_eq!(manifest.display_name, "My Notes");
}
#[test]
fn test_kb_manifest_validate_wrong_version() {
let manifest = KbManifest {
notedthat_version: "0.1.0".to_string(),
manifest_version: 99,
tenant_slug: TenantSlug::try_new("default").unwrap(),
kb_slug: KbSlug::try_new("notes").unwrap(),
display_name: "Notes".to_string(),
created_at: 1_700_000_000_i64,
qdrant_collection: None,
embedding: None,
};
assert!(
manifest.validate().is_err(),
"manifest_version != 1 should fail validate()"
);
}
#[test]
fn test_kb_new_valid() {
let slug = KbSlug::try_new("my-kb").unwrap();
assert!(Kb::new(slug, "My KB").is_ok());
}
#[test]
fn test_kb_new_empty_display_name() {
let slug = KbSlug::try_new("my-kb").unwrap();
assert!(Kb::new(slug, "").is_err());
}
#[test]
fn test_kb_new_display_name_exactly_128_chars() {
let slug = KbSlug::try_new("my-kb").unwrap();
let name = "a".repeat(128);
assert!(Kb::new(slug, name).is_ok());
}
#[test]
fn test_kb_new_display_name_129_chars() {
let slug = KbSlug::try_new("my-kb").unwrap();
let name = "a".repeat(129);
assert!(Kb::new(slug, name).is_err());
}
#[test]
fn test_object_meta_serde_round_trip() {
let meta = ObjectMeta {
key: "foo/bar.md".to_string(),
size: 1024_u64,
last_modified: Some(1_700_000_000_i64),
content_type: Some("text/markdown".to_string()),
etag: None,
};
let json = serde_json::to_string(&meta).unwrap();
let restored: ObjectMeta = serde_json::from_str(&json).unwrap();
assert_eq!(restored.key, meta.key);
assert_eq!(restored.size, meta.size);
assert_eq!(restored.last_modified, meta.last_modified);
assert_eq!(restored.content_type, meta.content_type);
assert_eq!(restored.etag, meta.etag);
}
#[test]
fn test_object_meta_optional_fields_none() {
let meta = ObjectMeta {
key: "foo.md".to_string(),
size: 0_u64,
last_modified: None,
content_type: None,
etag: None,
};
let json = serde_json::to_string(&meta).unwrap();
let restored: ObjectMeta = serde_json::from_str(&json).unwrap();
assert!(restored.last_modified.is_none());
assert!(restored.content_type.is_none());
assert!(restored.etag.is_none());
}
#[test]
fn test_kb_manifest_backwards_compat_no_embedding() {
let json = serde_json::json!({
"notedthat_version": "0.1.0",
"manifest_version": 1,
"tenant_slug": "default",
"kb_slug": "my-notes",
"display_name": "My Notes",
"created_at": 1_700_000_000_i64
});
let manifest: KbManifest = serde_json::from_value(json).unwrap();
assert_eq!(manifest.manifest_version, 1);
assert!(
manifest.embedding.is_none(),
"embedding should default to None"
);
}
#[test]
fn test_kb_manifest_embedding_round_trip() {
let embedding = ManifestEmbedding {
model: "text-embedding-3-small".to_string(),
dimensions: 1536,
endpoint_url_hint: Some("https://api.openai.com/v1/embeddings".to_string()),
};
let manifest = KbManifest {
notedthat_version: "0.1.0".to_string(),
manifest_version: 1,
tenant_slug: TenantSlug::try_new("default").unwrap(),
kb_slug: KbSlug::try_new("my-notes").unwrap(),
display_name: "My Notes".to_string(),
created_at: 1_700_000_000_i64,
qdrant_collection: None,
embedding: Some(embedding.clone()),
};
let json = serde_json::to_string(&manifest).unwrap();
let restored: KbManifest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.embedding, Some(embedding));
}
#[test]
fn test_kb_manifest_embedding_endpoint_url_hint_omitted() {
let embedding = ManifestEmbedding {
model: "text-embedding-3-small".to_string(),
dimensions: 1536,
endpoint_url_hint: None,
};
let manifest = KbManifest {
notedthat_version: "0.1.0".to_string(),
manifest_version: 1,
tenant_slug: TenantSlug::try_new("default").unwrap(),
kb_slug: KbSlug::try_new("my-notes").unwrap(),
display_name: "My Notes".to_string(),
created_at: 1_700_000_000_i64,
qdrant_collection: None,
embedding: Some(embedding),
};
let json = serde_json::to_string(&manifest).unwrap();
assert!(
!json.contains("endpoint_url_hint"),
"endpoint_url_hint should not be serialized when None"
);
let restored: KbManifest = serde_json::from_str(&json).unwrap();
assert!(restored.embedding.is_some());
assert!(
restored
.embedding
.as_ref()
.unwrap()
.endpoint_url_hint
.is_none()
);
}
}