1use serde::{Deserialize, Serialize};
7use std::env;
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
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
21const BUSY_WAIT: Duration = Duration::from_millis(50);
23const BUSY_RETRIES: u32 = 3;
25
26fn send(
31 req: ureq::Request,
32 body: Option<&serde_json::Value>,
33 budget: Duration,
34) -> Result<ureq::Response, Box<ureq::Error>> {
35 let started = Instant::now();
36 let mut wait = BUSY_WAIT;
37 let mut retries = 0;
38 loop {
39 let left = budget
40 .saturating_sub(started.elapsed())
41 .max(Duration::from_millis(1));
42 let attempt = req.clone().timeout(left);
43 let answered = match body {
44 Some(json) => attempt.send_json(json),
45 None => attempt.call(),
46 };
47 match answered {
48 Err(ureq::Error::Status(503, _))
49 if retries < BUSY_RETRIES && started.elapsed() + wait < budget =>
50 {
51 std::thread::sleep(wait);
52 wait *= 3;
53 retries += 1;
54 }
55 other => return other.map_err(Box::new),
56 }
57 }
58}
59
60fn path_seg(id: &str) -> String {
61 let mut out = String::with_capacity(id.len());
62 for b in id.bytes() {
63 match b {
64 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
65 out.push(b as char)
66 }
67 _ => out.push_str(&format!("%{b:02X}")),
68 }
69 }
70 out
71}
72
73pub const DEFAULT_PORT: u16 = 8761;
76
77pub fn load_seat_env() {
80 let Some(home) = env::var_os("HOME") else {
81 return;
82 };
83 let path = PathBuf::from(home).join(".config/ljos/env");
84 let Ok(text) = std::fs::read_to_string(path) else {
85 return;
86 };
87 for line in text.lines() {
88 let line = line.trim();
89 if line.is_empty() || line.starts_with('#') {
90 continue;
91 }
92 let Some((k, v)) = line.split_once('=') else {
93 continue;
94 };
95 let k = k.trim();
96 if k.is_empty() || env::var_os(k).is_some() {
97 continue;
98 }
99 env::set_var(k, v.trim());
100 }
101}
102
103#[must_use]
108pub fn resolved_workspace() -> String {
109 load_seat_env();
110 env::var("PACKSET_WORKSPACE")
111 .ok()
112 .map(|w| w.trim().to_string())
113 .filter(|w| !w.is_empty())
114 .unwrap_or_else(|| "seat".to_string())
115}
116
117#[must_use]
119pub fn default_port() -> u16 {
120 env::var("PACKSET_PORT")
121 .or_else(|_| env::var("GROK_MEM_PORT"))
122 .ok()
123 .and_then(|raw| raw.trim().parse().ok())
124 .unwrap_or(DEFAULT_PORT)
125}
126
127#[must_use]
133pub fn token_path() -> Option<PathBuf> {
134 if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
135 return Some(PathBuf::from(file));
136 }
137 packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
138}
139
140pub const TOKEN_FILE: &str = "token";
142
143#[must_use]
147pub fn token() -> Option<String> {
148 if let Ok(t) = env::var("PACKSET_TOKEN") {
149 let t = t.trim().to_string();
150 if !t.is_empty() {
151 return Some(t);
152 }
153 }
154 let text = std::fs::read_to_string(token_path()?).ok()?;
155 let t = text.lines().next()?.trim().to_string();
156 (!t.is_empty()).then_some(t)
157}
158
159pub fn authorize(request: ureq::Request) -> ureq::Request {
166 if foreign_listener(request.url()) {
167 return request;
168 }
169 match token() {
170 Some(t) => request.set("Authorization", &format!("Bearer {t}")),
171 None => request,
172 }
173}
174
175fn loopback_port(url: &str) -> Option<u16> {
177 let rest = url
178 .strip_prefix("http://")
179 .or_else(|| url.strip_prefix("https://"))?;
180 let authority = rest.split(['/', '?', '#']).next()?;
181 let (host, port) = match authority.rsplit_once(':') {
182 Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
183 _ => (authority, 80),
184 };
185 matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
186}
187
188fn listen_uids(table: &str, port: u16) -> Vec<u32> {
192 table
193 .lines()
194 .skip(1)
195 .filter_map(|line| {
196 let fields: Vec<&str> = line.split_whitespace().collect();
197 let local = fields.get(1)?;
198 if fields.get(3) != Some(&"0A") {
199 return None;
200 }
201 let hex = local.rsplit(':').next()?;
202 if u16::from_str_radix(hex, 16).ok()? != port {
203 return None;
204 }
205 fields.get(7)?.parse().ok()
206 })
207 .collect()
208}
209
210fn foreign_listener(url: &str) -> bool {
214 #[cfg(target_os = "linux")]
215 {
216 use std::os::unix::fs::MetadataExt;
217 let Some(port) = loopback_port(url) else {
218 return false;
219 };
220 let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
221 return false;
222 };
223 let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
224 .iter()
225 .filter_map(|t| std::fs::read_to_string(t).ok())
226 .flat_map(|text| listen_uids(&text, port))
227 .collect();
228 !owners.is_empty() && owners.iter().all(|uid| *uid != me)
229 }
230 #[cfg(not(target_os = "linux"))]
231 {
232 let _ = url;
233 false
234 }
235}
236
237#[derive(Debug, thiserror::Error)]
238pub enum Error {
239 #[error("packset url missing")]
240 NoUrl,
241 #[error("http: {0}")]
242 Http(#[from] Box<ureq::Error>),
243 #[error("io: {0}")]
244 Io(#[from] std::io::Error),
245 #[error("json: {0}")]
246 Json(#[from] serde_json::Error),
247 #[error("bad response: {0}")]
248 Bad(String),
249}
250
251#[derive(Debug, Clone)]
252pub struct PacksetClient {
253 base: String,
254 workspace: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Hit {
261 pub id: Option<String>,
262 pub text: String,
263 #[serde(default)]
264 pub score: f64,
265 #[serde(default)]
266 pub kind: String,
267 #[serde(default)]
270 pub ts: Option<String>,
271 #[serde(default)]
276 pub entities: Vec<String>,
277 #[serde(default)]
278 pub ballots: Option<u32>,
279 #[serde(default)]
280 pub of: Option<u32>,
281 #[serde(default)]
284 pub rerank_score: Option<f64>,
285}
286
287fn refused(url: &str, e: Box<ureq::Error>) -> Error {
289 match *e {
290 ureq::Error::Status(code, response) => {
291 let text = response.into_string().unwrap_or_default();
292 let reason = serde_json::from_str::<serde_json::Value>(&text)
293 .ok()
294 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
295 .unwrap_or(text);
296 let reason = reason.trim();
297 if code == 401 {
298 return Error::Bad(format!(
299 "{url}: 401: {reason}; this seat's token does not match the writer's, \
300 so it is another user's writer or PACKSET_HOME names another pack"
301 ));
302 }
303 if reason.is_empty() {
304 Error::Bad(format!("{url}: status code {code}"))
305 } else {
306 Error::Bad(format!("{url}: {code}: {reason}"))
307 }
308 }
309 other => Error::Http(Box::new(other)),
310 }
311}
312
313impl PacksetClient {
314 pub fn new(base: impl Into<String>) -> Self {
315 let mut base = base.into();
316 while base.ends_with('/') {
317 base.pop();
318 }
319 Self {
320 base,
321 workspace: None,
322 }
323 }
324
325 #[must_use]
329 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
330 let workspace = workspace.into();
331 self.workspace = (!workspace.is_empty()).then_some(workspace);
332 self
333 }
334
335 pub fn from_env() -> Result<Self, Error> {
340 load_seat_env();
341 let url = env::var("PACKSET_URL")
342 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
343 .ok()
344 .filter(|url| !url.is_empty());
345 match url {
346 Some(url) if url == "off" => Err(Error::NoUrl),
347 Some(url) => Ok(Self::new(url)),
348 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
349 }
350 }
351
352 pub fn base(&self) -> &str {
353 &self.base
354 }
355
356 pub fn workspace(&self) -> String {
357 if let Some(w) = &self.workspace {
358 return w.clone();
359 }
360 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
361 if !w.is_empty() {
362 return w;
363 }
364 }
365 let cwd = env::var("GROKOS_WORKSPACE")
366 .ok()
367 .map(|s| s.trim().to_string())
368 .filter(|s| !s.is_empty())
369 .map(std::path::PathBuf::from)
370 .or_else(|| env::current_dir().ok())
371 .unwrap_or_else(|| std::path::PathBuf::from("."));
372 self.workspace_for_cwd(&cwd)
373 }
374
375 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
377 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
378 let url = format!("{}/v1/identity", self.base);
379 let body = send(
380 authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
381 None,
382 timeout(),
383 )
384 .ok()
385 .and_then(|r| r.into_string().ok());
386 if let Some(body) = body {
387 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
388 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
389 if !ws.is_empty() {
390 return ws.to_string();
391 }
392 }
393 }
394 }
395 format!("dir:{}", abs.display())
396 }
397
398 pub fn health(&self) -> Result<String, Error> {
399 let url = format!("{}/health", self.base);
400 let body = send(authorize(ureq::get(&url)), None, timeout())
401 .map_err(|e| refused(&url, e))?
402 .into_string()?;
403 Ok(body)
404 }
405
406 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
407 let encoded = path_seg(id);
408 let url = format!("{}/v1/atoms/{encoded}", self.base);
409 let resp = match send(
410 authorize(ureq::get(&url)).query("workspace", workspace),
411 None,
412 timeout(),
413 ) {
414 Ok(resp) => resp,
415 Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
416 return Err(Error::Bad(format!("no atom {id}")));
417 }
418 Err(e) => return Err(Error::Http(e)),
419 };
420 Ok(resp.into_json()?)
421 }
422
423 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
424 self.atoms_as_of(workspace, None)
425 }
426
427 pub fn atoms_as_of(
433 &self,
434 workspace: &str,
435 as_of: Option<&str>,
436 ) -> Result<Vec<serde_json::Value>, Error> {
437 let url = format!("{}/v1/atoms", self.base);
438 let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
439 if let Some(at) = as_of {
440 req = req.query("as_of", at);
441 }
442 let mut body: serde_json::Value = send(req, None, timeout())
443 .map_err(|e| refused(&url, e))?
444 .into_json()?;
445 let atoms = body
446 .get_mut("atoms")
447 .map(serde_json::Value::take)
448 .unwrap_or(serde_json::Value::Array(vec![]));
449 Ok(serde_json::from_value(atoms)?)
450 }
451
452 pub fn atoms_without_vectors(
460 &self,
461 workspace: &str,
462 as_of: Option<&str>,
463 ) -> Result<Vec<serde_json::Value>, Error> {
464 let url = format!("{}/v1/atoms", self.base);
465 let mut req = authorize(ureq::get(&url))
466 .query("workspace", workspace)
467 .query("embedding", "omit");
468 if let Some(at) = as_of {
469 req = req.query("as_of", at);
470 }
471 let mut body: serde_json::Value = send(req, None, timeout())
472 .map_err(|e| refused(&url, e))?
473 .into_json()?;
474 let atoms = body
475 .get_mut("atoms")
476 .map(serde_json::Value::take)
477 .unwrap_or(serde_json::Value::Array(vec![]));
478 Ok(serde_json::from_value(atoms)?)
479 }
480
481 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
482 self.search_as_of(workspace, q, limit, None)
483 }
484
485 pub fn search_as_of(
491 &self,
492 workspace: &str,
493 q: &str,
494 limit: u32,
495 as_of: Option<&str>,
496 ) -> Result<Vec<Hit>, Error> {
497 self.search_opts(workspace, q, limit, as_of, false)
498 }
499
500 pub fn search_opts(
510 &self,
511 workspace: &str,
512 q: &str,
513 limit: u32,
514 as_of: Option<&str>,
515 rerank: bool,
516 ) -> Result<Vec<Hit>, Error> {
517 self.search_ranked(workspace, q, limit, as_of, rerank, None)
518 }
519
520 pub fn search_ranked(
531 &self,
532 workspace: &str,
533 q: &str,
534 limit: u32,
535 as_of: Option<&str>,
536 rerank: bool,
537 rank: Option<&str>,
538 ) -> Result<Vec<Hit>, Error> {
539 let url = format!("{}/v1/search", self.base);
540 let budget = if rerank {
541 Duration::from_secs(60).max(timeout())
542 } else {
543 timeout()
544 };
545 let mut req = authorize(ureq::get(&url))
546 .query("workspace", workspace)
547 .query("q", q)
548 .query("limit", &limit.to_string());
549 if let Some(at) = as_of {
550 req = req.query("as_of", at);
551 }
552 if rerank {
553 req = req.query("rerank", "1");
554 }
555 if let Some(rank) = rank.map(str::trim).filter(|s| !s.is_empty()) {
556 req = req.query("rank", rank);
557 }
558 let body: serde_json::Value = send(req, None, budget)
559 .map_err(|e| refused(&url, e))?
560 .into_json()?;
561 let hits = body
562 .get("hits")
563 .cloned()
564 .unwrap_or(serde_json::Value::Array(vec![]));
565 Ok(serde_json::from_value(hits)?)
566 }
567
568 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
574 let url = format!("{}/v1/status", self.base);
575 let mut req = authorize(ureq::get(&url));
576 if let Some(workspace) = workspace {
577 req = req.query("workspace", workspace);
578 }
579 Ok(send(req, None, timeout())
580 .map_err(|e| refused(&url, e))?
581 .into_json()?)
582 }
583
584 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
590 let url = format!("{}/v1/pin", self.base);
591 Ok(send(
592 authorize(ureq::get(&url)).query("workspace", workspace),
593 None,
594 timeout(),
595 )
596 .map_err(|e| refused(&url, e))?
597 .into_json()?)
598 }
599
600 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
606 let url = format!("{}/v1/pin", self.base);
607 Ok(send(
608 authorize(ureq::put(&url)),
609 Some(&serde_json::json!({ "workspace": workspace, "name": name })),
610 timeout(),
611 )
612 .map_err(|e| refused(&url, e))?
613 .into_json()?)
614 }
615
616 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
625 let url = format!("{}/v1/accessions", self.base);
626 let body: serde_json::Value = send(
627 authorize(ureq::get(&url)).query("workspace", workspace),
628 None,
629 timeout(),
630 )
631 .map_err(|e| refused(&url, e))?
632 .into_json()?;
633 let found = body
634 .get("accessions")
635 .cloned()
636 .unwrap_or(serde_json::Value::Array(vec![]));
637 Ok(serde_json::from_value(found)?)
638 }
639 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
648 self.atoms_as_of(workspace, None)
649 }
650
651 pub fn atoms_of_kind(
658 &self,
659 workspace: &str,
660 kind: &str,
661 ) -> Result<Vec<serde_json::Value>, Error> {
662 let url = format!("{}/v1/atoms", self.base);
663 let body: serde_json::Value = send(
664 authorize(ureq::get(&url))
665 .query("workspace", workspace)
666 .query("kind", kind),
667 None,
668 timeout(),
669 )
670 .map_err(|e| refused(&url, e))?
671 .into_json()?;
672 Ok(body
673 .get("atoms")
674 .and_then(serde_json::Value::as_array)
675 .cloned()
676 .unwrap_or_default())
677 }
678
679 pub fn citers(
685 &self,
686 workspace: &str,
687 accession: &str,
688 ) -> Result<Vec<serde_json::Value>, Error> {
689 let url = format!("{}/v1/citers", self.base);
690 let body: serde_json::Value = send(
691 authorize(ureq::get(&url))
692 .query("workspace", workspace)
693 .query("accession", accession),
694 None,
695 timeout(),
696 )
697 .map_err(|e| refused(&url, e))?
698 .into_json()?;
699 let found = body
700 .get("atoms")
701 .cloned()
702 .unwrap_or(serde_json::Value::Array(vec![]));
703 Ok(serde_json::from_value(found)?)
704 }
705
706 pub fn delete_atom(
719 &self,
720 workspace: &str,
721 id: &str,
722 why: Option<&str>,
723 ) -> Result<serde_json::Value, Error> {
724 let url = format!("{}/v1/atoms/delete", self.base);
725 let mut body = serde_json::json!({
726 "workspace": workspace,
727 "id": id,
728 });
729 if let Some(accession) = why {
730 body["why"] = serde_json::Value::String(accession.to_string());
731 }
732 let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
733 Ok(resp) => resp,
734 Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
735 return Err(Error::Bad(format!("no atom {id}")));
736 }
737 Err(e) => return Err(refused(&url, e)),
738 };
739 Ok(resp.into_json()?)
740 }
741
742 pub fn grade(
744 &self,
745 workspace: &str,
746 id: &str,
747 recalled: bool,
748 ) -> Result<serde_json::Value, Error> {
749 let url = format!("{}/v1/grade", self.base);
750 let body: serde_json::Value = send(
751 authorize(ureq::post(&url)),
752 Some(&serde_json::json!({
753 "workspace": workspace,
754 "id": id,
755 "recalled": recalled,
756 })),
757 timeout(),
758 )
759 .map_err(|e| refused(&url, e))?
760 .into_json()?;
761 Ok(body)
762 }
763
764 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
767 let url = format!("{}/v1/hubs", self.base);
768 let body: serde_json::Value = send(
769 authorize(ureq::get(&url))
770 .query("workspace", workspace)
771 .query("limit", &limit.to_string()),
772 None,
773 timeout(),
774 )
775 .map_err(|e| refused(&url, e))?
776 .into_json()?;
777 Ok(body)
778 }
779
780 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
781 let url = format!("{}/v1/islands", self.base);
782 let body: serde_json::Value = send(
783 authorize(ureq::get(&url)).query("workspace", workspace),
784 None,
785 timeout(),
786 )
787 .map_err(|e| refused(&url, e))?
788 .into_json()?;
789 Ok(body)
790 }
791
792 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
794 self.fire_as(workspace, ids, None)
795 }
796
797 pub fn fire_as(
804 &self,
805 workspace: &str,
806 ids: &[String],
807 lens: Option<&str>,
808 ) -> Result<serde_json::Value, Error> {
809 let url = format!("{}/v1/fire", self.base);
810 let body: serde_json::Value = send(
811 authorize(ureq::post(&url)),
812 Some(
813 &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
814 ),
815 timeout(),
816 )
817 .map_err(|e| refused(&url, e))?
818 .into_json()?;
819 Ok(body)
820 }
821
822 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
826 let url = format!("{}/v1/consolidate", self.base);
827 let body: serde_json::Value = send(
828 authorize(ureq::post(&url)),
829 Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
830 timeout(),
831 )
832 .map_err(|e| refused(&url, e))?
833 .into_json()?;
834 Ok(body)
835 }
836
837 pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
844 let url = format!("{}/v1/sweep", self.base);
845 let body: serde_json::Value = send(
846 authorize(ureq::post(&url)),
847 Some(&serde_json::json!({"workspace": workspace})),
848 timeout(),
849 )
850 .map_err(|e| refused(&url, e))?
851 .into_json()?;
852 Ok(body)
853 }
854
855 pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
863 let url = format!("{}/v1/forget", self.base);
864 let body: serde_json::Value = send(
865 authorize(ureq::post(&url)),
866 Some(&serde_json::json!({"workspace": workspace})),
867 timeout(),
868 )
869 .map_err(|e| refused(&url, e))?
870 .into_json()?;
871 Ok(body)
872 }
873
874 pub fn activate(
877 &self,
878 workspace: &str,
879 q: &str,
880 limit: u32,
881 fire: bool,
882 ) -> Result<serde_json::Value, Error> {
883 self.activate_as(workspace, q, limit, fire, None)
884 }
885
886 pub fn activate_as(
893 &self,
894 workspace: &str,
895 q: &str,
896 limit: u32,
897 fire: bool,
898 lens: Option<&str>,
899 ) -> Result<serde_json::Value, Error> {
900 let url = format!("{}/v1/activate", self.base);
901 let mut req = authorize(ureq::get(&url))
902 .query("workspace", workspace)
903 .query("q", q)
904 .query("limit", &limit.to_string())
905 .query("fire", if fire { "1" } else { "0" });
906 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
907 req = req.query("as", name);
908 }
909 let body: serde_json::Value = send(req, None, timeout())
910 .map_err(|e| refused(&url, e))?
911 .into_json()?;
912 Ok(body)
913 }
914
915 pub fn atoms_in_set(
922 &self,
923 workspace: &str,
924 set: &str,
925 ) -> Result<Vec<serde_json::Value>, Error> {
926 let url = format!("{}/v1/pack", self.base);
927 let body: serde_json::Value = send(
928 authorize(ureq::get(&url))
929 .query("workspace", workspace)
930 .query("set", set),
931 None,
932 timeout(),
933 )
934 .map_err(|e| refused(&url, e))?
935 .into_json()?;
936 Ok(body
937 .get("atoms")
938 .and_then(serde_json::Value::as_array)
939 .cloned()
940 .unwrap_or_default())
941 }
942
943 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
944 let url = format!("{}/v1/atoms", self.base);
945 let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
946 .map_err(|e| refused(&url, e))?
947 .into_json()?;
948 Ok(body)
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
961
962 #[test]
963 fn only_a_loopback_url_has_a_port_to_check() {
964 assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
965 assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
966 assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
967 assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
968 assert_eq!(loopback_port("http://example.org:8761/"), None);
969 assert_eq!(loopback_port("http://[::1]/"), Some(80));
970 }
971
972 #[test]
973 fn the_owner_of_a_listening_port_is_read_off_the_table() {
974 let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
975 0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 4242 1 0 100 0 0 10 0
976 1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 4343 1 0 20 4 30 10 -1
977 2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4444 1 0 100 0 0 10 0
978";
979 assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
980 assert_eq!(listen_uids(table, 8080), vec![1000]);
981 assert!(listen_uids(table, 9).is_empty());
982 }
983
984 #[cfg(target_os = "linux")]
985 #[test]
986 fn a_port_this_user_holds_is_not_foreign() {
987 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
988 let port = held.local_addr().unwrap().port();
989 assert!(!foreign_listener(&format!(
990 "http://127.0.0.1:{port}/health"
991 )));
992 drop(held);
993 assert!(
994 !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
995 "nobody listening is not foreign"
996 );
997 }
998
999 #[test]
1000 fn resolved_workspace_reads_ljos_env_not_default() {
1001 let _env = ENV_LOCK.lock().unwrap();
1002 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
1003 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
1004 std::fs::write(
1005 dir.join(".config/ljos/env"),
1006 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
1007 )
1008 .unwrap();
1009 let old_home = env::var("HOME").ok();
1010 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1011 unsafe {
1012 env::remove_var("PACKSET_WORKSPACE");
1013 env::set_var("HOME", &dir);
1014 }
1015 let got = resolved_workspace();
1016 unsafe {
1017 match old_home {
1018 Some(h) => env::set_var("HOME", h),
1019 None => env::remove_var("HOME"),
1020 }
1021 match old_ws {
1022 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1023 None => env::remove_var("PACKSET_WORKSPACE"),
1024 }
1025 }
1026 assert_eq!(got, "git:example.com/seat/notes");
1027 }
1028
1029 #[test]
1030 fn resolved_workspace_without_env_is_seat_not_default() {
1031 let _env = ENV_LOCK.lock().unwrap();
1032 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1033 std::fs::create_dir_all(&dir).unwrap();
1034 let old_home = env::var("HOME").ok();
1035 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1036 unsafe {
1037 env::remove_var("PACKSET_WORKSPACE");
1038 env::set_var("HOME", &dir);
1039 }
1040 let got = resolved_workspace();
1041 unsafe {
1042 match old_home {
1043 Some(h) => env::set_var("HOME", h),
1044 None => env::remove_var("HOME"),
1045 }
1046 match old_ws {
1047 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1048 None => env::remove_var("PACKSET_WORKSPACE"),
1049 }
1050 }
1051 assert_eq!(got, "seat");
1052 assert_ne!(got, "default");
1053 }
1054
1055 fn busy_for(
1058 busy: usize,
1059 ) -> (
1060 PacksetClient,
1061 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1062 ) {
1063 use std::io::{BufRead, BufReader, Read, Write};
1064 use std::sync::atomic::Ordering;
1065 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1066 let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1067 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1068 let counted = std::sync::Arc::clone(&seen);
1069 std::thread::spawn(move || {
1070 for stream in listener.incoming().flatten() {
1071 let mut reader = BufReader::new(&stream);
1072 let mut length = 0;
1073 let mut line = String::new();
1074 while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1075 if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1076 length = v.trim().parse().unwrap_or(0);
1077 }
1078 line.clear();
1079 }
1080 let mut body = vec![0u8; length];
1081 let _ = reader.read_exact(&mut body);
1082 let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1083 ("503 Service Unavailable", r#"{"error":"busy"}"#)
1084 } else {
1085 ("200 OK", r#"{"ok":true}"#)
1086 };
1087 let _ = (&stream).write_all(
1088 format!(
1089 "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1090 text.len()
1091 )
1092 .as_bytes(),
1093 );
1094 }
1095 });
1096 (client, seen)
1097 }
1098
1099 #[test]
1100 fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1101 use std::sync::atomic::Ordering;
1102 let _env = ENV_LOCK.lock().unwrap();
1103 let (client, seen) = busy_for(2);
1104 assert_eq!(client.status(None).unwrap()["ok"], true);
1105 assert_eq!(seen.load(Ordering::SeqCst), 3);
1106 let (client, seen) = busy_for(1);
1107 let stored = client
1108 .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1109 .unwrap();
1110 assert_eq!(stored["ok"], true);
1111 assert_eq!(
1112 seen.load(Ordering::SeqCst),
1113 2,
1114 "the write was not sent again"
1115 );
1116 }
1117
1118 #[test]
1119 fn a_writer_busy_past_three_retries_is_a_refusal() {
1120 use std::sync::atomic::Ordering;
1121 let _env = ENV_LOCK.lock().unwrap();
1122 let (client, seen) = busy_for(usize::MAX);
1123 let err = client.status(None).unwrap_err().to_string();
1124 assert!(err.contains("503") && err.contains("busy"), "{err}");
1125 assert_eq!(seen.load(Ordering::SeqCst), 4);
1126 }
1127
1128 #[test]
1130 fn the_retries_stop_where_the_budget_does() {
1131 use std::sync::atomic::Ordering;
1132 let _env = ENV_LOCK.lock().unwrap();
1133 let old = env::var_os("PACKSET_TIMEOUT_MS");
1134 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "100") };
1135 let (client, seen) = busy_for(usize::MAX);
1136 let started = Instant::now();
1137 let answered = client.status(None);
1138 let took = started.elapsed();
1139 unsafe {
1140 match old {
1141 Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1142 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1143 }
1144 }
1145 assert!(answered.is_err());
1146 assert_eq!(seen.load(Ordering::SeqCst), 2);
1147 assert!(took < Duration::from_millis(300), "{took:?}");
1148 }
1149}