Skip to main content

semora_cli/
docref.rs

1//! Document references: a bare id, a page URL, a websocket URL, or a project path link.
2
3use anyhow::bail;
4
5use crate::client::Client;
6use crate::config::origin;
7
8fn is_id(s: &str) -> bool {
9    s.len() == 10 && s.bytes().all(|b| b.is_ascii_alphanumeric())
10}
11
12/// `k3F9xQ2mLp`, `https://semora/d/k3F9xQ2mLp/page.md`, `wss://semora/ws/d/k3F9xQ2mLp` → `k3F9xQ2mLp`.
13pub fn parse(s: &str) -> anyhow::Result<String> {
14    let s = s.trim();
15    if is_id(s) {
16        return Ok(s.to_string());
17    }
18    if let Ok(u) = url::Url::parse(s) {
19        let segs: Vec<&str> = u.path_segments().map(|p| p.collect()).unwrap_or_default();
20        for w in segs.windows(2) {
21            if w[0] == "d" && is_id(w[1]) {
22                return Ok(w[1].to_string());
23            }
24        }
25    }
26    bail!("`{s}` is not a document id or Semora URL")
27}
28
29/// A page URL for a project path: `https://server/p/<project>/<path…>`.
30#[derive(Debug, PartialEq, Eq)]
31pub struct PathLink {
32    pub origin: String,
33    pub project: String,
34    pub path: String,
35}
36
37fn decode(s: &str) -> String {
38    percent_encoding::percent_decode_str(s)
39        .decode_utf8_lossy()
40        .into_owned()
41}
42
43/// `Ok(None)` when `s` is not a project link. A link to a project page (no path) is an error.
44pub fn parse_path_link(s: &str) -> anyhow::Result<Option<PathLink>> {
45    let s = s.trim();
46    let Ok(u) = url::Url::parse(s) else {
47        return Ok(None);
48    };
49    let segs: Vec<&str> = u.path_segments().map(|p| p.collect()).unwrap_or_default();
50    if segs.first() != Some(&"p") || segs.get(1).is_none_or(|p| p.is_empty()) {
51        return Ok(None);
52    }
53    let rest: Vec<String> = segs[2..]
54        .iter()
55        .filter(|p| !p.is_empty())
56        .map(|p| decode(p))
57        .collect();
58    if rest.is_empty() {
59        bail!("`{s}` is a project page, not a document");
60    }
61    let project = decode(segs[1]);
62    if !crate::project_link::valid_slug(&project) {
63        bail!("`{s}` is not a path link: `{project}` is not a project id");
64    }
65    Ok(Some(PathLink {
66        origin: origin(&u),
67        project,
68        path: rest.join("/"),
69    }))
70}
71
72/// The document id a reference names. Ids and `/d/` and `/ws/d/` URLs are read offline; a path
73/// link is looked up on the server. A path link is checked first, so a path that holds
74/// `d/<id>` is still a path link.
75pub async fn resolve(client: &Client, s: &str) -> anyhow::Result<String> {
76    if let Some(link) = parse_path_link(s)? {
77        let have = client.origin();
78        if link.origin != have {
79            bail!(
80                "`{}` is a link for {}, but this command uses {have}",
81                s.trim(),
82                link.origin
83            );
84        }
85        return Ok(client.doc_at(&link.project, &link.path).await?.id);
86    }
87    parse(s)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::{parse, parse_path_link};
93
94    #[test]
95    fn parses_ids_and_urls() {
96        assert_eq!(parse("k3F9xQ2mLp").unwrap(), "k3F9xQ2mLp");
97        assert_eq!(
98            parse("https://semora.example.com/d/k3F9xQ2mLp/").unwrap(),
99            "k3F9xQ2mLp"
100        );
101        assert_eq!(
102            parse("https://semora.example.com/d/k3F9xQ2mLp/guide/a.md").unwrap(),
103            "k3F9xQ2mLp"
104        );
105        assert_eq!(
106            parse("wss://semora.example.com/ws/d/k3F9xQ2mLp").unwrap(),
107            "k3F9xQ2mLp"
108        );
109        assert!(parse("nope").is_err());
110        assert!(parse("https://x/d/short/").is_err());
111    }
112
113    #[test]
114    fn path_links_are_read_and_decoded() {
115        let l = parse_path_link("https://semora.example.com/p/semora/docs/a%20b/%C3%A9.md#top")
116            .unwrap()
117            .unwrap();
118        assert_eq!(l.origin, "https://semora.example.com");
119        assert_eq!(
120            (l.project.as_str(), l.path.as_str()),
121            ("semora", "docs/a b/é.md")
122        );
123        let l = parse_path_link("http://localhost:8080/p/x/a.md/")
124            .unwrap()
125            .unwrap();
126        assert_eq!(
127            (l.origin.as_str(), l.path.as_str()),
128            ("http://localhost:8080", "a.md")
129        );
130        // A path that happens to hold `/d/<id>` is still a path link.
131        let l = parse_path_link("https://s.example.com/p/x/d/abcdefghij")
132            .unwrap()
133            .unwrap();
134        assert_eq!(l.path, "d/abcdefghij");
135    }
136
137    #[test]
138    fn other_references_are_not_path_links() {
139        assert_eq!(parse_path_link("k3F9xQ2mLp").unwrap(), None);
140        assert_eq!(
141            parse_path_link("https://semora.example.com/d/k3F9xQ2mLp/").unwrap(),
142            None
143        );
144        assert_eq!(
145            parse_path_link("wss://semora.example.com/ws/d/k3F9xQ2mLp").unwrap(),
146            None
147        );
148        assert_eq!(parse_path_link("nope").unwrap(), None);
149    }
150
151    #[test]
152    fn a_path_link_needs_a_valid_project_id() {
153        // `%2F` decodes to a slash, which would put part of the path into the project id.
154        for s in [
155            "https://s.example.com/p/semora-review%2Fdocs/a.md",
156            "https://s.example.com/p/Semora/a.md",
157        ] {
158            let err = parse_path_link(s).unwrap_err().to_string();
159            assert!(err.contains("project id"), "{s}: {err}");
160        }
161    }
162
163    #[test]
164    fn a_project_page_is_not_a_document() {
165        for s in [
166            "https://semora.example.com/p/semora",
167            "https://semora.example.com/p/semora/",
168        ] {
169            let err = parse_path_link(s).unwrap_err().to_string();
170            assert!(err.contains("project page"), "{err}");
171        }
172    }
173}