semora-cli 0.8.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Document references: a bare id, a page URL, a websocket URL, or a project path link.

use anyhow::bail;

use crate::client::Client;
use crate::config::origin;

fn is_id(s: &str) -> bool {
    s.len() == 10 && s.bytes().all(|b| b.is_ascii_alphanumeric())
}

/// `k3F9xQ2mLp`, `https://semora/d/k3F9xQ2mLp/page.md`, `wss://semora/ws/d/k3F9xQ2mLp` → `k3F9xQ2mLp`.
pub fn parse(s: &str) -> anyhow::Result<String> {
    let s = s.trim();
    if is_id(s) {
        return Ok(s.to_string());
    }
    if let Ok(u) = url::Url::parse(s) {
        let segs: Vec<&str> = u.path_segments().map(|p| p.collect()).unwrap_or_default();
        for w in segs.windows(2) {
            if w[0] == "d" && is_id(w[1]) {
                return Ok(w[1].to_string());
            }
        }
    }
    bail!("`{s}` is not a document id or Semora URL")
}

/// A page URL for a project path: `https://server/p/<project>/<path…>`.
#[derive(Debug, PartialEq, Eq)]
pub struct PathLink {
    pub origin: String,
    pub project: String,
    pub path: String,
}

fn decode(s: &str) -> String {
    percent_encoding::percent_decode_str(s)
        .decode_utf8_lossy()
        .into_owned()
}

/// `Ok(None)` when `s` is not a project link. A link to a project page (no path) is an error.
pub fn parse_path_link(s: &str) -> anyhow::Result<Option<PathLink>> {
    let s = s.trim();
    let Ok(u) = url::Url::parse(s) else {
        return Ok(None);
    };
    let segs: Vec<&str> = u.path_segments().map(|p| p.collect()).unwrap_or_default();
    if segs.first() != Some(&"p") || segs.get(1).is_none_or(|p| p.is_empty()) {
        return Ok(None);
    }
    let rest: Vec<String> = segs[2..]
        .iter()
        .filter(|p| !p.is_empty())
        .map(|p| decode(p))
        .collect();
    if rest.is_empty() {
        bail!("`{s}` is a project page, not a document");
    }
    let project = decode(segs[1]);
    if !crate::project_link::valid_slug(&project) {
        bail!("`{s}` is not a path link: `{project}` is not a project id");
    }
    Ok(Some(PathLink {
        origin: origin(&u),
        project,
        path: rest.join("/"),
    }))
}

/// The document id a reference names. Ids and `/d/` and `/ws/d/` URLs are read offline; a path
/// link is looked up on the server. A path link is checked first, so a path that holds
/// `d/<id>` is still a path link.
pub async fn resolve(client: &Client, s: &str) -> anyhow::Result<String> {
    if let Some(link) = parse_path_link(s)? {
        let have = client.origin();
        if link.origin != have {
            bail!(
                "`{}` is a link for {}, but this command uses {have}",
                s.trim(),
                link.origin
            );
        }
        return Ok(client.doc_at(&link.project, &link.path).await?.id);
    }
    parse(s)
}

#[cfg(test)]
mod tests {
    use super::{parse, parse_path_link};

    #[test]
    fn parses_ids_and_urls() {
        assert_eq!(parse("k3F9xQ2mLp").unwrap(), "k3F9xQ2mLp");
        assert_eq!(
            parse("https://semora.example.com/d/k3F9xQ2mLp/").unwrap(),
            "k3F9xQ2mLp"
        );
        assert_eq!(
            parse("https://semora.example.com/d/k3F9xQ2mLp/guide/a.md").unwrap(),
            "k3F9xQ2mLp"
        );
        assert_eq!(
            parse("wss://semora.example.com/ws/d/k3F9xQ2mLp").unwrap(),
            "k3F9xQ2mLp"
        );
        assert!(parse("nope").is_err());
        assert!(parse("https://x/d/short/").is_err());
    }

    #[test]
    fn path_links_are_read_and_decoded() {
        let l = parse_path_link("https://semora.example.com/p/semora/docs/a%20b/%C3%A9.md#top")
            .unwrap()
            .unwrap();
        assert_eq!(l.origin, "https://semora.example.com");
        assert_eq!(
            (l.project.as_str(), l.path.as_str()),
            ("semora", "docs/a b/é.md")
        );
        let l = parse_path_link("http://localhost:8080/p/x/a.md/")
            .unwrap()
            .unwrap();
        assert_eq!(
            (l.origin.as_str(), l.path.as_str()),
            ("http://localhost:8080", "a.md")
        );
        // A path that happens to hold `/d/<id>` is still a path link.
        let l = parse_path_link("https://s.example.com/p/x/d/abcdefghij")
            .unwrap()
            .unwrap();
        assert_eq!(l.path, "d/abcdefghij");
    }

    #[test]
    fn other_references_are_not_path_links() {
        assert_eq!(parse_path_link("k3F9xQ2mLp").unwrap(), None);
        assert_eq!(
            parse_path_link("https://semora.example.com/d/k3F9xQ2mLp/").unwrap(),
            None
        );
        assert_eq!(
            parse_path_link("wss://semora.example.com/ws/d/k3F9xQ2mLp").unwrap(),
            None
        );
        assert_eq!(parse_path_link("nope").unwrap(), None);
    }

    #[test]
    fn a_path_link_needs_a_valid_project_id() {
        // `%2F` decodes to a slash, which would put part of the path into the project id.
        for s in [
            "https://s.example.com/p/semora-review%2Fdocs/a.md",
            "https://s.example.com/p/Semora/a.md",
        ] {
            let err = parse_path_link(s).unwrap_err().to_string();
            assert!(err.contains("project id"), "{s}: {err}");
        }
    }

    #[test]
    fn a_project_page_is_not_a_document() {
        for s in [
            "https://semora.example.com/p/semora",
            "https://semora.example.com/p/semora/",
        ] {
            let err = parse_path_link(s).unwrap_err().to_string();
            assert!(err.contains("project page"), "{err}");
        }
    }
}