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())
}
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")
}
#[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()
}
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("/"),
}))
}
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")
);
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() {
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}");
}
}
}