use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::db::types::*;
pub(crate) fn deserialize_nullable_field<'de, D, T>(
deserializer: D,
) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
let value: Option<T> = Option::deserialize(deserializer)?;
Ok(Some(value))
}
pub(crate) fn nullable_string_schema(
_generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), serde_json::json!(["string", "null"]));
map.insert(
"description".to_string(),
serde_json::json!("New memory type. Omit to leave unchanged, set to null to clear, or provide a string to replace."),
);
map.into()
}
pub(crate) fn is_false(b: &bool) -> bool {
!b
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct StoreInput {
pub content: String,
#[serde(default)]
pub projects: Vec<String>,
#[serde(rename = "type")]
pub memory_type: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub links: Vec<StoreLinkInput>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct StoreLinkInput {
pub target_id: String,
pub relation: String,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct UpdateInput {
pub id: String,
pub content: Option<String>,
#[serde(
rename = "type",
default,
deserialize_with = "deserialize_nullable_field"
)]
#[schemars(schema_with = "nullable_string_schema")]
pub memory_type: Option<Option<String>>,
pub projects: Option<Vec<String>>,
pub tags: Option<Vec<String>>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct ArchiveInput {
pub id: String,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct MergeInput {
pub source_ids: Vec<String>,
pub content: String,
#[serde(default)]
pub projects: Vec<String>,
#[serde(rename = "type")]
pub memory_type: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct LinkInput {
pub source_id: String,
pub target_id: String,
pub relation: String,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct UnlinkInput {
pub id: Option<String>,
pub source_id: Option<String>,
pub target_id: Option<String>,
pub relation: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct SearchInput {
pub query: String,
pub projects: Option<Vec<String>>,
#[serde(rename = "type")]
pub memory_type: Option<String>,
pub tags: Option<Vec<String>>,
pub include_global: Option<bool>,
pub include_archived: Option<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub created_max_age_days: Option<u32>,
pub created_min_age_days: Option<u32>,
pub updated_max_age_days: Option<u32>,
pub updated_min_age_days: Option<u32>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct GetInput {
pub ids: Vec<String>,
}
#[derive(Deserialize, JsonSchema)]
pub(crate) struct ListInput {
pub projects: Option<Vec<String>>,
#[serde(rename = "type")]
pub memory_type: Option<String>,
pub tags: Option<Vec<String>>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub created_max_age_days: Option<u32>,
pub created_min_age_days: Option<u32>,
pub updated_max_age_days: Option<u32>,
pub updated_min_age_days: Option<u32>,
pub include_global: Option<bool>,
pub include_archived: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct StoreResponse {
pub id: String,
pub similar: Vec<SimilarMemoryResponse>,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct SimilarMemoryResponse {
pub id: String,
pub content: String,
pub projects: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub memory_type: Option<String>,
pub tags: Vec<String>,
pub similarity: f64,
pub created_at: String,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
}
#[derive(Serialize)]
pub(crate) struct MergeResponse {
pub id: String,
pub archived: Vec<String>,
pub similar: Vec<SimilarMemoryResponse>,
}
#[derive(Serialize)]
pub(crate) struct UnlinkResponse {
pub removed: usize,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct SearchHitResponse {
pub id: String,
pub content: String,
pub projects: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub memory_type: Option<String>,
pub tags: Vec<String>,
pub links: LinksResponse,
pub score: f64,
pub created_at: String,
pub access_count: i64,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct LinksResponse {
pub outgoing: Vec<Link>,
pub incoming: Vec<Link>,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct MemoryFullResponse {
pub id: String,
pub content: String,
pub projects: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub memory_type: Option<String>,
pub tags: Vec<String>,
pub links: LinksResponse,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
pub access_count: i64,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct ListResponse {
pub memories: Vec<MemoryResponseSerde>,
pub total: i64,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct MemoryResponseSerde {
pub id: String,
pub content: String,
pub projects: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub memory_type: Option<String>,
pub tags: Vec<String>,
pub created_at: String,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
}
impl From<MemoryWithLinks> for MemoryFullResponse {
fn from(mwl: MemoryWithLinks) -> Self {
Self {
id: mwl.memory.id,
content: mwl.memory.content,
projects: mwl.memory.projects,
memory_type: mwl.memory.memory_type,
tags: mwl.memory.tags,
links: LinksResponse {
outgoing: mwl.outgoing_links,
incoming: mwl.incoming_links,
},
created_at: mwl.memory.created_at,
updated_at: mwl.memory.updated_at,
archived_at: mwl.memory.archived_at,
access_count: mwl.memory.access_count,
}
}
}
impl From<Memory> for MemoryResponseSerde {
fn from(mem: Memory) -> Self {
Self {
id: mem.id,
content: mem.content,
projects: mem.projects,
memory_type: mem.memory_type,
tags: mem.tags,
created_at: mem.created_at,
truncated: mem.truncated,
}
}
}
impl From<SearchHit> for SearchHitResponse {
fn from(hit: SearchHit) -> Self {
Self {
id: hit.memory.id,
content: hit.memory.content,
projects: hit.memory.projects,
memory_type: hit.memory.memory_type,
tags: hit.memory.tags,
links: LinksResponse {
outgoing: hit.outgoing_links,
incoming: hit.incoming_links,
},
score: hit.score,
created_at: hit.memory.created_at,
access_count: hit.memory.access_count,
truncated: hit.memory.truncated,
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct ContextInput {
pub queries: Vec<String>,
pub projects: Option<Vec<String>>,
#[serde(rename = "type")]
pub memory_type: Option<String>,
pub tags: Option<Vec<String>>,
pub include_global: Option<bool>,
pub include_taxonomy: Option<bool>,
pub content_budget: Option<u32>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ContextHit {
pub id: String,
pub content: String,
pub projects: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub memory_type: Option<String>,
pub tags: Vec<String>,
pub score: f64,
pub query_index: usize,
pub created_at: String,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ContextResponse {
pub memories: Vec<ContextHit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taxonomy: Option<DiscoverResult>,
pub truncated: bool,
}
impl From<crate::service::StoredMemory> for StoreResponse {
fn from(sm: crate::service::StoredMemory) -> Self {
Self {
id: sm.id,
similar: sm
.similar
.into_iter()
.map(|(mem, score)| SimilarMemoryResponse {
id: mem.id,
content: mem.content,
projects: mem.projects,
memory_type: mem.memory_type,
tags: mem.tags,
similarity: score,
created_at: mem.created_at,
truncated: mem.truncated,
})
.collect(),
}
}
}
impl From<crate::service::MergedMemory> for MergeResponse {
fn from(mm: crate::service::MergedMemory) -> Self {
Self {
id: mm.id,
archived: mm.archived,
similar: mm
.similar
.into_iter()
.map(|(mem, score)| SimilarMemoryResponse {
id: mem.id,
content: mem.content,
projects: mem.projects,
memory_type: mem.memory_type,
tags: mem.tags,
similarity: score,
created_at: mem.created_at,
truncated: mem.truncated,
})
.collect(),
}
}
}
impl From<crate::service::ContextResult> for ContextResponse {
fn from(cr: crate::service::ContextResult) -> Self {
Self {
memories: cr
.hits
.into_iter()
.map(|hit| ContextHit {
id: hit.memory.id,
content: hit.memory.content,
projects: hit.memory.projects,
memory_type: hit.memory.memory_type,
tags: hit.memory.tags,
score: hit.score,
query_index: hit.query_index,
created_at: hit.memory.created_at,
truncated: hit.memory.truncated,
})
.collect(),
taxonomy: cr.taxonomy,
truncated: cr.truncated,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::{ContextHitInner, ContextResult, MergedMemory, StoredMemory};
fn sample_memory(id: &str, content: &str) -> Memory {
Memory {
id: id.to_string(),
content: content.to_string(),
memory_type: Some("pattern".to_string()),
projects: vec!["proj-a".to_string()],
tags: vec!["rust".to_string()],
created_at: "2025-06-15T00:00:00.000Z".to_string(),
updated_at: "2025-06-15T00:00:00.000Z".to_string(),
archived_at: None,
last_accessed_at: None,
access_count: 0,
truncated: false,
}
}
#[test]
fn store_input_converts_to_store_request() {
use crate::service::StoreRequest;
let input = StoreInput {
content: "some content".to_string(),
projects: vec!["proj-a".to_string()],
memory_type: Some("pattern".to_string()),
tags: vec!["rust".to_string()],
links: vec![
StoreLinkInput {
target_id: "target-1".to_string(),
relation: "related_to".to_string(),
},
StoreLinkInput {
target_id: "target-2".to_string(),
relation: "caused_by".to_string(),
},
],
};
let req = StoreRequest::from(input);
assert_eq!(req.content, "some content");
assert_eq!(req.projects, vec!["proj-a"]);
assert_eq!(req.memory_type, Some("pattern".to_string()));
assert_eq!(req.tags, vec!["rust"]);
assert_eq!(
req.links,
vec![
("target-1".to_string(), "related_to".to_string()),
("target-2".to_string(), "caused_by".to_string()),
]
);
}
#[test]
fn stored_memory_converts_to_store_response() {
let sm = StoredMemory {
id: "new-id".to_string(),
similar: vec![
(sample_memory("sim-1", "similar content 1"), 0.92),
(sample_memory("sim-2", "similar content 2"), 0.85),
],
};
let resp = StoreResponse::from(sm);
assert_eq!(resp.id, "new-id");
assert_eq!(resp.similar.len(), 2);
assert_eq!(resp.similar[0].id, "sim-1");
assert_eq!(resp.similar[0].content, "similar content 1");
assert_eq!(resp.similar[0].similarity, 0.92);
assert_eq!(resp.similar[0].projects, vec!["proj-a"]);
assert_eq!(resp.similar[0].memory_type, Some("pattern".to_string()));
assert_eq!(resp.similar[0].tags, vec!["rust"]);
assert_eq!(resp.similar[0].created_at, "2025-06-15T00:00:00.000Z");
assert!(!resp.similar[0].truncated);
}
#[test]
fn stored_memory_with_truncated_similar() {
let mut mem = sample_memory("sim-1", "truncated");
mem.truncated = true;
let sm = StoredMemory {
id: "new-id".to_string(),
similar: vec![(mem, 0.90)],
};
let resp = StoreResponse::from(sm);
assert!(resp.similar[0].truncated);
}
#[test]
fn update_input_converts_to_update_request() {
use crate::db::types::FieldUpdate;
use crate::service::UpdateRequest;
let input = UpdateInput {
id: "mem-1".to_string(),
content: Some("new content".to_string()),
memory_type: Some(Some("decision".to_string())),
projects: Some(vec!["proj-b".to_string()]),
tags: Some(vec!["go".to_string()]),
};
let req = UpdateRequest::from(input);
assert_eq!(req.id, "mem-1");
assert_eq!(req.content, Some("new content".to_string()));
assert_eq!(req.memory_type, FieldUpdate::Set("decision".to_string()));
assert_eq!(req.projects, Some(vec!["proj-b".to_string()]));
assert_eq!(req.tags, Some(vec!["go".to_string()]));
}
#[test]
fn update_input_none_type_is_no_change() {
use crate::db::types::FieldUpdate;
use crate::service::UpdateRequest;
let input = UpdateInput {
id: "mem-1".to_string(),
content: None,
memory_type: None, projects: None,
tags: None,
};
let req = UpdateRequest::from(input);
assert_eq!(req.memory_type, FieldUpdate::NoChange);
}
#[test]
fn update_input_some_none_type_is_clear() {
use crate::db::types::FieldUpdate;
use crate::service::UpdateRequest;
let input = UpdateInput {
id: "mem-1".to_string(),
content: None,
memory_type: Some(None), projects: None,
tags: None,
};
let req = UpdateRequest::from(input);
assert_eq!(req.memory_type, FieldUpdate::Clear);
}
#[test]
fn merge_input_converts_to_merge_request() {
use crate::service::MergeRequest;
let input = MergeInput {
source_ids: vec!["id-1".to_string(), "id-2".to_string()],
content: "merged content".to_string(),
projects: vec!["proj-a".to_string()],
memory_type: Some("decision".to_string()),
tags: vec!["rust".to_string()],
};
let req = MergeRequest::from(input);
assert_eq!(req.source_ids, vec!["id-1", "id-2"]);
assert_eq!(req.content, "merged content");
assert_eq!(req.projects, vec!["proj-a"]);
assert_eq!(req.memory_type, Some("decision".to_string()));
assert_eq!(req.tags, vec!["rust"]);
}
#[test]
fn merged_memory_converts_to_merge_response() {
let mm = MergedMemory {
id: "merged-id".to_string(),
archived: vec!["src-1".to_string(), "src-2".to_string()],
similar: vec![(sample_memory("sim-1", "similar"), 0.88)],
};
let resp = MergeResponse::from(mm);
assert_eq!(resp.id, "merged-id");
assert_eq!(resp.archived, vec!["src-1", "src-2"]);
assert_eq!(resp.similar.len(), 1);
assert_eq!(resp.similar[0].id, "sim-1");
assert_eq!(resp.similar[0].similarity, 0.88);
}
#[test]
fn context_input_converts_to_context_request() {
use crate::service::ContextRequest;
let input = ContextInput {
queries: vec!["q1".to_string(), "q2".to_string()],
projects: Some(vec!["proj-a".to_string()]),
memory_type: Some("pattern".to_string()),
tags: Some(vec!["rust".to_string()]),
include_global: Some(false),
include_taxonomy: Some(true),
content_budget: Some(5000),
limit: Some(20),
};
let req = ContextRequest::from(input);
assert_eq!(req.queries, vec!["q1", "q2"]);
assert_eq!(req.projects, Some(vec!["proj-a".to_string()]));
assert_eq!(req.memory_type, Some("pattern".to_string()));
assert_eq!(req.tags, Some(vec!["rust".to_string()]));
assert!(!req.include_global);
assert!(req.include_taxonomy);
assert_eq!(req.content_budget, 5000);
assert_eq!(req.limit, 20);
}
#[test]
fn context_input_defaults() {
use crate::service::ContextRequest;
let input = ContextInput {
queries: vec!["q1".to_string()],
projects: None,
memory_type: None,
tags: None,
include_global: None, include_taxonomy: None, content_budget: None, limit: None, };
let req = ContextRequest::from(input);
assert!(req.include_global);
assert!(!req.include_taxonomy);
assert_eq!(req.content_budget, 2000);
assert_eq!(req.limit, 10);
}
#[test]
fn context_result_converts_to_context_response() {
let cr = ContextResult {
hits: vec![ContextHitInner {
memory: sample_memory("hit-1", "context content"),
score: 0.95,
query_index: 0,
}],
taxonomy: None,
truncated: true,
};
let resp = ContextResponse::from(cr);
assert_eq!(resp.memories.len(), 1);
assert_eq!(resp.memories[0].id, "hit-1");
assert_eq!(resp.memories[0].content, "context content");
assert_eq!(resp.memories[0].score, 0.95);
assert_eq!(resp.memories[0].query_index, 0);
assert!(resp.truncated);
assert!(resp.taxonomy.is_none());
}
}