Skip to main content

locus_mcp/
shared.rs

1use chrono::{DateTime, Utc};
2use locus_core_rs::EmbeddingMigrationMode;
3use serde_json::{Value, json};
4
5pub(crate) fn parse_utc_required(value: &str, field: &str) -> Result<DateTime<Utc>, String> {
6    DateTime::parse_from_rfc3339(value)
7        .map(|parsed| parsed.with_timezone(&Utc))
8        .map_err(|_| format!("{field} must be an ISO8601 UTC datetime"))
9}
10
11pub(crate) fn parse_utc_optional(
12    value: Option<&str>,
13    field: &str,
14) -> Result<Option<DateTime<Utc>>, String> {
15    match value {
16        Some(raw) => parse_utc_required(raw, field).map(Some),
17        None => Ok(None),
18    }
19}
20
21pub(crate) fn validate_limit(limit: usize, field: &str) -> Result<usize, String> {
22    if (1..=200).contains(&limit) {
23        Ok(limit)
24    } else {
25        Err(format!("{field} must be between 1 and 200"))
26    }
27}
28
29pub(crate) fn validate_batch_size(batch_size: usize) -> Result<usize, String> {
30    if (1..=500).contains(&batch_size) {
31        Ok(batch_size)
32    } else {
33        Err("batch_size must be between 1 and 500".to_string())
34    }
35}
36
37pub(crate) fn validate_max_nodes(max_nodes: usize) -> Result<usize, String> {
38    if (1..=50000).contains(&max_nodes) {
39        Ok(max_nodes)
40    } else {
41        Err("max_nodes must be between 1 and 50000".to_string())
42    }
43}
44
45pub(crate) fn parse_migration_mode(value: Option<&str>) -> Result<EmbeddingMigrationMode, String> {
46    match value
47        .unwrap_or("missing_only")
48        .trim()
49        .to_ascii_lowercase()
50        .as_str()
51    {
52        "missing_only" => Ok(EmbeddingMigrationMode::MissingOnly),
53        "reindex_all" => Ok(EmbeddingMigrationMode::ReindexAll),
54        _ => Err("mode must be one of: missing_only, reindex_all, tags, both".to_string()),
55    }
56}
57
58pub(crate) fn mode_to_string(mode: EmbeddingMigrationMode) -> &'static str {
59    match mode {
60        EmbeddingMigrationMode::MissingOnly => "missing_only",
61        EmbeddingMigrationMode::ReindexAll => "reindex_all",
62    }
63}
64
65pub(crate) fn expanded_limit(limit: usize) -> usize {
66    limit.saturating_mul(5).clamp(1, 200)
67}
68
69pub(crate) fn normalize_tiers(tiers: &[String]) -> Vec<String> {
70    tiers
71        .iter()
72        .map(|value| value.trim().to_ascii_lowercase())
73        .filter(|value| !value.is_empty())
74        .collect::<Vec<_>>()
75}
76
77pub(crate) fn normalize_context_keywords(keywords: Option<&[String]>) -> Vec<String> {
78    keywords
79        .unwrap_or(&[])
80        .iter()
81        .map(|value| value.trim().to_ascii_lowercase())
82        .filter(|value| !value.is_empty())
83        .collect::<Vec<_>>()
84}
85
86pub(crate) fn filter_nodes_by_context_keywords(
87    nodes: &[locus_core_rs::SttpNode],
88    keywords: &[String],
89    limit: usize,
90) -> Vec<locus_core_rs::SttpNode> {
91    use locus_sdk::application::memory_lexical::{
92        LexicalFields, legacy_phrase_filter, parse_lexical_query, select_lexical_matches,
93    };
94    use locus_sdk::domain::memory::StrictnessMode;
95
96    let query = parse_lexical_query(&keywords.join(" "));
97    let ranked = if query.term_count() == 0 {
98        legacy_phrase_filter(nodes.to_vec(), &keywords.join(" "))
99    } else {
100        select_lexical_matches(
101            nodes.to_vec(),
102            &query,
103            StrictnessMode::Recall,
104            LexicalFields::INVENTORY,
105        )
106    };
107
108    ranked.into_iter().take(limit).collect()
109}
110
111pub(crate) fn to_json_string(value: Value) -> String {
112    match serde_json::to_string(&value) {
113        Ok(serialized) => serialized,
114        Err(err) => tool_error("SerializationFailure", &err.to_string()),
115    }
116}
117
118pub(crate) fn tool_error(code: &str, message: &str) -> String {
119    to_json_string(json!({
120        "error": {
121            "code": code,
122            "message": message,
123            "model_guidance": schema_first_guidance_payload(
124                "If this error happened during payload construction, call get_schema first and align request shape."
125            ),
126        }
127    }))
128}
129
130pub(crate) fn infer_store_error_code(message: &str) -> &'static str {
131    let normalized = message.trim().to_ascii_lowercase();
132
133    if normalized.starts_with("parsefailure") {
134        "StrictTypedIrParseFailure"
135    } else if normalized.starts_with("ratelimited") {
136        "StoreRateLimited"
137    } else if normalized.starts_with("storefailure") {
138        "StoreFailure"
139    } else if normalized.contains("strict profile") {
140        "StrictTypedIrPolicyViolation"
141    } else {
142        "StoreContextFailure"
143    }
144}
145
146pub(crate) fn strict_typed_ir_profile_name() -> &'static str {
147    "strict_typed_ir"
148}
149
150fn schema_tool_name() -> &'static str {
151    "get_schema"
152}
153
154pub(crate) fn schema_first_guidance_payload(summary: &str) -> Value {
155    json!({
156        "summary": summary,
157        "recommended_first_tool": schema_tool_name(),
158        "recommended_next_steps": [
159            "call get_schema",
160            "verify payload layers provenance->envelope->content->metrics",
161            "ensure required typed-ir keys/enums/numerics are present before retry"
162        ],
163        "ingest_profile_policy": strict_typed_ir_profile_name(),
164    })
165}
166
167pub(crate) fn avec_to_json(avec: locus_core_rs::AvecState) -> Value {
168    json!({
169        "stability": avec.stability,
170        "friction": avec.friction,
171        "logic": avec.logic,
172        "autonomy": avec.autonomy,
173        "psi": avec.psi(),
174    })
175}
176
177pub(crate) fn sttp_node_to_json(node: &locus_core_rs::SttpNode) -> Value {
178    json!({
179        "raw": node.raw,
180        "session_id": node.session_id,
181        "tier": node.tier,
182        "timestamp": node.timestamp.to_rfc3339(),
183        "compression_depth": node.compression_depth,
184        "parent_node_id": node.parent_node_id,
185        "sync_key": node.sync_key,
186        "updated_at": node.updated_at.to_rfc3339(),
187        "source_metadata": node.source_metadata,
188        "context_summary": node.context_summary,
189        "semantic_tags": node.semantic_tags,
190        "semantic_links": node
191            .semantic_links
192            .as_ref()
193            .map(|links| {
194                links
195                    .iter()
196                    .map(|link| {
197                        json!({
198                            "rel": link.rel,
199                            "target": link.target,
200                            "confidence": link.confidence,
201                        })
202                    })
203                    .collect::<Vec<_>>()
204            }),
205        "has_embedding": node.embedding.as_ref().map(|values| !values.is_empty()).unwrap_or(false),
206        "embedding_model": node.embedding_model,
207        "embedding_dimensions": node.embedding_dimensions,
208        "embedded_at": node.embedded_at.map(|value| value.to_rfc3339()),
209        "user_avec": avec_to_json(node.user_avec),
210        "model_avec": avec_to_json(node.model_avec),
211        "compression_avec": node.compression_avec.map(avec_to_json),
212        "rho": node.rho,
213        "kappa": node.kappa,
214        "psi": node.psi,
215    })
216}