1use crate::mcp::auth::McpSession;
5use crate::mcp::tools::{
6 ErukaReadInput, ErukaReadOutput,
7 ErukaWriteInput, ErukaWriteOutput,
8 ErukaSearchInput, ErukaSearchOutput, ErukaSearchResult,
9};
10use crate::types::AppError;
11use serde_json::Value;
12
13#[derive(Debug, thiserror::Error)]
15pub enum ErukaProxyError {
16 #[error("Eruka HTTP request failed: {0}")]
17 Http(#[from] reqwest::Error),
18
19 #[error("Eruka returned error: {status} — {body}")]
20 ApiError { status: u16, body: String },
21
22 #[error("Failed to parse Eruka response: {0}")]
23 Parse(String),
24
25 #[error("Eruka is not configured or unreachable")]
26 NotConfigured,
27}
28
29impl From<ErukaProxyError> for AppError {
30 fn from(e: ErukaProxyError) -> Self {
31 match e {
32 ErukaProxyError::Http(e) => AppError::External(format!("Eruka HTTP error: {}", e)),
33 ErukaProxyError::ApiError { status, body } => {
34 AppError::External(format!("Eruka API error {}: {}", status, body))
35 }
36 ErukaProxyError::Parse(s) => AppError::External(format!("Eruka parse error: {}", s)),
37 ErukaProxyError::NotConfigured => {
38 AppError::External("Eruka not configured".to_string())
39 }
40 }
41 }
42}
43
44pub struct ErukaProxy {
47 http: reqwest::Client,
48 base_url: String,
49}
50
51impl ErukaProxy {
52 pub fn new(eruka_base_url: &str) -> Self {
57 let http = reqwest::Client::builder()
58 .timeout(std::time::Duration::from_secs(15))
59 .build()
60 .expect("Failed to build Eruka proxy HTTP client");
61
62 Self {
63 http,
64 base_url: eruka_base_url.trim_end_matches('/').to_string(),
65 }
66 }
67
68 pub async fn read(
72 &self,
73 session: &McpSession,
74 input: ErukaReadInput,
75 ) -> Result<ErukaReadOutput, ErukaProxyError> {
76 let workspace_id = input
77 .workspace_id
78 .as_deref()
79 .unwrap_or(&session.eruka_workspace_id);
80
81 let url = format!(
82 "{}/api/workspaces/{}/context/{}/{}",
83 self.base_url, workspace_id, input.category, input.field
84 );
85
86 let response = self.http.get(&url).send().await?;
87
88 if !response.status().is_success() {
89 let status = response.status().as_u16();
90 let body = response.text().await.unwrap_or_default();
91 return Err(ErukaProxyError::ApiError { status, body });
92 }
93
94 let json: Value = response.json().await?;
95
96 Ok(ErukaReadOutput {
97 field: json["field"]
98 .as_str()
99 .unwrap_or(&input.field)
100 .to_string(),
101 value: json["value"].clone(),
102 state: json["state"]
103 .as_str()
104 .unwrap_or("UNKNOWN")
105 .to_string(),
106 confidence: json["confidence"].as_f64().unwrap_or(0.0),
107 last_updated: json["last_updated"].as_str().map(String::from),
108 })
109 }
110
111 pub async fn write(
115 &self,
116 session: &McpSession,
117 input: ErukaWriteInput,
118 ) -> Result<ErukaWriteOutput, ErukaProxyError> {
119 let workspace_id = input
120 .workspace_id
121 .as_deref()
122 .unwrap_or(&session.eruka_workspace_id);
123
124 let url = format!(
125 "{}/api/workspaces/{}/context",
126 self.base_url, workspace_id
127 );
128
129 let body = serde_json::json!({
130 "category": input.category,
131 "field": input.field,
132 "value": input.value,
133 "confidence": input.confidence,
134 "source": input.source
135 });
136
137 let response = self.http.post(&url).json(&body).send().await?;
138
139 if !response.status().is_success() {
140 let status = response.status().as_u16();
141 let body_text = response.text().await.unwrap_or_default();
142 return Err(ErukaProxyError::ApiError {
143 status,
144 body: body_text,
145 });
146 }
147
148 let json: Value = response.json().await?;
149
150 let state = if input.confidence >= 1.0 {
151 "CONFIRMED"
152 } else {
153 "UNCERTAIN"
154 };
155
156 Ok(ErukaWriteOutput {
157 field: input.field,
158 state: state.to_string(),
159 written_at: chrono::Utc::now().to_rfc3339(),
160 })
161 }
162
163 pub async fn search(
167 &self,
168 session: &McpSession,
169 input: ErukaSearchInput,
170 ) -> Result<ErukaSearchOutput, ErukaProxyError> {
171 let workspace_id = input
172 .workspace_id
173 .as_deref()
174 .unwrap_or(&session.eruka_workspace_id);
175
176 let url = format!(
177 "{}/api/workspaces/{}/search",
178 self.base_url, workspace_id
179 );
180
181 let body = serde_json::json!({
182 "query": input.query,
183 "limit": input.limit
184 });
185
186 let response = self.http.post(&url).json(&body).send().await?;
187
188 if !response.status().is_success() {
189 let status = response.status().as_u16();
190 let body_text = response.text().await.unwrap_or_default();
191 return Err(ErukaProxyError::ApiError {
192 status,
193 body: body_text,
194 });
195 }
196
197 let json: Value = response.json().await?;
198
199 let results: Vec<ErukaSearchResult> = json["results"]
200 .as_array()
201 .map(|arr| {
202 arr.iter()
203 .map(|r| ErukaSearchResult {
204 category: r["category"].as_str().unwrap_or("").to_string(),
205 field: r["field"].as_str().unwrap_or("").to_string(),
206 value: r["value"].clone(),
207 state: r["state"].as_str().unwrap_or("UNKNOWN").to_string(),
208 relevance: r["relevance"].as_f64().unwrap_or(0.0),
209 })
210 .collect()
211 })
212 .unwrap_or_default();
213
214 let total = results.len();
215
216 Ok(ErukaSearchOutput {
217 results,
218 total_results: total,
219 })
220 }
221}