use crate::payload::DocPayload;
use compact_str::CompactString;
use std::path::PathBuf;
use url::Url;
pub type DocId = u32;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ResourceKind {
Html,
Markdown,
Code,
Text,
Pdf,
Json,
Unknown,
}
impl ResourceKind {
#[must_use]
pub fn from_content_type(content_type: &str) -> Self {
let mime = content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match mime.as_str() {
"text/html" | "application/xhtml+xml" => Self::Html,
"text/markdown" | "text/x-markdown" => Self::Markdown,
"application/json" | "text/json" => Self::Json,
"application/pdf" => Self::Pdf,
"text/plain" => Self::Text,
m if m.starts_with("text/") => Self::Code,
_ => Self::Unknown,
}
}
#[must_use]
pub fn from_path(path: &str) -> Self {
let lower = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
let ext = lower.rsplit('.').next().unwrap_or("");
match ext {
"html" | "htm" | "xhtml" => Self::Html,
"md" | "markdown" | "mdx" => Self::Markdown,
"json" => Self::Json,
"pdf" => Self::Pdf,
"txt" | "text" => Self::Text,
"rs" | "py" | "js" | "ts" | "go" | "c" | "h" | "cpp" | "hpp" | "java" | "rb"
| "toml" | "yaml" | "yml" | "sh" | "sql" => Self::Code,
_ => Self::Unknown,
}
}
#[must_use]
pub fn refine_with_path(self, path: &str) -> Self {
if matches!(self, Self::Text | Self::Unknown) {
let from_path = Self::from_path(path);
if from_path != Self::Unknown {
return from_path;
}
}
self
}
#[must_use]
pub fn is_indexable(self) -> bool {
!matches!(self, Self::Pdf | Self::Unknown)
}
#[must_use]
pub fn is_linkable(self) -> bool {
matches!(self, Self::Html | Self::Markdown)
}
#[must_use]
pub fn as_tag(self) -> u8 {
match self {
Self::Html => 0,
Self::Markdown => 1,
Self::Code => 2,
Self::Text => 3,
Self::Pdf => 4,
Self::Json => 5,
Self::Unknown => 255,
}
}
#[must_use]
pub fn from_tag(tag: u8) -> Self {
match tag {
0 => Self::Html,
1 => Self::Markdown,
2 => Self::Code,
3 => Self::Text,
4 => Self::Pdf,
5 => Self::Json,
_ => Self::Unknown,
}
}
}
#[derive(Clone, Debug)]
pub struct Resource {
pub url: Url,
pub kind: ResourceKind,
pub etag: Option<CompactString>,
pub size: Option<u64>,
}
impl Resource {
#[must_use]
pub fn new(url: Url) -> Self {
let kind = ResourceKind::from_path(url.path());
Self {
url,
kind,
etag: None,
size: None,
}
}
#[must_use]
pub fn with_kind(mut self, kind: ResourceKind) -> Self {
self.kind = kind;
self
}
#[must_use]
pub fn with_etag(mut self, etag: impl Into<CompactString>) -> Self {
self.etag = Some(etag.into());
self
}
#[must_use]
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
}
#[derive(Debug)]
pub struct Page<'a> {
pub resource: Resource,
pub payload: DocPayload<'a>,
}
impl<'a> Page<'a> {
#[must_use]
pub fn new(resource: Resource, payload: DocPayload<'a>) -> Self {
Self { resource, payload }
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum SourceRef {
Http {
root: Url,
},
Fs {
root: PathBuf,
},
}
impl SourceRef {
pub fn http(url: &str) -> crate::Result<Self> {
let root =
Url::parse(url).map_err(|e| crate::Error::invalid_url(format!("{url:?}: {e}")))?;
if root.scheme() != "http" && root.scheme() != "https" {
return Err(crate::Error::invalid_url(format!(
"expected http(s), got scheme {:?}",
root.scheme()
)));
}
Ok(Self::Http { root })
}
#[must_use]
pub fn fs(root: impl Into<PathBuf>) -> Self {
Self::Fs { root: root.into() }
}
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Http { .. } => "http",
Self::Fs { .. } => "fs",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_type_classification() {
assert_eq!(
ResourceKind::from_content_type("text/html; charset=utf-8"),
ResourceKind::Html
);
assert_eq!(
ResourceKind::from_content_type("application/json"),
ResourceKind::Json
);
assert_eq!(
ResourceKind::from_content_type("text/x-rust"),
ResourceKind::Code
);
assert_eq!(
ResourceKind::from_content_type("image/png"),
ResourceKind::Unknown
);
}
#[test]
fn path_classification() {
assert_eq!(
ResourceKind::from_path("/docs/intro.html"),
ResourceKind::Html
);
assert_eq!(ResourceKind::from_path("README.md"), ResourceKind::Markdown);
assert_eq!(ResourceKind::from_path("src/lib.rs"), ResourceKind::Code);
assert_eq!(ResourceKind::from_path("/a/b/"), ResourceKind::Unknown);
}
#[test]
fn refine_prefers_specific_path_over_generic_text() {
let generic = ResourceKind::from_content_type("text/plain; charset=utf-8");
assert_eq!(
generic.refine_with_path("/o/r/main/docs/a.md"),
ResourceKind::Markdown
);
assert_eq!(
generic.refine_with_path("/o/r/main/Pulumi.yaml"),
ResourceKind::Code
);
assert_eq!(
generic.refine_with_path("/o/r/main/page.html"),
ResourceKind::Html
);
assert_eq!(
generic.refine_with_path("/o/r/main/LICENSE"),
ResourceKind::Text
);
let html = ResourceKind::from_content_type("text/html");
assert_eq!(html.refine_with_path("/a/b.md"), ResourceKind::Html);
assert_eq!(
ResourceKind::Unknown.refine_with_path("/a/b.md"),
ResourceKind::Markdown
);
}
#[test]
fn kind_tag_roundtrips() {
for kind in [
ResourceKind::Html,
ResourceKind::Markdown,
ResourceKind::Code,
ResourceKind::Text,
ResourceKind::Pdf,
ResourceKind::Json,
ResourceKind::Unknown,
] {
assert_eq!(ResourceKind::from_tag(kind.as_tag()), kind);
}
assert_eq!(ResourceKind::from_tag(200), ResourceKind::Unknown);
}
#[test]
fn linkable_and_indexable_flags() {
assert!(ResourceKind::Html.is_linkable());
assert!(
ResourceKind::Markdown.is_linkable(),
"markdown links are followed"
);
assert!(!ResourceKind::Code.is_linkable());
assert!(ResourceKind::Markdown.is_indexable());
assert!(!ResourceKind::Pdf.is_indexable());
}
#[test]
fn resource_builder_classifies_from_path() {
let r = Resource::new(Url::parse("https://x.dev/a/b.md").unwrap())
.with_etag("abc")
.with_size(42);
assert_eq!(r.kind, ResourceKind::Markdown);
assert_eq!(r.etag.as_deref(), Some("abc"));
assert_eq!(r.size, Some(42));
}
#[test]
fn source_ref_http_validates_scheme() {
assert!(SourceRef::http("https://example.com/docs/").is_ok());
assert!(SourceRef::http("ftp://example.com").is_err());
assert!(SourceRef::http("not a url").is_err());
assert_eq!(SourceRef::fs("/tmp/x").kind(), "fs");
}
}