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 forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
657 let url = format!("{}/v1/forget", self.base);
658 let body: serde_json::Value = ureq::post(&url)
659 .timeout(timeout())
660 .send_json(serde_json::json!({"workspace": workspace}))
661 .map_err(|e| refused(&url, e))?
662 .into_json()?;
663 Ok(body)
664 }
665
666 pub fn activate(
669 &self,
670 workspace: &str,
671 q: &str,
672 limit: u32,
673 fire: bool,
674 ) -> Result<serde_json::Value, Error> {
675 self.activate_as(workspace, q, limit, fire, None)
676 }
677
678 pub fn activate_as(
685 &self,
686 workspace: &str,
687 q: &str,
688 limit: u32,
689 fire: bool,
690 lens: Option<&str>,
691 ) -> Result<serde_json::Value, Error> {
692 let url = format!("{}/v1/activate", self.base);
693 let mut req = ureq::get(&url)
694 .query("workspace", workspace)
695 .query("q", q)
696 .query("limit", &limit.to_string())
697 .query("fire", if fire { "1" } else { "0" })
698 .timeout(timeout());
699 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
700 req = req.query("as", name);
701 }
702 let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
703 Ok(body)
704 }
705
706 pub fn atoms_in_set(
713 &self,
714 workspace: &str,
715 set: &str,
716 ) -> Result<Vec<serde_json::Value>, Error> {
717 let url = format!("{}/v1/pack", self.base);
718 let body: serde_json::Value = ureq::get(&url)
719 .query("workspace", workspace)
720 .query("set", set)
721 .timeout(timeout())
722 .call()
723 .map_err(|e| refused(&url, e))?
724 .into_json()?;
725 Ok(body
726 .get("atoms")
727 .and_then(serde_json::Value::as_array)
728 .cloned()
729 .unwrap_or_default())
730 }
731
732 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
733 let url = format!("{}/v1/atoms", self.base);
734 let body: serde_json::Value = ureq::post(&url)
735 .timeout(timeout())
736 .send_json(atom.clone())
737 .map_err(|e| refused(&url, e))?
738 .into_json()?;
739 Ok(body)
740 }
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746
747 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
752
753 #[test]
754 fn resolved_workspace_reads_ljos_env_not_default() {
755 let _env = ENV_LOCK.lock().unwrap();
756 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
757 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
758 std::fs::write(
759 dir.join(".config/ljos/env"),
760 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
761 )
762 .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, "git:example.com/seat/notes");
781 }
782
783 #[test]
784 fn resolved_workspace_without_env_is_seat_not_default() {
785 let _env = ENV_LOCK.lock().unwrap();
786 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
787 std::fs::create_dir_all(&dir).unwrap();
788 let old_home = env::var("HOME").ok();
789 let old_ws = env::var("PACKSET_WORKSPACE").ok();
790 unsafe {
791 env::remove_var("PACKSET_WORKSPACE");
792 env::set_var("HOME", &dir);
793 }
794 let got = resolved_workspace();
795 unsafe {
796 match old_home {
797 Some(h) => env::set_var("HOME", h),
798 None => env::remove_var("HOME"),
799 }
800 match old_ws {
801 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
802 None => env::remove_var("PACKSET_WORKSPACE"),
803 }
804 }
805 assert_eq!(got, "seat");
806 assert_ne!(got, "default");
807 }
808}