1use serde::{Deserialize, Serialize};
7use std::env;
8use std::path::PathBuf;
9use std::time::Duration;
10
11fn timeout() -> Duration {
14 std::env::var("PACKSET_TIMEOUT_MS")
15 .ok()
16 .and_then(|v| v.trim().parse::<u64>().ok())
17 .filter(|ms| *ms > 0)
18 .map_or(Duration::from_secs(30), Duration::from_millis)
19}
20
21fn path_seg(id: &str) -> String {
22 let mut out = String::with_capacity(id.len());
23 for b in id.bytes() {
24 match b {
25 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
26 out.push(b as char)
27 }
28 _ => out.push_str(&format!("%{b:02X}")),
29 }
30 }
31 out
32}
33
34pub const DEFAULT_PORT: u16 = 8761;
37
38pub fn load_seat_env() {
41 let Some(home) = env::var_os("HOME") else {
42 return;
43 };
44 let path = PathBuf::from(home).join(".config/ljos/env");
45 let Ok(text) = std::fs::read_to_string(path) else {
46 return;
47 };
48 for line in text.lines() {
49 let line = line.trim();
50 if line.is_empty() || line.starts_with('#') {
51 continue;
52 }
53 let Some((k, v)) = line.split_once('=') else {
54 continue;
55 };
56 let k = k.trim();
57 if k.is_empty() || env::var_os(k).is_some() {
58 continue;
59 }
60 env::set_var(k, v.trim());
61 }
62}
63
64#[must_use]
69pub fn resolved_workspace() -> String {
70 load_seat_env();
71 env::var("PACKSET_WORKSPACE")
72 .ok()
73 .map(|w| w.trim().to_string())
74 .filter(|w| !w.is_empty())
75 .unwrap_or_else(|| "seat".to_string())
76}
77
78#[must_use]
80pub fn default_port() -> u16 {
81 env::var("PACKSET_PORT")
82 .or_else(|_| env::var("GROK_MEM_PORT"))
83 .ok()
84 .and_then(|raw| raw.trim().parse().ok())
85 .unwrap_or(DEFAULT_PORT)
86}
87
88#[derive(Debug, thiserror::Error)]
89pub enum Error {
90 #[error("packset url missing")]
91 NoUrl,
92 #[error("http: {0}")]
93 Http(#[from] Box<ureq::Error>),
94 #[error("io: {0}")]
95 Io(#[from] std::io::Error),
96 #[error("json: {0}")]
97 Json(#[from] serde_json::Error),
98 #[error("bad response: {0}")]
99 Bad(String),
100}
101
102#[derive(Debug, Clone)]
103pub struct PacksetClient {
104 base: String,
105 workspace: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Hit {
112 pub id: Option<String>,
113 pub text: String,
114 #[serde(default)]
115 pub score: f64,
116 #[serde(default)]
117 pub kind: String,
118 #[serde(default)]
121 pub ts: Option<String>,
122 #[serde(default)]
127 pub entities: Vec<String>,
128 #[serde(default)]
129 pub ballots: Option<u32>,
130 #[serde(default)]
131 pub of: Option<u32>,
132}
133
134fn refused(url: &str, e: ureq::Error) -> Error {
136 match e {
137 ureq::Error::Status(code, response) => {
138 let text = response.into_string().unwrap_or_default();
139 let reason = serde_json::from_str::<serde_json::Value>(&text)
140 .ok()
141 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
142 .unwrap_or(text);
143 let reason = reason.trim();
144 if reason.is_empty() {
145 Error::Bad(format!("{url}: status code {code}"))
146 } else {
147 Error::Bad(format!("{url}: {code}: {reason}"))
148 }
149 }
150 other => Error::Http(Box::new(other)),
151 }
152}
153
154impl PacksetClient {
155 pub fn new(base: impl Into<String>) -> Self {
156 let mut base = base.into();
157 while base.ends_with('/') {
158 base.pop();
159 }
160 Self {
161 base,
162 workspace: None,
163 }
164 }
165
166 #[must_use]
170 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
171 let workspace = workspace.into();
172 self.workspace = (!workspace.is_empty()).then_some(workspace);
173 self
174 }
175
176 pub fn from_env() -> Result<Self, Error> {
181 load_seat_env();
182 let url = env::var("PACKSET_URL")
183 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
184 .ok()
185 .filter(|url| !url.is_empty());
186 match url {
187 Some(url) if url == "off" => Err(Error::NoUrl),
188 Some(url) => Ok(Self::new(url)),
189 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
190 }
191 }
192
193 pub fn base(&self) -> &str {
194 &self.base
195 }
196
197 pub fn workspace(&self) -> String {
198 if let Some(w) = &self.workspace {
199 return w.clone();
200 }
201 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
202 if !w.is_empty() {
203 return w;
204 }
205 }
206 let cwd = env::var("GROKOS_WORKSPACE")
207 .ok()
208 .map(|s| s.trim().to_string())
209 .filter(|s| !s.is_empty())
210 .map(std::path::PathBuf::from)
211 .or_else(|| env::current_dir().ok())
212 .unwrap_or_else(|| std::path::PathBuf::from("."));
213 self.workspace_for_cwd(&cwd)
214 }
215
216 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
218 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
219 let url = format!("{}/v1/identity", self.base);
220 let body = ureq::get(&url)
221 .query("cwd", abs.to_string_lossy().as_ref())
222 .timeout(timeout())
223 .call()
224 .ok()
225 .and_then(|r| r.into_string().ok());
226 if let Some(body) = body {
227 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
228 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
229 if !ws.is_empty() {
230 return ws.to_string();
231 }
232 }
233 }
234 }
235 format!("dir:{}", abs.display())
236 }
237
238 pub fn health(&self) -> Result<String, Error> {
239 let url = format!("{}/health", self.base);
240 let body = ureq::get(&url)
241 .timeout(timeout())
242 .call()
243 .map_err(|e| refused(&url, e))?
244 .into_string()?;
245 Ok(body)
246 }
247
248 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
249 let encoded = path_seg(id);
250 let url = format!("{}/v1/atoms/{encoded}", self.base);
251 let resp = match ureq::get(&url)
252 .query("workspace", workspace)
253 .timeout(timeout())
254 .call()
255 {
256 Ok(resp) => resp,
257 Err(ureq::Error::Status(404, _)) => {
258 return Err(Error::Bad(format!("no atom {id}")));
259 }
260 Err(e) => return Err(Error::Http(Box::new(e))),
261 };
262 Ok(resp.into_json()?)
263 }
264
265 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
266 self.atoms_as_of(workspace, None)
267 }
268
269 pub fn atoms_as_of(
275 &self,
276 workspace: &str,
277 as_of: Option<&str>,
278 ) -> Result<Vec<serde_json::Value>, Error> {
279 let url = format!("{}/v1/atoms", self.base);
280 let mut req = ureq::get(&url)
281 .query("workspace", workspace)
282 .timeout(timeout());
283 if let Some(at) = as_of {
284 req = req.query("as_of", at);
285 }
286 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
287 let atoms = body
288 .get("atoms")
289 .cloned()
290 .unwrap_or(serde_json::Value::Array(vec![]));
291 Ok(serde_json::from_value(atoms)?)
292 }
293
294 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
295 self.search_as_of(workspace, q, limit, None)
296 }
297
298 pub fn search_as_of(
304 &self,
305 workspace: &str,
306 q: &str,
307 limit: u32,
308 as_of: Option<&str>,
309 ) -> Result<Vec<Hit>, Error> {
310 self.search_opts(workspace, q, limit, as_of, false)
311 }
312
313 pub fn search_opts(
323 &self,
324 workspace: &str,
325 q: &str,
326 limit: u32,
327 as_of: Option<&str>,
328 rerank: bool,
329 ) -> Result<Vec<Hit>, Error> {
330 let url = format!("{}/v1/search", self.base);
331 let budget = if rerank {
332 Duration::from_secs(60).max(timeout())
333 } else {
334 timeout()
335 };
336 let mut req = ureq::get(&url)
337 .query("workspace", workspace)
338 .query("q", q)
339 .query("limit", &limit.to_string())
340 .timeout(budget);
341 if let Some(at) = as_of {
342 req = req.query("as_of", at);
343 }
344 if rerank {
345 req = req.query("rerank", "1");
346 }
347 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
348 let hits = body
349 .get("hits")
350 .cloned()
351 .unwrap_or(serde_json::Value::Array(vec![]));
352 Ok(serde_json::from_value(hits)?)
353 }
354
355 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
361 let url = format!("{}/v1/status", self.base);
362 let mut req = ureq::get(&url).timeout(timeout());
363 if let Some(workspace) = workspace {
364 req = req.query("workspace", workspace);
365 }
366 Ok(req.call().map_err(|e| refused(&url, e))?.into_json()?)
367 }
368
369 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
375 let url = format!("{}/v1/pin", self.base);
376 Ok(ureq::get(&url)
377 .query("workspace", workspace)
378 .timeout(timeout())
379 .call()
380 .map_err(|e| refused(&url, e))?
381 .into_json()?)
382 }
383
384 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
390 let url = format!("{}/v1/pin", self.base);
391 Ok(ureq::put(&url)
392 .timeout(timeout())
393 .send_json(serde_json::json!({ "workspace": workspace, "name": name }))
394 .map_err(|e| refused(&url, e))?
395 .into_json()?)
396 }
397
398 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
407 let url = format!("{}/v1/accessions", self.base);
408 let body: serde_json::Value = ureq::get(&url)
409 .query("workspace", workspace)
410 .timeout(timeout())
411 .call()
412 .map_err(|e| refused(&url, e))?
413 .into_json()?;
414 let found = body
415 .get("accessions")
416 .cloned()
417 .unwrap_or(serde_json::Value::Array(vec![]));
418 Ok(serde_json::from_value(found)?)
419 }
420 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
429 self.atoms_as_of(workspace, None)
430 }
431
432 pub fn citers(
438 &self,
439 workspace: &str,
440 accession: &str,
441 ) -> Result<Vec<serde_json::Value>, Error> {
442 let url = format!("{}/v1/citers", self.base);
443 let body: serde_json::Value = ureq::get(&url)
444 .query("workspace", workspace)
445 .query("accession", accession)
446 .timeout(timeout())
447 .call()
448 .map_err(|e| refused(&url, e))?
449 .into_json()?;
450 let found = body
451 .get("atoms")
452 .cloned()
453 .unwrap_or(serde_json::Value::Array(vec![]));
454 Ok(serde_json::from_value(found)?)
455 }
456
457 pub fn delete_atom(
470 &self,
471 workspace: &str,
472 id: &str,
473 why: Option<&str>,
474 ) -> Result<serde_json::Value, Error> {
475 let url = format!("{}/v1/atoms/delete", self.base);
476 let mut body = serde_json::json!({
477 "workspace": workspace,
478 "id": id,
479 });
480 if let Some(accession) = why {
481 body["why"] = serde_json::Value::String(accession.to_string());
482 }
483 let resp = match ureq::post(&url).timeout(timeout()).send_json(body) {
484 Ok(resp) => resp,
485 Err(ureq::Error::Status(404, _)) => {
486 return Err(Error::Bad(format!("no atom {id}")));
487 }
488 Err(e) => return Err(refused(&url, e)),
489 };
490 Ok(resp.into_json()?)
491 }
492
493 pub fn grade(
495 &self,
496 workspace: &str,
497 id: &str,
498 recalled: bool,
499 ) -> Result<serde_json::Value, Error> {
500 let url = format!("{}/v1/grade", self.base);
501 let body: serde_json::Value = ureq::post(&url)
502 .timeout(timeout())
503 .send_json(serde_json::json!({
504 "workspace": workspace,
505 "id": id,
506 "recalled": recalled,
507 }))
508 .map_err(|e| refused(&url, e))?
509 .into_json()?;
510 Ok(body)
511 }
512
513 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
516 let url = format!("{}/v1/hubs", self.base);
517 let body: serde_json::Value = ureq::get(&url)
518 .query("workspace", workspace)
519 .query("limit", &limit.to_string())
520 .timeout(timeout())
521 .call()
522 .map_err(|e| refused(&url, e))?
523 .into_json()?;
524 Ok(body)
525 }
526
527 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
528 let url = format!("{}/v1/islands", self.base);
529 let body: serde_json::Value = ureq::get(&url)
530 .query("workspace", workspace)
531 .timeout(timeout())
532 .call()
533 .map_err(|e| refused(&url, e))?
534 .into_json()?;
535 Ok(body)
536 }
537
538 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
540 let url = format!("{}/v1/fire", self.base);
541 let body: serde_json::Value = ureq::post(&url)
542 .timeout(timeout())
543 .send_json(serde_json::json!({"workspace": workspace, "ids": ids}))
544 .map_err(|e| refused(&url, e))?
545 .into_json()?;
546 Ok(body)
547 }
548
549 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
553 let url = format!("{}/v1/consolidate", self.base);
554 let body: serde_json::Value = ureq::post(&url)
555 .timeout(timeout())
556 .send_json(serde_json::json!({"workspace": workspace, "apply": apply}))
557 .map_err(|e| refused(&url, e))?
558 .into_json()?;
559 Ok(body)
560 }
561
562 pub fn activate(
565 &self,
566 workspace: &str,
567 q: &str,
568 limit: u32,
569 fire: bool,
570 ) -> Result<serde_json::Value, Error> {
571 let url = format!("{}/v1/activate", self.base);
572 let body: serde_json::Value = ureq::get(&url)
573 .query("workspace", workspace)
574 .query("q", q)
575 .query("limit", &limit.to_string())
576 .query("fire", if fire { "1" } else { "0" })
577 .timeout(timeout())
578 .call()
579 .map_err(|e| refused(&url, e))?
580 .into_json()?;
581 Ok(body)
582 }
583
584 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
585 let url = format!("{}/v1/atoms", self.base);
586 let body: serde_json::Value = ureq::post(&url)
587 .timeout(timeout())
588 .send_json(atom.clone())
589 .map_err(|e| refused(&url, e))?
590 .into_json()?;
591 Ok(body)
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
600 fn resolved_workspace_reads_ljos_env_not_default() {
601 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
602 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
603 std::fs::write(
604 dir.join(".config/ljos/env"),
605 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
606 )
607 .unwrap();
608 let old_home = env::var("HOME").ok();
609 let old_ws = env::var("PACKSET_WORKSPACE").ok();
610 unsafe {
611 env::remove_var("PACKSET_WORKSPACE");
612 env::set_var("HOME", &dir);
613 }
614 let got = resolved_workspace();
615 unsafe {
616 match old_home {
617 Some(h) => env::set_var("HOME", h),
618 None => env::remove_var("HOME"),
619 }
620 match old_ws {
621 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
622 None => env::remove_var("PACKSET_WORKSPACE"),
623 }
624 }
625 assert_eq!(got, "git:example.com/seat/notes");
626 }
627
628 #[test]
629 fn resolved_workspace_without_env_is_seat_not_default() {
630 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
631 std::fs::create_dir_all(&dir).unwrap();
632 let old_home = env::var("HOME").ok();
633 let old_ws = env::var("PACKSET_WORKSPACE").ok();
634 unsafe {
635 env::remove_var("PACKSET_WORKSPACE");
636 env::set_var("HOME", &dir);
637 }
638 let got = resolved_workspace();
639 unsafe {
640 match old_home {
641 Some(h) => env::set_var("HOME", h),
642 None => env::remove_var("HOME"),
643 }
644 match old_ws {
645 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
646 None => env::remove_var("PACKSET_WORKSPACE"),
647 }
648 }
649 assert_eq!(got, "seat");
650 assert_ne!(got, "default");
651 }
652}