1use 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
12pub 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#[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
43pub 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
72pub 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 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 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}