semora-cli 1.1.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! The id each server gives itself, which `.semora` names the server by. The CLI learns it from
//! `whoami` and keeps it in `server-ids.cache` in the config folder, one `<origin>\t<id>` line per
//! server, so most commands don't ask again.

use std::path::Path;

use anyhow::bail;

use crate::client::Client;
use crate::{cachefile, exit};

const CACHE: &str = "server-ids.cache";

/// 32 hex digits once trimmed, in any case.
pub fn is_id(s: &str) -> bool {
    let s = s.trim();
    s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
}

/// An id as the server writes it: trimmed, lowercase.
pub fn normal(s: &str) -> String {
    s.trim().to_ascii_lowercase()
}

fn load(dir: &Path, origin: &str) -> Option<String> {
    let text = std::fs::read_to_string(dir.join(CACHE)).ok()?;
    text.lines().find_map(|l| {
        let (o, id) = l.split_once('\t')?;
        (o == origin && is_id(id)).then(|| normal(id))
    })
}

fn save(dir: &Path, origin: &str, id: &str) -> anyhow::Result<()> {
    cachefile::rewrite(dir, CACHE, |text| {
        let mut out: String = text
            .lines()
            .filter(|l| !l.is_empty() && l.split_once('\t').is_none_or(|(o, _)| o != origin))
            .map(|l| format!("{l}\n"))
            .collect();
        out.push_str(&format!("{origin}\t{id}\n"));
        out
    })
}

/// The id of the client's server: the cached one unless `fresh`, else `whoami`'s, which is
/// cached for next time. A cache that can't be written costs only the next lookup.
pub async fn of(client: &Client, config_dir: &Path, fresh: bool) -> anyhow::Result<String> {
    let origin = client.origin();
    if !fresh && let Some(id) = load(config_dir, &origin) {
        return Ok(id);
    }
    let older = || format!("the server at {origin} is older than this CLI; update it");
    let who = match client.whoami().await {
        Ok(w) => w,
        Err(e) if exit::is_not_found(&e) => bail!(older()),
        Err(e) => return Err(e),
    };
    let Some(id) = who.server_id.filter(|id| is_id(id)).map(|id| normal(&id)) else {
        bail!(older());
    };
    let _ = save(config_dir, &origin, &id);
    Ok(id)
}

#[cfg(test)]
mod tests {
    use super::*;

    const ID: &str = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b";

    #[test]
    fn ids_are_32_hex_in_any_case() {
        assert!(is_id(ID));
        assert!(is_id(&format!("  {}  ", ID.to_uppercase())));
        assert_eq!(normal(&format!(" {} ", ID.to_uppercase())), ID);
        for bad in [
            &ID[..31],
            &format!("{ID}0"),
            "https://a.example.com",
            &"g".repeat(32),
        ] {
            assert!(!is_id(bad), "{bad}");
        }
    }

    #[test]
    fn the_cache_keeps_one_id_per_server() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(load(d.path(), "https://a.example.com"), None);
        save(d.path(), "https://a.example.com", ID).unwrap();
        save(d.path(), "https://b.example.com", &"0".repeat(32)).unwrap();
        save(d.path(), "https://a.example.com", &"1".repeat(32)).unwrap();
        assert_eq!(
            load(d.path(), "https://a.example.com"),
            Some("1".repeat(32))
        );
        assert_eq!(
            load(d.path(), "https://b.example.com"),
            Some("0".repeat(32))
        );
    }

    #[test]
    fn an_unwritable_cache_does_not_stop_the_lookup() {
        let d = tempfile::tempdir().unwrap();
        let file = d.path().join("not-a-folder");
        std::fs::write(&file, "x").unwrap();
        assert!(save(&file, "https://a.example.com", ID).is_err());
        assert_eq!(load(&file, "https://a.example.com"), None);
    }
}