Skip to main content

packset_client/
lib.rs

1//! Loopback HTTP client for packsetd.
2//!
3//! Reads `PACKSET_URL` or `INSIDE_MEMORY_URL`. search/get against packsetd; no SQLite.
4//! Does not open LMDB.
5
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// How long one request may take: `PACKSET_TIMEOUT_MS`, else thirty seconds.
12/// A write that waits behind thirty others on a busy seat is late, not failed.
13fn 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
34/// The port a writer listens on when nothing names one. The command line,
35/// the server and this client agree on it, so a seat needs no variable set.
36pub const DEFAULT_PORT: u16 = 8761;
37
38/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
39/// those keys. `ljos`, `packset`, and `packset-mcp` then share one pack.
40pub 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/// The workspace the seat's memory lives in: `PACKSET_WORKSPACE` after
65/// loading the seat env file, else `seat`. Not `default`, and not the
66/// working directory's git remote: those two are how one harness's
67/// remember missed the other's sitting.
68#[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/// `PACKSET_PORT` (`GROK_MEM_PORT` is an alias), else [`DEFAULT_PORT`].
79#[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    /// A workspace pinned by the caller; `None` reads the environment and
106    /// the working directory.
107    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    /// When the memory was written, the writer's clock, RFC 3339. Absent
119    /// on card paragraphs, which have no clock.
120    #[serde(default)]
121    pub ts: Option<String>,
122    /// How many of the panel's ballots named this hit, and how many ran.
123    /// Two of three is agreement; one of three is one scorer's opinion.
124    /// The atom's entities: `seat:<name>` names the seat that wrote it,
125    /// `persona:<name>` a persona's own claim, `habit:<name>` a reading.
126    #[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
134/// A refusal, carrying the reason the writer gave in its body.
135fn 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    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
167    /// and the working directory. A seat that is one memory across every
168    /// repository it works in sets this once.
169    #[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    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
177    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
178    /// line starts a writer on, `PACKSET_PORT` (`GROK_MEM_PORT`) or 8761.
179    /// `PACKSET_URL=off` is the one way to have no pack.
180    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    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
217    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    /// Live-now atoms, or the ones that were live at `as_of`.
270    ///
271    /// # Errors
272    ///
273    /// The request's, or a body that is not JSON.
274    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    /// [`Self::atoms_as_of`] without the dense vectors, for a reader of
295    /// texts, review clocks or rules: the vectors are most of the bytes. A
296    /// writer older than the `embedding=omit` query sends them anyway.
297    ///
298    /// # Errors
299    ///
300    /// The pack not answering, or an answer that is not atoms.
301    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    /// Ranked hits, optionally over the atoms that were live at `as_of`.
327    ///
328    /// # Errors
329    ///
330    /// The request's, or a body that is not JSON.
331    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    /// Ranked hits, optionally dated and optionally through the measured
342    /// cross-encoder stage.
343    ///
344    /// Off by default. On, the writer spends a forward pass per candidate and
345    /// the request waits for that rather than the usual five-second budget.
346    ///
347    /// # Errors
348    ///
349    /// The request's, or a body that is not a hit list.
350    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    /// Seat home, atom counts by kind, pin, index and embedder.
384    ///
385    /// # Errors
386    ///
387    /// The request's, or a body that is not JSON.
388    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    /// The set a workspace is pinned to.
398    ///
399    /// # Errors
400    ///
401    /// The request's, or a body that is not JSON.
402    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    /// Pin a workspace to a set.
413    ///
414    /// # Errors
415    ///
416    /// The request's, or a body that is not JSON.
417    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    /// The deed accessions a workspace's live atoms cite, sorted.
427    ///
428    /// The accession is the only identifier crossing the tracker, the pack and
429    /// the deed store, so this is what `deedar evidence -` reads.
430    ///
431    /// # Errors
432    ///
433    /// The request's, or a body that is not JSON.
434    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    /// Every live atom in a workspace.
449    ///
450    /// The bodies, not the join keys: this is what a handover carries when
451    /// somebody is given what the seat learned rather than only what it cites.
452    ///
453    /// # Errors
454    ///
455    /// The request's, or a body that is not JSON.
456    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
457        self.atoms_as_of(workspace, None)
458    }
459
460    /// The live atoms of one kind in a workspace: personas, trust rows,
461    /// habits, without the rest of the pack.
462    ///
463    /// # Errors
464    ///
465    /// The request's, or a body that is not JSON.
466    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    /// The live atoms in a workspace that cite one deed accession.
487    ///
488    /// # Errors
489    ///
490    /// The request's, or a body that is not JSON.
491    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    /// Tombstone one atom. The daemon keeps the record and drops the index
512    /// entry, so a forgotten atom stops being recalled without the pack losing
513    /// the fact that it once held it.
514    ///
515    /// `why` is the deed accession that withdrew the claim, and the daemon
516    /// refuses one that is not an accession. It rides onto the tombstone beside
517    /// the text, so the retraction and what it retracted read back together.
518    ///
519    /// # Errors
520    ///
521    /// [`Error::Bad`] when the workspace does not hold that atom, else the
522    /// request's or a body that is not JSON.
523    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    /// Move one atom along the review clock: recalled, or lapsed.
548    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    /// The link graph's communities, largest first.
568    /// The claims the link graph turns on, highest first.
569    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    /// Claims that fired together: their links gain weight.
593    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
594        self.fire_as(workspace, ids, None)
595    }
596
597    /// [`Self::fire`] through a persona's lens: the weights move under its
598    /// name and the seat's stand.
599    ///
600    /// # Errors
601    ///
602    /// The request's, or a body that is not JSON.
603    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    /// Consolidate the workspace: every claim that replaces an earlier one
621    /// closes it (the write-time rule, run over what is held). `apply`
622    /// false reports the pairs and writes nothing.
623    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    /// Sweep a workspace for neglect: reviews left due past twice their
634    /// interval lapse, and the third miss forgets a never-recalled lesson.
635    ///
636    /// # Errors
637    ///
638    /// The request's, or a body that is not JSON.
639    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    /// The memories a cue activates, strongest first; with `fire`, the top
650    /// of them fire together.
651    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    /// [`Self::activate`] through a persona's lens: the spread follows the
662    /// weights that persona wrote, and a fire writes them.
663    ///
664    /// # Errors
665    ///
666    /// The request's, or a body that is not JSON.
667    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    /// The live atoms of one named set in a workspace: a persona's own
690    /// conclusions, a pinned slice.
691    ///
692    /// # Errors
693    ///
694    /// The request's, or a body that is not JSON.
695    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}