use crate::models::MemoryKind;
use crate::models::field_names;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const RECALL_MODE_HYBRID_RERANK: &str = "hybrid+rerank";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[allow(dead_code)]
#[serde(untagged)]
pub enum KindsFilter {
Array(Vec<String>),
Csv(String),
}
impl KindsFilter {
#[must_use]
pub fn parse(&self) -> Option<Vec<MemoryKind>> {
match self {
Self::Csv(s) => {
if s.trim().eq_ignore_ascii_case("all") {
return None;
}
MemoryKind::parse_csv(s)
}
Self::Array(arr) => {
if arr.is_empty() {
return None;
}
let mut out: Vec<MemoryKind> = Vec::new();
for raw in arr {
if let Some(k) = MemoryKind::from_str(raw.trim())
&& !out.contains(&k)
{
out.push(k);
}
}
Some(out)
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct RecallRequest {
pub context: String,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
#[schemars(description = "#151 scope-visibility agent.")]
pub as_agent: Option<String>,
#[serde(default)]
pub budget_tokens: Option<i64>,
#[serde(default)]
pub context_tokens: Option<Vec<String>>,
#[serde(default)]
pub session_default: Option<bool>,
#[serde(default)]
#[schemars(description = "#518 session id; +0.05 rerank boost for in-session ring (cap 50).")]
pub session_id: Option<String>,
#[serde(default)]
pub include_archived: Option<bool>,
#[serde(default)]
pub has_citations: Option<bool>,
#[serde(default)]
pub source_uri_prefix: Option<String>,
#[serde(default)]
pub kinds: Option<KindsFilter>,
#[serde(default)]
pub confidence_tier: Option<String>,
#[serde(default)]
pub verbose_provenance: Option<bool>,
#[serde(default)]
pub format: Option<String>,
}
impl RecallRequest {
pub fn from_mcp_params(params: &Value) -> Result<Self, String> {
if params.get("context").and_then(Value::as_str).is_none() {
return Err(crate::errors::msg::CONTEXT_REQUIRED.to_string());
}
let mut owned = params.clone();
if let Some(obj) = owned.as_object_mut() {
for key in ["limit", field_names::BUDGET_TOKENS] {
if let Some(v) = obj.get(key)
&& let Some(n) = v.as_u64()
&& n > i64::MAX as u64
{
obj.insert(key.to_string(), Value::from(i64::MAX));
}
}
}
serde_json::from_value::<Self>(owned).map_err(|e| e.to_string())
}
#[must_use]
pub fn from_http_query(q: &crate::models::RecallQuery) -> Self {
let context = q
.context
.as_deref()
.or(q.query.as_deref())
.or(q.q.as_deref())
.unwrap_or("")
.to_string();
Self {
context,
namespace: q.namespace.clone(),
limit: q.limit.and_then(|v| i64::try_from(v).ok()),
tags: q.tags.clone(),
since: q.since.clone(),
until: q.until.clone(),
as_agent: q.as_agent.clone(),
budget_tokens: q.budget_tokens.and_then(|v| i64::try_from(v).ok()),
context_tokens: q.context_tokens.as_deref().map(|s| {
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
.collect()
}),
session_default: q.session_default,
session_id: q.session_id.clone(),
include_archived: q.include_archived,
has_citations: q.has_citations,
source_uri_prefix: q.source_uri_prefix.clone(),
kinds: q.kinds.as_deref().map(|s| KindsFilter::Csv(s.to_string())),
confidence_tier: q.confidence_tier.clone(),
verbose_provenance: q.verbose_provenance,
format: q.format.clone(),
}
}
#[must_use]
pub fn from_http_body(body: &crate::models::RecallBody) -> Self {
let kinds = body.kinds.as_ref().and_then(|raw| {
if let Some(s) = raw.as_str() {
Some(KindsFilter::Csv(s.to_string()))
} else if let Some(arr) = raw.as_array() {
let strs: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
Some(KindsFilter::Array(strs))
} else {
None
}
});
Self {
context: body.resolved_query(),
namespace: body.namespace.clone(),
limit: body.limit.and_then(|v| i64::try_from(v).ok()),
tags: body.tags.clone(),
since: body.since.clone(),
until: body.until.clone(),
as_agent: body.as_agent.clone(),
budget_tokens: body.budget_tokens.and_then(|v| i64::try_from(v).ok()),
context_tokens: body.context_tokens.clone(),
session_default: body.session_default,
session_id: body.session_id.clone(),
include_archived: body.include_archived,
has_citations: body.has_citations,
source_uri_prefix: body.source_uri_prefix.clone(),
kinds,
confidence_tier: body.confidence_tier.clone(),
verbose_provenance: body.verbose_provenance,
format: body.format.clone(),
}
}
#[must_use]
pub fn from_cli_args(args: &crate::cli::recall::RecallArgs) -> Self {
Self {
context: args.context.clone(),
namespace: args.namespace.clone(),
limit: i64::try_from(args.limit).ok(),
tags: args.tags.clone(),
since: args.since.clone(),
until: args.until.clone(),
as_agent: args.as_agent.clone(),
budget_tokens: args.budget_tokens.and_then(|v| i64::try_from(v).ok()),
context_tokens: args.context_tokens.clone(),
session_default: Some(args.session_default),
session_id: args.session_id.clone(),
include_archived: Some(args.include_archived),
has_citations: Some(args.has_citations),
source_uri_prefix: args.source_uri_prefix.clone(),
kinds: args
.kind
.as_deref()
.map(|s| KindsFilter::Csv(s.to_string())),
confidence_tier: args.confidence_tier.clone(),
verbose_provenance: Some(args.verbose_provenance),
format: Some(args.format.clone()),
}
}
#[must_use]
pub fn resolved_limit(&self) -> usize {
match self.limit {
Some(v) if v > 0 => usize::try_from(v).unwrap_or(usize::MAX),
_ => 10,
}
}
#[must_use]
pub fn resolved_budget_tokens(&self) -> Option<usize> {
self.budget_tokens.and_then(|v| {
if v < 0 {
None
} else {
Some(usize::try_from(v).unwrap_or(usize::MAX))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn from_mcp_params_requires_context() {
let err = RecallRequest::from_mcp_params(&json!({})).unwrap_err();
assert!(
err.contains("context"),
"missing context must surface 'context' in the error: {err}"
);
}
#[test]
fn from_mcp_params_happy_path_minimal() {
let req = RecallRequest::from_mcp_params(&json!({"context": "hello"})).unwrap();
assert_eq!(req.context, "hello");
assert!(req.namespace.is_none());
assert!(req.limit.is_none());
}
#[test]
fn from_mcp_params_full_field_set() {
let req = RecallRequest::from_mcp_params(&json!({
"context": "q",
"namespace": "ns",
"limit": 25,
"tags": "a,b",
"since": "2026-01-01T00:00:00Z",
"until": "2026-12-31T00:00:00Z",
"as_agent": "ai:viewer",
"budget_tokens": 100,
"context_tokens": ["alpha", "beta"],
"session_default": true,
"session_id": "sess-1",
"include_archived": true,
"has_citations": true,
"source_uri_prefix": "doc:",
"kinds": "concept,claim",
"confidence_tier": "confirmed",
"verbose_provenance": false,
"format": "toon_compact"
}))
.unwrap();
assert_eq!(req.context, "q");
assert_eq!(req.namespace.as_deref(), Some("ns"));
assert_eq!(req.limit, Some(25));
assert_eq!(req.tags.as_deref(), Some("a,b"));
assert_eq!(req.budget_tokens, Some(100));
assert_eq!(
req.context_tokens.as_deref(),
Some(&["alpha".to_string(), "beta".to_string()][..])
);
assert_eq!(req.session_id.as_deref(), Some("sess-1"));
assert!(matches!(req.kinds, Some(KindsFilter::Csv(ref s)) if s == "concept,claim"));
assert_eq!(req.confidence_tier.as_deref(), Some("confirmed"));
assert_eq!(req.verbose_provenance, Some(false));
}
#[test]
fn from_mcp_params_limit_u64_max_saturates() {
let req = RecallRequest::from_mcp_params(&json!({
"context": "q",
"limit": u64::MAX,
}))
.expect("u64::MAX limit must saturate, not error");
assert_eq!(req.limit, Some(i64::MAX));
}
#[test]
fn from_mcp_params_budget_tokens_u64_max_saturates() {
let req = RecallRequest::from_mcp_params(&json!({
"context": "q",
"budget_tokens": u64::MAX,
}))
.expect("u64::MAX budget_tokens must saturate, not error");
assert_eq!(req.budget_tokens, Some(i64::MAX));
}
#[test]
fn from_mcp_params_unknown_field_tolerated_at_runtime() {
let req = RecallRequest::from_mcp_params(&json!({
"context": "q",
"completely_unknown_field": true
}))
.expect("unknown fields are tolerated at runtime (post-#1052 contract is wire-truthful)");
assert_eq!(req.context, "q");
}
#[test]
fn from_mcp_params_kinds_array_shape() {
let req = RecallRequest::from_mcp_params(&json!({
"context": "q",
"kinds": ["concept", "claim"]
}))
.unwrap();
let kinds = req.kinds.expect("kinds present");
match &kinds {
KindsFilter::Array(v) => {
assert_eq!(v, &vec!["concept".to_string(), "claim".to_string()]);
}
_ => panic!("expected Array variant: {kinds:?}"),
}
let parsed = kinds.parse().expect("parses to Some");
assert_eq!(parsed.len(), 2);
}
#[test]
fn kinds_filter_all_treated_as_no_filter() {
let csv = KindsFilter::Csv("all".to_string());
assert!(csv.parse().is_none());
let csv_upper = KindsFilter::Csv("ALL".to_string());
assert!(csv_upper.parse().is_none());
}
#[test]
fn kinds_filter_empty_array_is_no_filter() {
let arr = KindsFilter::Array(vec![]);
assert!(arr.parse().is_none());
}
#[test]
fn kinds_filter_typo_array_returns_empty_some_cor4() {
let arr = KindsFilter::Array(vec!["reflektion".to_string()]);
let parsed = arr.parse().expect("declared filter returns Some");
assert!(parsed.is_empty(), "typo'd kinds must return empty Some");
}
#[test]
fn resolved_limit_default_is_ten() {
let req = RecallRequest {
context: "q".to_string(),
..Default::default()
};
assert_eq!(req.resolved_limit(), 10);
}
#[test]
fn resolved_limit_uses_explicit_value() {
let req = RecallRequest {
context: "q".to_string(),
limit: Some(25),
..Default::default()
};
assert_eq!(req.resolved_limit(), 25);
}
#[test]
fn resolved_budget_tokens_zero_preserved() {
let req = RecallRequest {
context: "q".to_string(),
budget_tokens: Some(0),
..Default::default()
};
assert_eq!(req.resolved_budget_tokens(), Some(0));
}
#[test]
fn resolved_budget_tokens_none_when_negative() {
let req = RecallRequest {
context: "q".to_string(),
budget_tokens: Some(-1),
..Default::default()
};
assert!(req.resolved_budget_tokens().is_none());
}
#[test]
fn from_cli_args_round_trips_all_fields() {
let cli_args = crate::cli::recall::RecallArgs {
context: "hello".to_string(),
namespace: Some("ns".to_string()),
limit: 7,
tags: Some("rust".to_string()),
since: Some("2026-01-01T00:00:00Z".to_string()),
until: Some("2026-12-31T00:00:00Z".to_string()),
tier: Some("keyword".to_string()),
as_agent: Some("ai:viewer".to_string()),
budget_tokens: Some(50),
context_tokens: Some(vec!["alpha".to_string()]),
session_default: true,
include_archived: true,
has_citations: true,
source_uri_prefix: Some("doc:".to_string()),
kind: Some("concept,claim".to_string()),
confidence_tier: Some("high".to_string()),
verbose_provenance: true,
format: "toon".to_string(),
session_id: Some("sess-1".to_string()),
};
let req = RecallRequest::from_cli_args(&cli_args);
assert_eq!(req.context, "hello");
assert_eq!(req.namespace.as_deref(), Some("ns"));
assert_eq!(req.limit, Some(7));
assert_eq!(req.tags.as_deref(), Some("rust"));
assert_eq!(req.budget_tokens, Some(50));
assert_eq!(req.session_default, Some(true));
assert_eq!(req.include_archived, Some(true));
assert_eq!(req.has_citations, Some(true));
assert_eq!(req.source_uri_prefix.as_deref(), Some("doc:"));
assert!(matches!(req.kinds, Some(KindsFilter::Csv(ref s)) if s == "concept,claim"));
assert_eq!(
req.confidence_tier.as_deref(),
Some("high"),
"#1098: --confidence-tier marshals into DTO.confidence_tier"
);
assert_eq!(
req.verbose_provenance,
Some(true),
"#1098: --verbose-provenance marshals into DTO.verbose_provenance"
);
assert_eq!(
req.format.as_deref(),
Some("toon"),
"#1098: --format marshals into DTO.format"
);
assert_eq!(
req.session_id.as_deref(),
Some("sess-1"),
"#1257: --session-id marshals into DTO.session_id"
);
}
#[test]
fn from_http_query_minimal() {
let q = crate::models::RecallQuery {
context: Some("hello".to_string()),
query: None,
q: None,
namespace: None,
limit: Some(15),
tags: None,
since: None,
until: None,
as_agent: None,
budget_tokens: None,
context_tokens: None,
session_default: None,
has_citations: None,
source_uri_prefix: None,
kinds: None,
session_id: None,
include_archived: None,
confidence_tier: None,
verbose_provenance: None,
format: None,
};
let req = RecallRequest::from_http_query(&q);
assert_eq!(req.context, "hello");
assert_eq!(req.limit, Some(15));
}
#[test]
fn from_http_query_aliases() {
let q = crate::models::RecallQuery {
context: None,
query: None,
q: Some("via-q".to_string()),
namespace: None,
limit: None,
tags: None,
since: None,
until: None,
as_agent: None,
budget_tokens: None,
context_tokens: None,
session_default: None,
has_citations: None,
source_uri_prefix: None,
kinds: None,
session_id: None,
include_archived: None,
confidence_tier: None,
verbose_provenance: None,
format: None,
};
let req = RecallRequest::from_http_query(&q);
assert_eq!(req.context, "via-q");
}
#[test]
fn round_trip_serialize_deserialize() {
let req = RecallRequest {
context: "q".to_string(),
namespace: Some("ns".to_string()),
limit: Some(5),
kinds: Some(KindsFilter::Csv("concept".to_string())),
..Default::default()
};
let json = serde_json::to_string(&req).unwrap();
let back: RecallRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.context, req.context);
assert_eq!(back.namespace, req.namespace);
assert_eq!(back.limit, req.limit);
}
#[test]
fn from_http_body_wires_context_tokens_1622() {
let body: crate::models::RecallBody =
serde_json::from_str(r#"{"context":"x","context_tokens":["alpha","beta"]}"#).unwrap();
let req = RecallRequest::from_http_body(&body);
assert_eq!(
req.context_tokens.as_deref(),
Some(&["alpha".to_string(), "beta".to_string()][..]),
"#1622: POST body context_tokens must reach the DTO"
);
}
#[test]
fn from_http_query_parses_csv_context_tokens_1622() {
let mut q = crate::models::RecallQuery {
context: Some("x".to_string()),
query: None,
q: None,
namespace: None,
limit: None,
tags: None,
since: None,
until: None,
as_agent: None,
budget_tokens: None,
context_tokens: Some(" alpha, beta ,,".to_string()),
session_default: None,
has_citations: None,
source_uri_prefix: None,
kinds: None,
session_id: None,
include_archived: None,
confidence_tier: None,
verbose_provenance: None,
format: None,
};
let req = RecallRequest::from_http_query(&q);
assert_eq!(
req.context_tokens.as_deref(),
Some(&["alpha".to_string(), "beta".to_string()][..]),
"#1622: CSV parses with trim + empty-segment drop"
);
q.context_tokens = None;
let req2 = RecallRequest::from_http_query(&q);
assert!(req2.context_tokens.is_none());
}
}