1use crate::payload::DocPayload;
11use compact_str::CompactString;
12use std::path::PathBuf;
13use url::Url;
14
15pub type DocId = u32;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum ResourceKind {
22 Html,
24 Markdown,
26 Code,
28 Text,
30 Pdf,
32 Json,
34 Unknown,
36}
37
38impl ResourceKind {
39 #[must_use]
41 pub fn from_content_type(content_type: &str) -> Self {
42 let mime = content_type
43 .split(';')
44 .next()
45 .unwrap_or("")
46 .trim()
47 .to_ascii_lowercase();
48 match mime.as_str() {
49 "text/html" | "application/xhtml+xml" => Self::Html,
50 "text/markdown" | "text/x-markdown" => Self::Markdown,
51 "application/json" | "text/json" => Self::Json,
52 "application/pdf" => Self::Pdf,
53 "text/plain" => Self::Text,
54 m if m.starts_with("text/") => Self::Code,
55 _ => Self::Unknown,
56 }
57 }
58
59 #[must_use]
61 pub fn from_path(path: &str) -> Self {
62 let lower = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
63 let ext = lower.rsplit('.').next().unwrap_or("");
64 match ext {
65 "html" | "htm" | "xhtml" => Self::Html,
66 "md" | "markdown" | "mdx" => Self::Markdown,
67 "json" => Self::Json,
68 "pdf" => Self::Pdf,
69 "txt" | "text" => Self::Text,
70 "rs" | "py" | "js" | "ts" | "go" | "c" | "h" | "cpp" | "hpp" | "java" | "rb"
71 | "toml" | "yaml" | "yml" | "sh" | "sql" => Self::Code,
72 _ => Self::Unknown,
73 }
74 }
75
76 #[must_use]
86 pub fn refine_with_path(self, path: &str) -> Self {
87 if matches!(self, Self::Text | Self::Unknown) {
88 let from_path = Self::from_path(path);
89 if from_path != Self::Unknown {
90 return from_path;
91 }
92 }
93 self
94 }
95
96 #[must_use]
98 pub fn is_indexable(self) -> bool {
99 !matches!(self, Self::Pdf | Self::Unknown)
100 }
101
102 #[must_use]
104 pub fn is_linkable(self) -> bool {
105 matches!(self, Self::Html | Self::Markdown)
106 }
107
108 #[must_use]
110 pub fn as_tag(self) -> u8 {
111 match self {
112 Self::Html => 0,
113 Self::Markdown => 1,
114 Self::Code => 2,
115 Self::Text => 3,
116 Self::Pdf => 4,
117 Self::Json => 5,
118 Self::Unknown => 255,
119 }
120 }
121
122 #[must_use]
124 pub fn from_tag(tag: u8) -> Self {
125 match tag {
126 0 => Self::Html,
127 1 => Self::Markdown,
128 2 => Self::Code,
129 3 => Self::Text,
130 4 => Self::Pdf,
131 5 => Self::Json,
132 _ => Self::Unknown,
133 }
134 }
135}
136
137#[derive(Clone, Debug)]
141pub struct Resource {
142 pub url: Url,
144 pub kind: ResourceKind,
146 pub etag: Option<CompactString>,
149 pub size: Option<u64>,
151}
152
153impl Resource {
154 #[must_use]
156 pub fn new(url: Url) -> Self {
157 let kind = ResourceKind::from_path(url.path());
158 Self {
159 url,
160 kind,
161 etag: None,
162 size: None,
163 }
164 }
165
166 #[must_use]
168 pub fn with_kind(mut self, kind: ResourceKind) -> Self {
169 self.kind = kind;
170 self
171 }
172
173 #[must_use]
175 pub fn with_etag(mut self, etag: impl Into<CompactString>) -> Self {
176 self.etag = Some(etag.into());
177 self
178 }
179
180 #[must_use]
182 pub fn with_size(mut self, size: u64) -> Self {
183 self.size = Some(size);
184 self
185 }
186}
187
188#[derive(Debug)]
193pub struct Page<'a> {
194 pub resource: Resource,
196 pub payload: DocPayload<'a>,
198}
199
200impl<'a> Page<'a> {
201 #[must_use]
203 pub fn new(resource: Resource, payload: DocPayload<'a>) -> Self {
204 Self { resource, payload }
205 }
206}
207
208#[derive(Clone, Debug)]
210#[non_exhaustive]
211pub enum SourceRef {
212 Http {
214 root: Url,
216 },
217 Fs {
219 root: PathBuf,
221 },
222}
223
224impl SourceRef {
225 pub fn http(url: &str) -> crate::Result<Self> {
227 let root =
228 Url::parse(url).map_err(|e| crate::Error::invalid_url(format!("{url:?}: {e}")))?;
229 if root.scheme() != "http" && root.scheme() != "https" {
230 return Err(crate::Error::invalid_url(format!(
231 "expected http(s), got scheme {:?}",
232 root.scheme()
233 )));
234 }
235 Ok(Self::Http { root })
236 }
237
238 #[must_use]
240 pub fn fs(root: impl Into<PathBuf>) -> Self {
241 Self::Fs { root: root.into() }
242 }
243
244 #[must_use]
246 pub fn kind(&self) -> &'static str {
247 match self {
248 Self::Http { .. } => "http",
249 Self::Fs { .. } => "fs",
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn content_type_classification() {
260 assert_eq!(
261 ResourceKind::from_content_type("text/html; charset=utf-8"),
262 ResourceKind::Html
263 );
264 assert_eq!(
265 ResourceKind::from_content_type("application/json"),
266 ResourceKind::Json
267 );
268 assert_eq!(
269 ResourceKind::from_content_type("text/x-rust"),
270 ResourceKind::Code
271 );
272 assert_eq!(
273 ResourceKind::from_content_type("image/png"),
274 ResourceKind::Unknown
275 );
276 }
277
278 #[test]
279 fn path_classification() {
280 assert_eq!(
281 ResourceKind::from_path("/docs/intro.html"),
282 ResourceKind::Html
283 );
284 assert_eq!(ResourceKind::from_path("README.md"), ResourceKind::Markdown);
285 assert_eq!(ResourceKind::from_path("src/lib.rs"), ResourceKind::Code);
286 assert_eq!(ResourceKind::from_path("/a/b/"), ResourceKind::Unknown);
287 }
288
289 #[test]
290 fn refine_prefers_specific_path_over_generic_text() {
291 let generic = ResourceKind::from_content_type("text/plain; charset=utf-8");
293 assert_eq!(
294 generic.refine_with_path("/o/r/main/docs/a.md"),
295 ResourceKind::Markdown
296 );
297 assert_eq!(
298 generic.refine_with_path("/o/r/main/Pulumi.yaml"),
299 ResourceKind::Code
300 );
301 assert_eq!(
302 generic.refine_with_path("/o/r/main/page.html"),
303 ResourceKind::Html
304 );
305 assert_eq!(
307 generic.refine_with_path("/o/r/main/LICENSE"),
308 ResourceKind::Text
309 );
310 let html = ResourceKind::from_content_type("text/html");
312 assert_eq!(html.refine_with_path("/a/b.md"), ResourceKind::Html);
313 assert_eq!(
315 ResourceKind::Unknown.refine_with_path("/a/b.md"),
316 ResourceKind::Markdown
317 );
318 }
319
320 #[test]
321 fn kind_tag_roundtrips() {
322 for kind in [
323 ResourceKind::Html,
324 ResourceKind::Markdown,
325 ResourceKind::Code,
326 ResourceKind::Text,
327 ResourceKind::Pdf,
328 ResourceKind::Json,
329 ResourceKind::Unknown,
330 ] {
331 assert_eq!(ResourceKind::from_tag(kind.as_tag()), kind);
332 }
333 assert_eq!(ResourceKind::from_tag(200), ResourceKind::Unknown);
334 }
335
336 #[test]
337 fn linkable_and_indexable_flags() {
338 assert!(ResourceKind::Html.is_linkable());
339 assert!(
340 ResourceKind::Markdown.is_linkable(),
341 "markdown links are followed"
342 );
343 assert!(!ResourceKind::Code.is_linkable());
344 assert!(ResourceKind::Markdown.is_indexable());
345 assert!(!ResourceKind::Pdf.is_indexable());
346 }
347
348 #[test]
349 fn resource_builder_classifies_from_path() {
350 let r = Resource::new(Url::parse("https://x.dev/a/b.md").unwrap())
351 .with_etag("abc")
352 .with_size(42);
353 assert_eq!(r.kind, ResourceKind::Markdown);
354 assert_eq!(r.etag.as_deref(), Some("abc"));
355 assert_eq!(r.size, Some(42));
356 }
357
358 #[test]
359 fn source_ref_http_validates_scheme() {
360 assert!(SourceRef::http("https://example.com/docs/").is_ok());
361 assert!(SourceRef::http("ftp://example.com").is_err());
362 assert!(SourceRef::http("not a url").is_err());
363 assert_eq!(SourceRef::fs("/tmp/x").kind(), "fs");
364 }
365}