Skip to main content

link_r/
resource.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! Resource addressing: what a source discovers and what gets indexed.
5//!
6//! A [`Resource`] is a remote document the index points *at* (its body stays at
7//! the URL). A [`Page`] couples a resource with its fetched body for the indexing
8//! pipeline. [`SourceRef`] names the parent a [`Source`](crate) crawls from.
9
10use crate::payload::DocPayload;
11use compact_str::CompactString;
12use std::path::PathBuf;
13use url::Url;
14
15/// A dense, index-local document identifier.
16pub type DocId = u32;
17
18/// The content class of a resource, used to select an extractor.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum ResourceKind {
22    /// HTML page (`text/html`).
23    Html,
24    /// Markdown document.
25    Markdown,
26    /// Source code or config.
27    Code,
28    /// Plain text.
29    Text,
30    /// PDF document.
31    Pdf,
32    /// JSON document.
33    Json,
34    /// Unknown / unclassified.
35    Unknown,
36}
37
38impl ResourceKind {
39    /// Classify from an HTTP `Content-Type` header value (parameters ignored).
40    #[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    /// Classify from a path or URL suffix.
60    #[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    /// Refine a header-derived kind with path evidence.
77    ///
78    /// `text/plain` is a generic server's shrug, not a real claim —
79    /// raw.githubusercontent.com serves *everything* as `text/plain`, so
80    /// trusting the header there classified `.md` as [`ResourceKind::Text`]
81    /// and markdown link-following never engaged. When the header says only
82    /// Text (or nothing usable), a more specific path extension wins; a
83    /// specific header claim (`text/html`, `text/markdown`, …) is never
84    /// overridden.
85    #[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    /// Whether this kind carries text worth extracting and indexing.
97    #[must_use]
98    pub fn is_indexable(self) -> bool {
99        !matches!(self, Self::Pdf | Self::Unknown)
100    }
101
102    /// Whether this kind can contain crawlable `<a href>` links.
103    #[must_use]
104    pub fn is_linkable(self) -> bool {
105        matches!(self, Self::Html | Self::Markdown)
106    }
107
108    /// A stable single-byte tag for on-disk metadata columns.
109    #[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    /// Inverse of [`ResourceKind::as_tag`]; unknown tags decode to [`ResourceKind::Unknown`].
123    #[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/// A remote document the index resolves to. The body lives at [`Resource::url`]
138/// and is fetched lazily by the caller; the index stores only this addressing
139/// plus a distilled descriptor.
140#[derive(Clone, Debug)]
141pub struct Resource {
142    /// The canonical remote URL.
143    pub url: Url,
144    /// The content class.
145    pub kind: ResourceKind,
146    /// An opaque change token (HTTP `ETag`, git blob SHA, or content hash) used to
147    /// skip re-embedding unchanged pages on re-crawl.
148    pub etag: Option<CompactString>,
149    /// The body size in bytes, if known.
150    pub size: Option<u64>,
151}
152
153impl Resource {
154    /// Create a resource, classifying its kind from the URL path.
155    #[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    /// Set the content kind explicitly (e.g. from a `Content-Type` header).
167    #[must_use]
168    pub fn with_kind(mut self, kind: ResourceKind) -> Self {
169        self.kind = kind;
170        self
171    }
172
173    /// Set the change token.
174    #[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    /// Set the known size.
181    #[must_use]
182    pub fn with_size(mut self, size: u64) -> Self {
183        self.size = Some(size);
184        self
185    }
186}
187
188/// A fetched page handed to the indexing pipeline: a [`Resource`] plus its body.
189///
190/// Crawl sources fetch a page to enumerate its links *and* extract its keywords,
191/// so they emit `Page` directly (the body is reused for both — no double fetch).
192#[derive(Debug)]
193pub struct Page<'a> {
194    /// The resource this page addresses.
195    pub resource: Resource,
196    /// The fetched body, in whichever zero-copy representation the source produced.
197    pub payload: DocPayload<'a>,
198}
199
200impl<'a> Page<'a> {
201    /// Couple a resource with its fetched payload.
202    #[must_use]
203    pub fn new(resource: Resource, payload: DocPayload<'a>) -> Self {
204        Self { resource, payload }
205    }
206}
207
208/// The parent a source crawls/enumerates from.
209#[derive(Clone, Debug)]
210#[non_exhaustive]
211pub enum SourceRef {
212    /// An HTTPS (or HTTP) parent URL to crawl recursively.
213    Http {
214        /// The root URL the crawl starts from.
215        root: Url,
216    },
217    /// A local directory tree to enumerate.
218    Fs {
219        /// The root directory.
220        root: PathBuf,
221    },
222}
223
224impl SourceRef {
225    /// Build an HTTP source ref from a URL string.
226    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    /// Build a filesystem source ref from a path.
239    #[must_use]
240    pub fn fs(root: impl Into<PathBuf>) -> Self {
241        Self::Fs { root: root.into() }
242    }
243
244    /// A stable label for the source kind.
245    #[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        // The raw.githubusercontent shape: everything arrives text/plain.
292        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        // A path with nothing to say leaves Text alone.
306        assert_eq!(
307            generic.refine_with_path("/o/r/main/LICENSE"),
308            ResourceKind::Text
309        );
310        // A specific header claim is never overridden by the path.
311        let html = ResourceKind::from_content_type("text/html");
312        assert_eq!(html.refine_with_path("/a/b.md"), ResourceKind::Html);
313        // Unknown still falls through to the path (the pre-existing behavior).
314        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}