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 mut body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
287 let atoms = body
288 .get_mut("atoms")
289 .map(serde_json::Value::take)
290 .unwrap_or(serde_json::Value::Array(vec![]));
291 Ok(serde_json::from_value(atoms)?)
292 }
293
294 pub fn atoms_without_vectors(
302 &self,
303 workspace: &str,
304 as_of: Option<&str>,
305 ) -> Result<Vec<serde_json::Value>, Error> {
306 let url = format!("{}/v1/atoms", self.base);
307 let mut req = ureq::get(&url)
308 .query("workspace", workspace)
309 .query("embedding", "omit")
310 .timeout(timeout());
311 if let Some(at) = as_of {
312 req = req.query("as_of", at);
313 }
314 let mut body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
315 let atoms = body
316 .get_mut("atoms")
317 .map(serde_json::Value::take)
318 .unwrap_or(serde_json::Value::Array(vec![]));
319 Ok(serde_json::from_value(atoms)?)
320 }
321
322 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
323 self.search_as_of(workspace, q, limit, None)
324 }
325
326 pub fn search_as_of(
332 &self,
333 workspace: &str,
334 q: &str,
335 limit: u32,
336 as_of: Option<&str>,
337 ) -> Result<Vec<Hit>, Error> {
338 self.search_opts(workspace, q, limit, as_of, false)
339 }
340
341 pub fn search_opts(
351 &self,
352 workspace: &str,
353 q: &str,
354 limit: u32,
355 as_of: Option<&str>,
356 rerank: bool,
357 ) -> Result<Vec<Hit>, Error> {
358 let url = format!("{}/v1/search", self.base);
359 let budget = if rerank {
360 Duration::from_secs(60).max(timeout())
361 } else {
362 timeout()
363 };
364 let mut req = ureq::get(&url)
365 .query("workspace", workspace)
366 .query("q", q)
367 .query("limit", &limit.to_string())
368 .timeout(budget);
369 if let Some(at) = as_of {
370 req = req.query("as_of", at);
371 }
372 if rerank {
373 req = req.query("rerank", "1");
374 }
375 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
376 let hits = body
377 .get("hits")
378 .cloned()
379 .unwrap_or(serde_json::Value::Array(vec![]));
380 Ok(serde_json::from_value(hits)?)
381 }
382
383 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
389 let url = format!("{}/v1/status", self.base);
390 let mut req = ureq::get(&url).timeout(timeout());
391 if let Some(workspace) = workspace {
392 req = req.query("workspace", workspace);
393 }
394 Ok(req.call().map_err(|e| refused(&url, e))?.into_json()?)
395 }
396
397 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
403 let url = format!("{}/v1/pin", self.base);
404 Ok(ureq::get(&url)
405 .query("workspace", workspace)
406 .timeout(timeout())
407 .call()
408 .map_err(|e| refused(&url, e))?
409 .into_json()?)
410 }
411
412 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
418 let url = format!("{}/v1/pin", self.base);
419 Ok(ureq::put(&url)
420 .timeout(timeout())
421 .send_json(serde_json::json!({ "workspace": workspace, "name": name }))
422 .map_err(|e| refused(&url, e))?
423 .into_json()?)
424 }
425
426 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
435 let url = format!("{}/v1/accessions", self.base);
436 let body: serde_json::Value = ureq::get(&url)
437 .query("workspace", workspace)
438 .timeout(timeout())
439 .call()
440 .map_err(|e| refused(&url, e))?
441 .into_json()?;
442 let found = body
443 .get("accessions")
444 .cloned()
445 .unwrap_or(serde_json::Value::Array(vec![]));
446 Ok(serde_json::from_value(found)?)
447 }
448 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
457 self.atoms_as_of(workspace, None)
458 }
459
460 pub fn atoms_of_kind(
467 &self,
468 workspace: &str,
469 kind: &str,
470 ) -> Result<Vec<serde_json::Value>, Error> {
471 let url = format!("{}/v1/atoms", self.base);
472 let body: serde_json::Value = ureq::get(&url)
473 .query("workspace", workspace)
474 .query("kind", kind)
475 .timeout(timeout())
476 .call()
477 .map_err(|e| refused(&url, e))?
478 .into_json()?;
479 Ok(body
480 .get("atoms")
481 .and_then(serde_json::Value::as_array)
482 .cloned()
483 .unwrap_or_default())
484 }
485
486 pub fn citers(
492 &self,
493 workspace: &str,
494 accession: &str,
495 ) -> Result<Vec<serde_json::Value>, Error> {
496 let url = format!("{}/v1/citers", self.base);
497 let body: serde_json::Value = ureq::get(&url)
498 .query("workspace", workspace)
499 .query("accession", accession)
500 .timeout(timeout())
501 .call()
502 .map_err(|e| refused(&url, e))?
503 .into_json()?;
504 let found = body
505 .get("atoms")
506 .cloned()
507 .unwrap_or(serde_json::Value::Array(vec![]));
508 Ok(serde_json::from_value(found)?)
509 }
510
511 pub fn delete_atom(
524 &self,
525 workspace: &str,
526 id: &str,
527 why: Option<&str>,
528 ) -> Result<serde_json::Value, Error> {
529 let url = format!("{}/v1/atoms/delete", self.base);
530 let mut body = serde_json::json!({
531 "workspace": workspace,
532 "id": id,
533 });
534 if let Some(accession) = why {
535 body["why"] = serde_json::Value::String(accession.to_string());
536 }
537 let resp = match ureq::post(&url).timeout(timeout()).send_json(body) {
538 Ok(resp) => resp,
539 Err(ureq::Error::Status(404, _)) => {
540 return Err(Error::Bad(format!("no atom {id}")));
541 }
542 Err(e) => return Err(refused(&url, e)),
543 };
544 Ok(resp.into_json()?)
545 }
546
547 pub fn grade(
549 &self,
550 workspace: &str,
551 id: &str,
552 recalled: bool,
553 ) -> Result<serde_json::Value, Error> {
554 let url = format!("{}/v1/grade", self.base);
555 let body: serde_json::Value = ureq::post(&url)
556 .timeout(timeout())
557 .send_json(serde_json::json!({
558 "workspace": workspace,
559 "id": id,
560 "recalled": recalled,
561 }))
562 .map_err(|e| refused(&url, e))?
563 .into_json()?;
564 Ok(body)
565 }
566
567 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
570 let url = format!("{}/v1/hubs", self.base);
571 let body: serde_json::Value = ureq::get(&url)
572 .query("workspace", workspace)
573 .query("limit", &limit.to_string())
574 .timeout(timeout())
575 .call()
576 .map_err(|e| refused(&url, e))?
577 .into_json()?;
578 Ok(body)
579 }
580
581 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
582 let url = format!("{}/v1/islands", self.base);
583 let body: serde_json::Value = ureq::get(&url)
584 .query("workspace", workspace)
585 .timeout(timeout())
586 .call()
587 .map_err(|e| refused(&url, e))?
588 .into_json()?;
589 Ok(body)
590 }
591
592 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
594 self.fire_as(workspace, ids, None)
595 }
596
597 pub fn fire_as(
604 &self,
605 workspace: &str,
606 ids: &[String],
607 lens: Option<&str>,
608 ) -> Result<serde_json::Value, Error> {
609 let url = format!("{}/v1/fire", self.base);
610 let body: serde_json::Value = ureq::post(&url)
611 .timeout(timeout())
612 .send_json(
613 serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
614 )
615 .map_err(|e| refused(&url, e))?
616 .into_json()?;
617 Ok(body)
618 }
619
620 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
624 let url = format!("{}/v1/consolidate", self.base);
625 let body: serde_json::Value = ureq::post(&url)
626 .timeout(timeout())
627 .send_json(serde_json::json!({"workspace": workspace, "apply": apply}))
628 .map_err(|e| refused(&url, e))?
629 .into_json()?;
630 Ok(body)
631 }
632
633 pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
640 let url = format!("{}/v1/sweep", self.base);
641 let body: serde_json::Value = ureq::post(&url)
642 .timeout(timeout())
643 .send_json(serde_json::json!({"workspace": workspace}))
644 .map_err(|e| refused(&url, e))?
645 .into_json()?;
646 Ok(body)
647 }
648
649 pub fn activate(
652 &self,
653 workspace: &str,
654 q: &str,
655 limit: u32,
656 fire: bool,
657 ) -> Result<serde_json::Value, Error> {
658 self.activate_as(workspace, q, limit, fire, None)
659 }
660
661 pub fn activate_as(
668 &self,
669 workspace: &str,
670 q: &str,
671 limit: u32,
672 fire: bool,
673 lens: Option<&str>,
674 ) -> Result<serde_json::Value, Error> {
675 let url = format!("{}/v1/activate", self.base);
676 let mut req = ureq::get(&url)
677 .query("workspace", workspace)
678 .query("q", q)
679 .query("limit", &limit.to_string())
680 .query("fire", if fire { "1" } else { "0" })
681 .timeout(timeout());
682 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
683 req = req.query("as", name);
684 }
685 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
686 Ok(body)
687 }
688
689 pub fn atoms_in_set(
696 &self,
697 workspace: &str,
698 set: &str,
699 ) -> Result<Vec<serde_json::Value>, Error> {
700 let url = format!("{}/v1/pack", self.base);
701 let body: serde_json::Value = ureq::get(&url)
702 .query("workspace", workspace)
703 .query("set", set)
704 .timeout(timeout())
705 .call()
706 .map_err(|e| refused(&url, e))?
707 .into_json()?;
708 Ok(body
709 .get("atoms")
710 .and_then(serde_json::Value::as_array)
711 .cloned()
712 .unwrap_or_default())
713 }
714
715 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
716 let url = format!("{}/v1/atoms", self.base);
717 let body: serde_json::Value = ureq::post(&url)
718 .timeout(timeout())
719 .send_json(atom.clone())
720 .map_err(|e| refused(&url, e))?
721 .into_json()?;
722 Ok(body)
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn resolved_workspace_reads_ljos_env_not_default() {
732 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
733 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
734 std::fs::write(
735 dir.join(".config/ljos/env"),
736 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
737 )
738 .unwrap();
739 let old_home = env::var("HOME").ok();
740 let old_ws = env::var("PACKSET_WORKSPACE").ok();
741 unsafe {
742 env::remove_var("PACKSET_WORKSPACE");
743 env::set_var("HOME", &dir);
744 }
745 let got = resolved_workspace();
746 unsafe {
747 match old_home {
748 Some(h) => env::set_var("HOME", h),
749 None => env::remove_var("HOME"),
750 }
751 match old_ws {
752 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
753 None => env::remove_var("PACKSET_WORKSPACE"),
754 }
755 }
756 assert_eq!(got, "git:example.com/seat/notes");
757 }
758
759 #[test]
760 fn resolved_workspace_without_env_is_seat_not_default() {
761 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
762 std::fs::create_dir_all(&dir).unwrap();
763 let old_home = env::var("HOME").ok();
764 let old_ws = env::var("PACKSET_WORKSPACE").ok();
765 unsafe {
766 env::remove_var("PACKSET_WORKSPACE");
767 env::set_var("HOME", &dir);
768 }
769 let got = resolved_workspace();
770 unsafe {
771 match old_home {
772 Some(h) => env::set_var("HOME", h),
773 None => env::remove_var("HOME"),
774 }
775 match old_ws {
776 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
777 None => env::remove_var("PACKSET_WORKSPACE"),
778 }
779 }
780 assert_eq!(got, "seat");
781 assert_ne!(got, "default");
782 }
783}