Skip to main content

brep_app/plm/
client.rs

1//! Sign-in, the refusals, the change feed and the version check (plan S1).
2//!
3//! [`PlmClient`] speaks the PLM's API over any [`PlmTransport`]. It owns the
4//! credential, so a transport only carries bytes:
5//!
6//! - **A token** (`Authorization: Bearer plm_…`) for the native app, the bake
7//!   worker and tooling. A user pastes one, or [`PlmClient::sign_in_for_token`]
8//!   trades a username and password for one: it signs in, mints a token through
9//!   that session, and signs the session out again, so the machine keeps only
10//!   the token (D5).
11//! - **The session cookie** when the page is served by the PLM itself (D6). The
12//!   browser holds the cookie; the client sends `X-CSRF-Token` with every change,
13//!   read from `/api/me`'s `csrf`.
14//!
15//! Every refusal carries the sentence the server sent ([`PlmError`]). `204` is
16//! not one: [`PlmClient::get_document`] answers `None` for a revision with no
17//! document yet.
18//!
19//! **The version check** runs on every sign-in, before the client counts as
20//! connected. On a mismatch the sign-in fails with [`PlmError::Version`], which
21//! names both versions, and the credential is dropped. The file stores are
22//! untouched either way; nothing here knows they exist.
23use super::{PlmRequest, PlmResponse, PlmTransport};
24use serde::Deserialize;
25use serde_json::{json, Value};
26use std::cell::RefCell;
27use std::rc::Rc;
28
29/// Sign in with a configured token and compose the session's store backend
30/// (`plm::backend`): the native boot's one call. (The web boot signs in with
31/// the browser's session instead: `plm::backend::open_web_session`.)
32#[cfg(not(target_arch = "wasm32"))]
33pub(crate) use super::backend::open_session;
34
35/// This app's version, as the PLM's `min_client_version` is compared against.
36pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
37
38/// Why a call to the PLM did not do what was asked. Each variant keeps the
39/// server's own sentence; `Display` shows it as the user should read it.
40#[derive(Debug, Clone, PartialEq)]
41pub enum PlmError {
42    /// No answer at all: the server is down, the address is wrong, the network
43    /// dropped.
44    Unreachable(String),
45    /// `401`: the credential is missing, expired or revoked. Sign in again.
46    SignIn(String),
47    /// `403`: signed in, but the account or the token's scope may not do this.
48    Forbidden(String),
49    /// `404`: the part, revision or route does not exist.
50    NotFound(String),
51    /// `409`: locked by someone else, not checked out, or not editable. For a
52    /// document write it arrives after `write()` returned, through the store's
53    /// write-behind error lane.
54    Conflict(String),
55    /// `413`: over the server's attachment limit.
56    TooLarge(String),
57    /// `429`: the sign-in throttle.
58    Throttled(String),
59    /// Any other refusal (`400`, `5xx`), with its status.
60    Refused { status: u16, message: String },
61    /// The answer was not what the contract says (unparseable JSON, a missing
62    /// field).
63    Malformed(String),
64    /// Signed in, but this app and this server do not serve each other. Both
65    /// versions are named. The file stores stay usable; the boot that falls
66    /// back to them says so after this sentence.
67    Version { server: String, min_client: String, client: String, reason: String },
68}
69
70impl PlmError {
71    /// The HTTP status behind a refusal, if there was one.
72    pub fn status(&self) -> Option<u16> {
73        match self {
74            PlmError::SignIn(_) => Some(401),
75            PlmError::Forbidden(_) => Some(403),
76            PlmError::NotFound(_) => Some(404),
77            PlmError::Conflict(_) => Some(409),
78            PlmError::TooLarge(_) => Some(413),
79            PlmError::Throttled(_) => Some(429),
80            PlmError::Refused { status, .. } => Some(*status),
81            PlmError::Unreachable(_) | PlmError::Malformed(_) | PlmError::Version { .. } => None,
82        }
83    }
84
85    /// Map a non-success answer to its refusal, keeping the server's sentence
86    /// (`{"error": "..."}`, else the body text, else a sentence of our own).
87    pub fn from_response(response: &PlmResponse) -> Self {
88        let sentence = server_sentence(response);
89        let or = |fallback: &str| if sentence.is_empty() { fallback.to_string() } else { sentence.clone() };
90        match response.status {
91            401 => PlmError::SignIn(or("sign in again")),
92            403 => PlmError::Forbidden(or("you may not do that")),
93            404 => PlmError::NotFound(or("not found")),
94            409 => PlmError::Conflict(or("refused: the revision is locked or not editable")),
95            413 => PlmError::TooLarge(or("too large for the server's limit")),
96            429 => PlmError::Throttled(or("too many sign-in attempts; wait and try again")),
97            status => PlmError::Refused { status, message: or(&format!("the server answered {status}")) },
98        }
99    }
100}
101
102impl std::fmt::Display for PlmError {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            PlmError::Unreachable(why) => write!(f, "the PLM server did not answer: {why}"),
106            PlmError::SignIn(s) => write!(f, "sign in again: {s}"),
107            PlmError::Forbidden(s)
108            | PlmError::NotFound(s)
109            | PlmError::Conflict(s)
110            | PlmError::TooLarge(s)
111            | PlmError::Throttled(s) => f.write_str(s),
112            PlmError::Refused { message, .. } => f.write_str(message),
113            PlmError::Malformed(why) => write!(f, "the PLM server's answer was not understood: {why}"),
114            PlmError::Version { server, min_client, client, reason } => write!(
115                f,
116                "this CAD app ({client}) cannot use this PLM server ({server}, which serves CAD {min_client} and later): {reason}."
117            ),
118        }
119    }
120}
121
122/// `Accept` and the credential's headers: the bearer token, or the session
123/// cookie with the CSRF token on anything but a `GET`.
124fn credential_headers(credential: &Credential, method: &str) -> Vec<(String, String)> {
125    let mut headers = vec![("Accept".to_string(), "application/json".to_string())];
126    match credential {
127        Credential::Anonymous => {}
128        Credential::Token(token) => headers.push(("Authorization".into(), format!("Bearer {token}"))),
129        Credential::Session { cookie, csrf } => {
130            if let Some(cookie) = cookie {
131                headers.push(("Cookie".into(), cookie.clone()));
132            }
133            if method != "GET" {
134                if let Some(csrf) = csrf {
135                    headers.push(("X-CSRF-Token".into(), csrf.clone()));
136                }
137            }
138        }
139    }
140    headers
141}
142
143fn server_sentence(response: &PlmResponse) -> String {
144    if let Ok(v) = serde_json::from_slice::<Value>(&response.body) {
145        if let Some(s) = v.get("error").and_then(Value::as_str) {
146            return s.to_string();
147        }
148    }
149    String::from_utf8_lossy(&response.body).trim().chars().take(500).collect()
150}
151
152/// The PLM's `/api/me`, read tolerantly: fields this client does not know are
153/// ignored and a missing one takes its default.
154#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
155#[serde(default)]
156pub struct Me {
157    pub id: String,
158    pub username: String,
159    pub display_name: String,
160    pub groups: Vec<String>,
161    /// `"token"` or `"session"`.
162    pub via: Option<String>,
163    /// A session's CSRF token (session sign-ins only).
164    pub csrf: Option<String>,
165    /// P1: the server's version (`BREP_plm`'s own).
166    pub server_version: Option<String>,
167    /// P1: the oldest CAD app (`BREP_app` version) the server serves.
168    pub min_client_version: Option<String>,
169    /// What the server has beyond the base contract (`workspaces`,
170    /// `attribute-filters`, `external-ref`, …). Absent on a server that
171    /// predates the list: then it has none of them. Names are only added.
172    #[serde(default)]
173    pub features: Vec<String>,
174}
175
176impl Me {
177    /// Whether the server says it has `feature`.
178    pub fn has(&self, feature: &str) -> bool {
179        self.features.iter().any(|f| f == feature)
180    }
181}
182
183/// How the client proves who it is.
184#[derive(Debug, Clone, PartialEq)]
185pub enum Credential {
186    Anonymous,
187    /// `Authorization: Bearer <token>`.
188    Token(String),
189    /// A session: the browser holds the cookie on the web (`cookie: None`); a
190    /// native password sign-in carries it itself, only long enough to mint a
191    /// token. `csrf` goes on every change.
192    Session { cookie: Option<String>, csrf: Option<String> },
193}
194
195/// `GET /api/store/index`: the store's metadata, one row per revision with a
196/// document key. This is the seam's `load_index`.
197#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
198#[serde(default)]
199pub struct StoreIndex {
200    pub seq: u64,
201    pub entries: Vec<IndexEntry>,
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
205#[serde(default)]
206pub struct IndexEntry {
207    pub key: String,
208    pub part_id: String,
209    pub part_number: String,
210    pub part_name: String,
211    pub revision_id: String,
212    pub revision_label: String,
213    pub lifecycle: String,
214    pub editable: bool,
215    pub content_hash: String,
216    pub size: u64,
217    pub modified: i64,
218    pub locked_by: Option<String>,
219    pub locked_by_me: bool,
220    /// P3; absent until the server sends it.
221    pub document_class: Option<String>,
222}
223
224/// `PUT /api/store/doc/*key`'s answer.
225#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
226#[serde(default)]
227pub struct PutAck {
228    pub seq: u64,
229    pub content_hash: String,
230    pub size: u64,
231}
232
233/// `GET /api/store/changes?since=`.
234#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
235#[serde(default)]
236pub struct Changes {
237    pub seq: u64,
238    /// The client asked from further back than the log reaches: `keys` is NOT
239    /// complete, and the index must be re-read.
240    pub stale: bool,
241    pub keys: Vec<String>,
242}
243
244/// One PLM server, and who the app is on it.
245pub struct PlmClient {
246    transport: Rc<dyn PlmTransport>,
247    credential: RefCell<Credential>,
248    me: RefCell<Option<Me>>,
249}
250
251impl PlmClient {
252    pub fn new(transport: Rc<dyn PlmTransport>) -> Self {
253        Self { transport, credential: RefCell::new(Credential::Anonymous), me: RefCell::new(None) }
254    }
255
256    /// Who the client is signed in as, once a sign-in has passed the version
257    /// check.
258    pub fn me(&self) -> Option<Me> {
259        self.me.borrow().clone()
260    }
261
262    pub fn credential(&self) -> Credential {
263        self.credential.borrow().clone()
264    }
265
266    /// Forget the credential locally. (A token stays valid on the server; the
267    /// user revokes it on the PLM's page.)
268    pub fn forget(&self) {
269        *self.credential.borrow_mut() = Credential::Anonymous;
270        *self.me.borrow_mut() = None;
271    }
272
273    /// One call with the current credential. Every status comes back `Ok`;
274    /// only a request that got no answer is an `Err`.
275    pub async fn raw(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
276        let credential = self.credential.borrow().clone();
277        self.send_as(&credential, method, path, body).await
278    }
279
280    async fn send_as(
281        &self,
282        credential: &Credential,
283        method: &'static str,
284        path: &str,
285        body: Option<Vec<u8>>,
286    ) -> Result<PlmResponse, PlmError> {
287        let mut headers = credential_headers(credential, method);
288        if body.as_deref().is_some_and(|b| serde_json::from_slice::<Value>(b).is_ok()) {
289            headers.push(("Content-Type".into(), "application/json".into()));
290        }
291        let request = PlmRequest { method, path: path.to_string(), headers, body: body.unwrap_or_default() };
292        self.transport.send(request).await.map_err(PlmError::Unreachable)
293    }
294
295    /// A 2xx-or-refusal call carrying `body` as `content_type`: a file
296    /// upload, whose media type the server keeps from this header (an
297    /// attachment sent without one is stored as `application/octet-stream`).
298    pub(crate) async fn call_typed(
299        &self,
300        method: &'static str,
301        path: &str,
302        body: Vec<u8>,
303        content_type: &str,
304    ) -> Result<PlmResponse, PlmError> {
305        let credential = self.credential.borrow().clone();
306        let mut headers = credential_headers(&credential, method);
307        headers.push(("Content-Type".into(), content_type.to_string()));
308        let request = PlmRequest { method, path: path.to_string(), headers, body };
309        let response = self.transport.send(request).await.map_err(PlmError::Unreachable)?;
310        if (200..300).contains(&response.status) {
311            Ok(response)
312        } else {
313            Err(PlmError::from_response(&response))
314        }
315    }
316
317    /// A call whose answer must be 2xx.
318    pub(crate) async fn call(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
319        let response = self.raw(method, path, body).await?;
320        if (200..300).contains(&response.status) {
321            Ok(response)
322        } else {
323            Err(PlmError::from_response(&response))
324        }
325    }
326
327    async fn call_json<T: for<'de> Deserialize<'de>>(
328        &self,
329        method: &'static str,
330        path: &str,
331        body: Option<Value>,
332    ) -> Result<T, PlmError> {
333        let response = self.call(method, path, body.map(|b| serde_json::to_vec(&b).unwrap_or_default())).await?;
334        parse(&response, path)
335    }
336
337    // ---------------------------------------------------------------- sign-in
338
339    /// Sign in with a token (pasted, or read from `plm-token`). Passes only if
340    /// the server accepts the token AND its version serves this app.
341    pub async fn sign_in_with_token(&self, token: &str) -> Result<Me, PlmError> {
342        let token = token.trim();
343        if token.is_empty() {
344            return Err(PlmError::SignIn("paste a token (it starts with plm_)".into()));
345        }
346        self.adopt(Credential::Token(token.to_string())).await
347    }
348
349    /// The page is served by the PLM and the browser already holds a session
350    /// cookie (D6): adopt it, and read the CSRF token changes must carry.
351    pub async fn sign_in_with_browser_session(&self) -> Result<Me, PlmError> {
352        self.adopt(Credential::Session { cookie: None, csrf: None }).await
353    }
354
355    /// Sign in with a username and password as a SESSION (the web lane, D6).
356    pub async fn sign_in_with_password(&self, username: &str, password: &str) -> Result<Me, PlmError> {
357        let cookie = self.log_in(username, password).await?;
358        self.adopt(Credential::Session { cookie, csrf: None }).await
359    }
360
361    /// Trade a username and password for a token (the native lane, D5): sign
362    /// in, mint a `full` token named `token_name` through that session, sign
363    /// the session out, and sign in with the token. Returns the token for the
364    /// caller to keep (`plm::config::write_token`); the password is not kept.
365    pub async fn sign_in_for_token(&self, username: &str, password: &str, token_name: &str) -> Result<(Me, String), PlmError> {
366        let cookie = self.log_in(username, password).await?;
367        let session = Credential::Session { cookie, csrf: None };
368        let me: Me = parse(&self.checked(&session, "GET", "/api/me", None).await?, "/api/me")?;
369        let session = Credential::Session { cookie: session_cookie(&session), csrf: me.csrf.clone() };
370        // Refuse BEFORE minting: a server that does not serve this app must not
371        // be left holding a fresh token nobody will use.
372        if let Err(refused) = check_version(&me) {
373            let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
374            return Err(refused);
375        }
376        let body = serde_json::to_vec(&json!({ "name": token_name, "scope": "full" })).unwrap_or_default();
377        let minted: Value = parse(&self.checked(&session, "POST", "/api/tokens", Some(body)).await?, "/api/tokens")?;
378        let token = minted
379            .get("token")
380            .and_then(Value::as_str)
381            .ok_or_else(|| PlmError::Malformed("/api/tokens answered without a token".into()))?
382            .to_string();
383        // The session was only a way to mint the token; do not leave it open.
384        let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
385        let me = self.sign_in_with_token(&token).await?;
386        Ok((me, token))
387    }
388
389    /// `POST /api/login`. The cookie comes back when the transport can see
390    /// `Set-Cookie` (native); in a browser it cannot, and need not — the
391    /// browser keeps it.
392    async fn log_in(&self, username: &str, password: &str) -> Result<Option<String>, PlmError> {
393        let body = serde_json::to_vec(&json!({ "username": username, "password": password })).unwrap_or_default();
394        let response = self.checked(&Credential::Anonymous, "POST", "/api/login", Some(body)).await?;
395        Ok(response
396            .headers
397            .iter()
398            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
399            .and_then(|(_, v)| v.split(';').next())
400            .map(|c| c.trim().to_string()))
401    }
402
403    /// A call with an explicit credential whose answer must be 2xx.
404    async fn checked(&self, credential: &Credential, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
405        let response = self.send_as(credential, method, path, body).await?;
406        if (200..300).contains(&response.status) {
407            Ok(response)
408        } else {
409            Err(PlmError::from_response(&response))
410        }
411    }
412
413    /// Read `/api/me` under `credential`, run the version check, and only then
414    /// keep the credential.
415    async fn adopt(&self, credential: Credential) -> Result<Me, PlmError> {
416        let me: Me = parse(&self.checked(&credential, "GET", "/api/me", None).await?, "/api/me")?;
417        check_version(&me)?;
418        let credential = match credential {
419            Credential::Session { cookie, .. } => Credential::Session { cookie, csrf: me.csrf.clone() },
420            other => other,
421        };
422        *self.credential.borrow_mut() = credential;
423        *self.me.borrow_mut() = Some(me.clone());
424        Ok(me)
425    }
426
427    // ------------------------------------------------------------ the store
428
429    /// `GET /api/store/index`.
430    pub async fn index(&self) -> Result<StoreIndex, PlmError> {
431        self.call_json("GET", "/api/store/index", None).await
432    }
433
434    /// `GET /api/store/index?keys=…`: only those revisions' rows (a key that
435    /// names no revision any more is absent from the answer).
436    pub async fn index_rows(&self, keys: &[String]) -> Result<StoreIndex, PlmError> {
437        self.call_json("GET", &format!("/api/store/index?keys={}", keys.iter().map(|k| crate::plm::identity::segment(k)).collect::<Vec<_>>().join(",")), None).await
438    }
439
440    /// `GET /api/store/doc/<key>`: the bytes, or `None` when the revision has
441    /// no document yet (`204`). A revision that does not exist is
442    /// [`PlmError::NotFound`].
443    pub async fn get_document(&self, key: &str) -> Result<Option<Vec<u8>>, PlmError> {
444        let response = self.call("GET", &doc_path(key), None).await?;
445        Ok(if response.status == 204 { None } else { Some(response.body) })
446    }
447
448    /// `PUT /api/store/doc/<key>`. Needs the lock and an editable revision;
449    /// otherwise [`PlmError::Conflict`] with the server's sentence.
450    pub async fn put_document(&self, key: &str, bytes: Vec<u8>) -> Result<PutAck, PlmError> {
451        let path = doc_path(key);
452        let response = self.call("PUT", &path, Some(bytes)).await?;
453        parse(&response, &path)
454    }
455
456    /// `DELETE /api/store/doc/<key>`, under the same two conditions as a write.
457    pub async fn delete_document(&self, key: &str) -> Result<(), PlmError> {
458        self.call("DELETE", &doc_path(key), None).await.map(|_| ())
459    }
460
461    // ------------------------------------------------------ preferences (P5)
462
463    /// `GET /api/me/preferences/<name>` (`@settings`, …): the value, or `None`
464    /// when the user has never set it. Unset is `204 No Content` (server B,
465    /// 2026-09-25: a hosted page's console logs every 404) and was `404`
466    /// before; both read as unset.
467    pub async fn get_preference(&self, name: &str) -> Result<Option<String>, PlmError> {
468        match self.call("GET", &preference_path(name), None).await {
469            Ok(response) if response.status == 204 => Ok(None),
470            Ok(response) => String::from_utf8(response.body)
471                .map(Some)
472                .map_err(|_| PlmError::Malformed(format!("preference {name} is not UTF-8"))),
473            Err(PlmError::NotFound(_)) => Ok(None),
474            Err(e) => Err(e),
475        }
476    }
477
478    /// `PUT /api/me/preferences/<name>` with the value, which must be JSON.
479    pub async fn put_preference(&self, name: &str, value: String) -> Result<(), PlmError> {
480        self.call("PUT", &preference_path(name), Some(value.into_bytes())).await.map(|_| ())
481    }
482
483    /// `DELETE /api/me/preferences/<name>`; deleting an unset one is fine.
484    pub async fn delete_preference(&self, name: &str) -> Result<(), PlmError> {
485        self.call("DELETE", &preference_path(name), None).await.map(|_| ())
486    }
487
488    /// `GET /api/store/changes?since=<seq>`.
489    pub async fn changes(&self, since: u64) -> Result<Changes, PlmError> {
490        self.call_json("GET", &format!("/api/store/changes?since={since}"), None).await
491    }
492}
493
494/// What one poll of the change feed found.
495#[derive(Debug, Clone, PartialEq)]
496pub enum FeedUpdate {
497    /// Nothing changed since the last poll.
498    Quiet,
499    /// These keys changed (in the server's order, each once).
500    Keys(Vec<String>),
501    /// The feed could not say what changed (the first poll, or the client
502    /// fell behind the log): here is the whole index instead.
503    Reindexed(StoreIndex),
504}
505
506/// The store's change feed: poll `?since=`, and re-read the index whenever
507/// the answer is `stale`. The first poll reads the index, which is also where
508/// the feed's position starts.
509#[derive(Debug, Clone, Default)]
510pub struct ChangeFeed {
511    since: Option<u64>,
512}
513
514impl ChangeFeed {
515    pub fn new() -> Self {
516        Self::default()
517    }
518
519    /// Start from a position the caller already holds (an index it read).
520    pub fn starting_at(seq: u64) -> Self {
521        Self { since: Some(seq) }
522    }
523
524    pub fn position(&self) -> Option<u64> {
525        self.since
526    }
527
528    pub async fn poll(&mut self, client: &PlmClient) -> Result<FeedUpdate, PlmError> {
529        let Some(since) = self.since else {
530            let index = client.index().await?;
531            self.since = Some(index.seq);
532            return Ok(FeedUpdate::Reindexed(index));
533        };
534        let changes = client.changes(since).await?;
535        if changes.stale {
536            let index = client.index().await?;
537            self.since = Some(index.seq);
538            return Ok(FeedUpdate::Reindexed(index));
539        }
540        self.since = Some(changes.seq.max(since));
541        let mut keys = Vec::new();
542        for key in changes.keys {
543            if !keys.contains(&key) {
544                keys.push(key);
545            }
546        }
547        Ok(if keys.is_empty() { FeedUpdate::Quiet } else { FeedUpdate::Keys(keys) })
548    }
549}
550
551/// P1: refuse a server that does not serve this app. The rule is the
552/// server's own (`brep_plm::version::serves`, mirrored exactly and pinned
553/// against it by a test): this app's version must parse and be at least
554/// `min_client_version`, and a version that does not parse is not served. A
555/// server that reports no version predates the handshake and is refused too:
556/// it cannot promise the contracts this client reads.
557pub fn check_version(me: &Me) -> Result<(), PlmError> {
558    check_version_of(me, CLIENT_VERSION)
559}
560
561fn check_version_of(me: &Me, client: &str) -> Result<(), PlmError> {
562    let server = me.server_version.as_deref().unwrap_or("a version it does not report");
563    let refuse = |min: &str, reason: String| PlmError::Version {
564        server: server.to_string(),
565        min_client: min.to_string(),
566        client: client.to_string(),
567        reason,
568    };
569    let Some(min) = me.min_client_version.as_deref() else {
570        return Err(refuse("?", "the server does not say which CAD versions it serves; it is older than this app".into()));
571    };
572    if !serves(min, client) {
573        return Err(refuse(min, format!("update this CAD app to {min} or later")));
574    }
575    Ok(())
576}
577
578/// `MAJOR.MINOR.PATCH` as numbers, a `-pre` / `+build` suffix ignored; exactly
579/// three fields. The same parse as `brep_plm::version::parse`.
580pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
581    let core = version.trim().split(['-', '+']).next()?;
582    let mut fields = core.split('.').map(|f| f.parse::<u64>().ok());
583    let triple = (fields.next()??, fields.next()??, fields.next()??);
584    fields.next().is_none().then_some(triple)
585}
586
587/// Whether a server whose oldest served client is `min_client_version` serves
588/// a client at `client_version` (`brep_plm::version::serves`).
589pub fn serves(min_client_version: &str, client_version: &str) -> bool {
590    match (parse_version(min_client_version), parse_version(client_version)) {
591        (Some(min), Some(client)) => client >= min,
592        _ => false,
593    }
594}
595
596fn preference_path(name: &str) -> String {
597    format!("/api/me/preferences/{name}")
598}
599
600fn doc_path(key: &str) -> String {
601    format!("/api/store/doc/{}", key.trim_start_matches('/').replace('%', "%25"))
602}
603
604fn session_cookie(credential: &Credential) -> Option<String> {
605    match credential {
606        Credential::Session { cookie, .. } => cookie.clone(),
607        _ => None,
608    }
609}
610
611fn parse<T: for<'de> Deserialize<'de>>(response: &PlmResponse, what: &str) -> Result<T, PlmError> {
612    serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
613}
614