1use serde::{Deserialize, Serialize};
7use std::env;
8use std::time::Duration;
9
10fn timeout() -> Duration {
13 std::env::var("PACKSET_TIMEOUT_MS")
14 .ok()
15 .and_then(|v| v.trim().parse::<u64>().ok())
16 .filter(|ms| *ms > 0)
17 .map_or(Duration::from_secs(30), Duration::from_millis)
18}
19
20fn path_seg(id: &str) -> String {
21 let mut out = String::with_capacity(id.len());
22 for b in id.bytes() {
23 match b {
24 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
25 out.push(b as char)
26 }
27 _ => out.push_str(&format!("%{b:02X}")),
28 }
29 }
30 out
31}
32
33pub const DEFAULT_PORT: u16 = 8761;
36
37#[must_use]
39pub fn default_port() -> u16 {
40 env::var("PACKSET_PORT")
41 .or_else(|_| env::var("GROK_MEM_PORT"))
42 .ok()
43 .and_then(|raw| raw.trim().parse().ok())
44 .unwrap_or(DEFAULT_PORT)
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum Error {
49 #[error("packset url missing")]
50 NoUrl,
51 #[error("http: {0}")]
52 Http(#[from] Box<ureq::Error>),
53 #[error("io: {0}")]
54 Io(#[from] std::io::Error),
55 #[error("json: {0}")]
56 Json(#[from] serde_json::Error),
57 #[error("bad response: {0}")]
58 Bad(String),
59}
60
61#[derive(Debug, Clone)]
62pub struct PacksetClient {
63 base: String,
64 workspace: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Hit {
71 pub id: Option<String>,
72 pub text: String,
73 #[serde(default)]
74 pub score: f64,
75 #[serde(default)]
76 pub kind: String,
77 #[serde(default)]
80 pub ts: Option<String>,
81 #[serde(default)]
84 pub ballots: Option<u32>,
85 #[serde(default)]
86 pub of: Option<u32>,
87}
88
89fn refused(url: &str, e: ureq::Error) -> Error {
91 match e {
92 ureq::Error::Status(code, response) => {
93 let text = response.into_string().unwrap_or_default();
94 let reason = serde_json::from_str::<serde_json::Value>(&text)
95 .ok()
96 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
97 .unwrap_or(text);
98 let reason = reason.trim();
99 if reason.is_empty() {
100 Error::Bad(format!("{url}: status code {code}"))
101 } else {
102 Error::Bad(format!("{url}: {code}: {reason}"))
103 }
104 }
105 other => Error::Http(Box::new(other)),
106 }
107}
108
109impl PacksetClient {
110 pub fn new(base: impl Into<String>) -> Self {
111 let mut base = base.into();
112 while base.ends_with('/') {
113 base.pop();
114 }
115 Self {
116 base,
117 workspace: None,
118 }
119 }
120
121 #[must_use]
125 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
126 let workspace = workspace.into();
127 self.workspace = (!workspace.is_empty()).then_some(workspace);
128 self
129 }
130
131 pub fn from_env() -> Result<Self, Error> {
136 let url = env::var("PACKSET_URL")
137 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
138 .ok()
139 .filter(|url| !url.is_empty());
140 match url {
141 Some(url) if url == "off" => Err(Error::NoUrl),
142 Some(url) => Ok(Self::new(url)),
143 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
144 }
145 }
146
147 pub fn base(&self) -> &str {
148 &self.base
149 }
150
151 pub fn workspace(&self) -> String {
152 if let Some(w) = &self.workspace {
153 return w.clone();
154 }
155 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
156 if !w.is_empty() {
157 return w;
158 }
159 }
160 let cwd = env::var("GROKOS_WORKSPACE")
161 .ok()
162 .map(|s| s.trim().to_string())
163 .filter(|s| !s.is_empty())
164 .map(std::path::PathBuf::from)
165 .or_else(|| env::current_dir().ok())
166 .unwrap_or_else(|| std::path::PathBuf::from("."));
167 self.workspace_for_cwd(&cwd)
168 }
169
170 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
172 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
173 let url = format!("{}/v1/identity", self.base);
174 let body = ureq::get(&url)
175 .query("cwd", abs.to_string_lossy().as_ref())
176 .timeout(timeout())
177 .call()
178 .ok()
179 .and_then(|r| r.into_string().ok());
180 if let Some(body) = body {
181 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
182 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
183 if !ws.is_empty() {
184 return ws.to_string();
185 }
186 }
187 }
188 }
189 format!("dir:{}", abs.display())
190 }
191
192 pub fn health(&self) -> Result<String, Error> {
193 let url = format!("{}/health", self.base);
194 let body = ureq::get(&url)
195 .timeout(timeout())
196 .call()
197 .map_err(|e| refused(&url, e))?
198 .into_string()?;
199 Ok(body)
200 }
201
202 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
203 let encoded = path_seg(id);
204 let url = format!("{}/v1/atoms/{encoded}", self.base);
205 let resp = match ureq::get(&url)
206 .query("workspace", workspace)
207 .timeout(timeout())
208 .call()
209 {
210 Ok(resp) => resp,
211 Err(ureq::Error::Status(404, _)) => {
212 return Err(Error::Bad(format!("no atom {id}")));
213 }
214 Err(e) => return Err(Error::Http(Box::new(e))),
215 };
216 Ok(resp.into_json()?)
217 }
218
219 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
220 self.atoms_as_of(workspace, None)
221 }
222
223 pub fn atoms_as_of(
229 &self,
230 workspace: &str,
231 as_of: Option<&str>,
232 ) -> Result<Vec<serde_json::Value>, Error> {
233 let url = format!("{}/v1/atoms", self.base);
234 let mut req = ureq::get(&url)
235 .query("workspace", workspace)
236 .timeout(timeout());
237 if let Some(at) = as_of {
238 req = req.query("as_of", at);
239 }
240 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
241 let atoms = body
242 .get("atoms")
243 .cloned()
244 .unwrap_or(serde_json::Value::Array(vec![]));
245 Ok(serde_json::from_value(atoms)?)
246 }
247
248 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
249 self.search_as_of(workspace, q, limit, None)
250 }
251
252 pub fn search_as_of(
258 &self,
259 workspace: &str,
260 q: &str,
261 limit: u32,
262 as_of: Option<&str>,
263 ) -> Result<Vec<Hit>, Error> {
264 self.search_opts(workspace, q, limit, as_of, false)
265 }
266
267 pub fn search_opts(
277 &self,
278 workspace: &str,
279 q: &str,
280 limit: u32,
281 as_of: Option<&str>,
282 rerank: bool,
283 ) -> Result<Vec<Hit>, Error> {
284 let url = format!("{}/v1/search", self.base);
285 let budget = if rerank {
286 Duration::from_secs(60).max(timeout())
287 } else {
288 timeout()
289 };
290 let mut req = ureq::get(&url)
291 .query("workspace", workspace)
292 .query("q", q)
293 .query("limit", &limit.to_string())
294 .timeout(budget);
295 if let Some(at) = as_of {
296 req = req.query("as_of", at);
297 }
298 if rerank {
299 req = req.query("rerank", "1");
300 }
301 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
302 let hits = body
303 .get("hits")
304 .cloned()
305 .unwrap_or(serde_json::Value::Array(vec![]));
306 Ok(serde_json::from_value(hits)?)
307 }
308
309 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
315 let url = format!("{}/v1/status", self.base);
316 let mut req = ureq::get(&url).timeout(timeout());
317 if let Some(workspace) = workspace {
318 req = req.query("workspace", workspace);
319 }
320 Ok(req.call().map_err(|e| refused(&url, e))?.into_json()?)
321 }
322
323 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
329 let url = format!("{}/v1/pin", self.base);
330 Ok(ureq::get(&url)
331 .query("workspace", workspace)
332 .timeout(timeout())
333 .call()
334 .map_err(|e| refused(&url, e))?
335 .into_json()?)
336 }
337
338 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
344 let url = format!("{}/v1/pin", self.base);
345 Ok(ureq::put(&url)
346 .timeout(timeout())
347 .send_json(serde_json::json!({ "workspace": workspace, "name": name }))
348 .map_err(|e| refused(&url, e))?
349 .into_json()?)
350 }
351
352 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
361 let url = format!("{}/v1/accessions", self.base);
362 let body: serde_json::Value = ureq::get(&url)
363 .query("workspace", workspace)
364 .timeout(timeout())
365 .call()
366 .map_err(|e| refused(&url, e))?
367 .into_json()?;
368 let found = body
369 .get("accessions")
370 .cloned()
371 .unwrap_or(serde_json::Value::Array(vec![]));
372 Ok(serde_json::from_value(found)?)
373 }
374 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
383 self.atoms_as_of(workspace, None)
384 }
385
386 pub fn citers(
392 &self,
393 workspace: &str,
394 accession: &str,
395 ) -> Result<Vec<serde_json::Value>, Error> {
396 let url = format!("{}/v1/citers", self.base);
397 let body: serde_json::Value = ureq::get(&url)
398 .query("workspace", workspace)
399 .query("accession", accession)
400 .timeout(timeout())
401 .call()
402 .map_err(|e| refused(&url, e))?
403 .into_json()?;
404 let found = body
405 .get("atoms")
406 .cloned()
407 .unwrap_or(serde_json::Value::Array(vec![]));
408 Ok(serde_json::from_value(found)?)
409 }
410
411 pub fn delete_atom(
424 &self,
425 workspace: &str,
426 id: &str,
427 why: Option<&str>,
428 ) -> Result<serde_json::Value, Error> {
429 let url = format!("{}/v1/atoms/delete", self.base);
430 let mut body = serde_json::json!({
431 "workspace": workspace,
432 "id": id,
433 });
434 if let Some(accession) = why {
435 body["why"] = serde_json::Value::String(accession.to_string());
436 }
437 let resp = match ureq::post(&url).timeout(timeout()).send_json(body) {
438 Ok(resp) => resp,
439 Err(ureq::Error::Status(404, _)) => {
440 return Err(Error::Bad(format!("no atom {id}")));
441 }
442 Err(e) => return Err(refused(&url, e)),
443 };
444 Ok(resp.into_json()?)
445 }
446
447 pub fn grade(
449 &self,
450 workspace: &str,
451 id: &str,
452 recalled: bool,
453 ) -> Result<serde_json::Value, Error> {
454 let url = format!("{}/v1/grade", self.base);
455 let body: serde_json::Value = ureq::post(&url)
456 .timeout(timeout())
457 .send_json(serde_json::json!({
458 "workspace": workspace,
459 "id": id,
460 "recalled": recalled,
461 }))
462 .map_err(|e| refused(&url, e))?
463 .into_json()?;
464 Ok(body)
465 }
466
467 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
470 let url = format!("{}/v1/hubs", self.base);
471 let body: serde_json::Value = ureq::get(&url)
472 .query("workspace", workspace)
473 .query("limit", &limit.to_string())
474 .timeout(timeout())
475 .call()
476 .map_err(|e| refused(&url, e))?
477 .into_json()?;
478 Ok(body)
479 }
480
481 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
482 let url = format!("{}/v1/islands", self.base);
483 let body: serde_json::Value = ureq::get(&url)
484 .query("workspace", workspace)
485 .timeout(timeout())
486 .call()
487 .map_err(|e| refused(&url, e))?
488 .into_json()?;
489 Ok(body)
490 }
491
492 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
494 let url = format!("{}/v1/fire", self.base);
495 let body: serde_json::Value = ureq::post(&url)
496 .timeout(timeout())
497 .send_json(serde_json::json!({"workspace": workspace, "ids": ids}))
498 .map_err(|e| refused(&url, e))?
499 .into_json()?;
500 Ok(body)
501 }
502
503 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
507 let url = format!("{}/v1/consolidate", self.base);
508 let body: serde_json::Value = ureq::post(&url)
509 .timeout(timeout())
510 .send_json(serde_json::json!({"workspace": workspace, "apply": apply}))
511 .map_err(|e| refused(&url, e))?
512 .into_json()?;
513 Ok(body)
514 }
515
516 pub fn activate(
519 &self,
520 workspace: &str,
521 q: &str,
522 limit: u32,
523 fire: bool,
524 ) -> Result<serde_json::Value, Error> {
525 let url = format!("{}/v1/activate", self.base);
526 let body: serde_json::Value = ureq::get(&url)
527 .query("workspace", workspace)
528 .query("q", q)
529 .query("limit", &limit.to_string())
530 .query("fire", if fire { "1" } else { "0" })
531 .timeout(timeout())
532 .call()
533 .map_err(|e| refused(&url, e))?
534 .into_json()?;
535 Ok(body)
536 }
537
538 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
539 let url = format!("{}/v1/atoms", self.base);
540 let body: serde_json::Value = ureq::post(&url)
541 .timeout(timeout())
542 .send_json(atom.clone())
543 .map_err(|e| refused(&url, e))?
544 .into_json()?;
545 Ok(body)
546 }
547}