Skip to main content

semora_cli/
server_id.rs

1//! The id each server gives itself, which `.semora` names the server by. The CLI learns it from
2//! `whoami` and keeps it in `server-ids.cache` in the config folder, one `<origin>\t<id>` line per
3//! server, so most commands don't ask again.
4
5use std::path::Path;
6
7use anyhow::bail;
8
9use crate::client::Client;
10use crate::{cachefile, exit};
11
12const CACHE: &str = "server-ids.cache";
13
14/// 32 hex digits once trimmed, in any case.
15pub fn is_id(s: &str) -> bool {
16    let s = s.trim();
17    s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
18}
19
20/// An id as the server writes it: trimmed, lowercase.
21pub fn normal(s: &str) -> String {
22    s.trim().to_ascii_lowercase()
23}
24
25fn load(dir: &Path, origin: &str) -> Option<String> {
26    let text = std::fs::read_to_string(dir.join(CACHE)).ok()?;
27    text.lines().find_map(|l| {
28        let (o, id) = l.split_once('\t')?;
29        (o == origin && is_id(id)).then(|| normal(id))
30    })
31}
32
33fn save(dir: &Path, origin: &str, id: &str) -> anyhow::Result<()> {
34    cachefile::rewrite(dir, CACHE, |text| {
35        let mut out: String = text
36            .lines()
37            .filter(|l| !l.is_empty() && l.split_once('\t').is_none_or(|(o, _)| o != origin))
38            .map(|l| format!("{l}\n"))
39            .collect();
40        out.push_str(&format!("{origin}\t{id}\n"));
41        out
42    })
43}
44
45/// The id of the client's server: the cached one unless `fresh`, else `whoami`'s, which is
46/// cached for next time. A cache that can't be written costs only the next lookup.
47pub async fn of(client: &Client, config_dir: &Path, fresh: bool) -> anyhow::Result<String> {
48    let origin = client.origin();
49    if !fresh && let Some(id) = load(config_dir, &origin) {
50        return Ok(id);
51    }
52    let older = || format!("the server at {origin} is older than this CLI; update it");
53    let who = match client.whoami().await {
54        Ok(w) => w,
55        Err(e) if exit::is_not_found(&e) => bail!(older()),
56        Err(e) => return Err(e),
57    };
58    let Some(id) = who.server_id.filter(|id| is_id(id)).map(|id| normal(&id)) else {
59        bail!(older());
60    };
61    let _ = save(config_dir, &origin, &id);
62    Ok(id)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    const ID: &str = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b";
70
71    #[test]
72    fn ids_are_32_hex_in_any_case() {
73        assert!(is_id(ID));
74        assert!(is_id(&format!("  {}  ", ID.to_uppercase())));
75        assert_eq!(normal(&format!(" {} ", ID.to_uppercase())), ID);
76        for bad in [
77            &ID[..31],
78            &format!("{ID}0"),
79            "https://a.example.com",
80            &"g".repeat(32),
81        ] {
82            assert!(!is_id(bad), "{bad}");
83        }
84    }
85
86    #[test]
87    fn the_cache_keeps_one_id_per_server() {
88        let d = tempfile::tempdir().unwrap();
89        assert_eq!(load(d.path(), "https://a.example.com"), None);
90        save(d.path(), "https://a.example.com", ID).unwrap();
91        save(d.path(), "https://b.example.com", &"0".repeat(32)).unwrap();
92        save(d.path(), "https://a.example.com", &"1".repeat(32)).unwrap();
93        assert_eq!(
94            load(d.path(), "https://a.example.com"),
95            Some("1".repeat(32))
96        );
97        assert_eq!(
98            load(d.path(), "https://b.example.com"),
99            Some("0".repeat(32))
100        );
101    }
102
103    #[test]
104    fn an_unwritable_cache_does_not_stop_the_lookup() {
105        let d = tempfile::tempdir().unwrap();
106        let file = d.path().join("not-a-folder");
107        std::fs::write(&file, "x").unwrap();
108        assert!(save(&file, "https://a.example.com", ID).is_err());
109        assert_eq!(load(&file, "https://a.example.com"), None);
110    }
111}