Skip to main content

seyk_core/
resource.rs

1use std::path::{Path, PathBuf};
2
3use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5
6use seyk_embed::Modality;
7
8/// Embedded PDF text format used by PageText locators, available without PDF extraction.
9pub const PDF_TEXT_VERSION: &str = "pdf-extract-0.12.1;plain-v1";
10
11/// A canonical absolute URI identifying a resource, serialized as a string.
12/// File URIs preserve non-UTF-8 Unix path bytes through percent encoding.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(try_from = "String", into = "String")]
15pub struct ResourceId(String);
16
17impl ResourceId {
18    /// Convert an absolute local path without accessing the filesystem.
19    /// URI dot segments are normalized. Request `.paths(...)` retains logical
20    /// root names for policy checks; discovery emits resolved resource IDs.
21    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
22        let url = url::Url::from_file_path(path.as_ref())
23            .map_err(|_| Error::Config("resource path must be absolute".into()))?;
24        if url
25            .host_str()
26            .is_some_and(|host| !host.is_empty() && host != "localhost")
27        {
28            return Err(Error::Config(
29                "resource path must be local (UNC hosts are unsupported)".into(),
30            ));
31        }
32        if url.path().to_ascii_lowercase().contains("%00") {
33            return Err(Error::Config("resource path contains NUL".into()));
34        }
35        let canonical = url::Url::parse(url.as_str()).map_err(|e| Error::Config(e.to_string()))?;
36        Ok(Self(canonical.into()))
37    }
38
39    pub(crate) fn labels(&self) -> (String, String) {
40        if let Some(path) = self.to_file_path() {
41            let name = path
42                .file_name()
43                .map(|s| s.to_string_lossy().into_owned())
44                .unwrap_or_default();
45            (name, path.to_string_lossy().into_owned())
46        } else {
47            let url = url::Url::parse(&self.0).expect("validated URI");
48            let name = url.path().rsplit('/').next().unwrap_or("").to_string();
49            (name, self.0.clone())
50        }
51    }
52
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    pub fn scheme(&self) -> &str {
58        self.0.split_once(':').expect("validated URI").0
59    }
60
61    /// Return the local path for a file URI; other schemes have no local path.
62    pub fn to_file_path(&self) -> Option<PathBuf> {
63        if self.scheme() != "file" {
64            return None;
65        }
66        url::Url::parse(&self.0).ok()?.to_file_path().ok()
67    }
68}
69
70impl std::str::FromStr for ResourceId {
71    type Err = Error;
72    fn from_str(value: &str) -> Result<Self> {
73        if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
74            return Err(Error::Config(
75                "resource URI contains whitespace or controls".into(),
76            ));
77        }
78        let bytes = value.as_bytes();
79        for (i, b) in bytes.iter().enumerate() {
80            if *b == b'%'
81                && (i + 2 >= bytes.len()
82                    || !bytes[i + 1].is_ascii_hexdigit()
83                    || !bytes[i + 2].is_ascii_hexdigit())
84            {
85                return Err(Error::Config("invalid URI percent escape".into()));
86            }
87        }
88        let url = url::Url::parse(value)
89            .map_err(|e| Error::Config(format!("invalid resource URI: {e}")))?;
90        if url.scheme() == "file" {
91            if url.query().is_some()
92                || url.fragment().is_some()
93                || !url.username().is_empty()
94                || url.password().is_some()
95                || url.port().is_some()
96                || url
97                    .host_str()
98                    .is_some_and(|h| h != "localhost" && !h.is_empty())
99            {
100                return Err(Error::Config(
101                    "file URI must identify a local path without query or fragment".into(),
102                ));
103            }
104            let path = url
105                .to_file_path()
106                .map_err(|_| Error::Config("invalid local file URI".into()))?;
107            return Self::from_path(path);
108        }
109        Ok(Self(url.into()))
110    }
111}
112
113impl TryFrom<String> for ResourceId {
114    type Error = Error;
115    fn try_from(value: String) -> Result<Self> {
116        value.parse()
117    }
118}
119impl From<ResourceId> for String {
120    fn from(value: ResourceId) -> Self {
121        value.0
122    }
123}
124impl std::fmt::Display for ResourceId {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129impl Ord for ResourceId {
130    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
131        match (self.to_file_path(), other.to_file_path()) {
132            (Some(a), Some(b)) => a.cmp(&b).then_with(|| self.0.cmp(&other.0)),
133            (Some(_), None) => std::cmp::Ordering::Less,
134            (None, Some(_)) => std::cmp::Ordering::Greater,
135            (None, None) => self.0.cmp(&other.0),
136        }
137    }
138}
139impl PartialOrd for ResourceId {
140    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
141        Some(self.cmp(other))
142    }
143}
144
145/// Where inside a resource a hit was found. Deliberately modality-independent.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum ResourceLocator {
150    /// A byte range of a text resource. Lines are 1-based and inclusive.
151    Text {
152        byte_start: u64,
153        byte_end: u64,
154        line_start: Option<u64>,
155        line_end: Option<u64>,
156    },
157    /// UTF-8 range within deterministic extracted text of a 1-based physical page.
158    PageText {
159        page: u32,
160        byte_start: u64,
161        byte_end: u64,
162        text_version: String,
163        page_text_hash: String,
164    },
165    /// A page of a paged document (reserved for PDF and similar).
166    Page { page: u32 },
167    /// A time window of an audio or video resource.
168    Time { start_ms: u64, end_ms: u64 },
169    /// Rectangle in original-image pixels. Origin is top-left; x grows right,
170    /// y grows down. Reserved: no current provider produces this locator.
171    ImageRegion {
172        x: u32,
173        y: u32,
174        width: u32,
175        height: u32,
176    },
177    /// The resource as a whole, for example a single image.
178    WholeResource,
179}
180
181impl ResourceLocator {
182    /// Stable wire kind used for returned-hit counts.
183    pub fn kind(&self) -> &'static str {
184        match self {
185            Self::Text { .. } => "text",
186            Self::PageText { .. } => "page_text",
187            Self::Page { .. } => "page",
188            Self::Time { .. } => "time",
189            Self::ImageRegion { .. } => "image_region",
190            Self::WholeResource => "whole_resource",
191        }
192    }
193}
194
195/// Facts about a resource at the time it was embedded.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ResourceMetadata {
198    /// Persisted dispatch hint; never guessed from the resource URI on extraction.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub media_type: Option<String>,
201    pub modality: Modality,
202    pub size_bytes: u64,
203    /// Modification time in nanoseconds since the Unix epoch, when available.
204    pub modified_ns: Option<i64>,
205    /// BLAKE3 hash of the full resource content, hex encoded.
206    pub content_hash: String,
207}
208
209// JSON's path is a display aid; ResourceId is the lossless identity.
210pub(crate) fn serialize_optional_path<S: serde::Serializer>(
211    path: &Option<PathBuf>,
212    serializer: S,
213) -> std::result::Result<S::Ok, S::Error> {
214    path.as_ref()
215        .map(|p| p.to_string_lossy())
216        .serialize(serializer)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn uri_and_serde_round_trip() {
225        for suffix in ["plain.md", "hello world/文書 #?%.md"] {
226            let path = std::env::temp_dir().join(suffix);
227            let id = ResourceId::from_path(&path).unwrap();
228            assert_eq!(id.to_file_path().unwrap(), path);
229            assert_eq!(id.as_str().parse::<ResourceId>().unwrap(), id);
230            assert_eq!(
231                serde_json::from_str::<ResourceId>(&serde_json::to_string(&id).unwrap()).unwrap(),
232                id
233            );
234        }
235        let uri = if cfg!(windows) {
236            "file:///C:/tmp/a%20b"
237        } else {
238            "file:///tmp/a%20b"
239        };
240        let id: ResourceId = uri.parse().unwrap();
241        assert_eq!(
242            ResourceId::from_path(id.to_file_path().unwrap()).unwrap(),
243            id
244        );
245        let alias = uri.replace("file:///", "file://localhost/");
246        assert_eq!(alias.parse::<ResourceId>().unwrap(), id);
247        assert!(
248            "mem://notes/chapter"
249                .parse::<ResourceId>()
250                .unwrap()
251                .to_file_path()
252                .is_none()
253        );
254    }
255
256    #[test]
257    fn rejects_invalid_ids() {
258        for uri in [
259            "relative/path",
260            "file:///tmp/a b",
261            "file:///tmp/%",
262            "file:///tmp/%xz",
263            "file:///tmp/%00",
264            "file://remote/tmp/a",
265            "file:///tmp/a?x",
266            "file:///tmp/a#x",
267            "mem://x/\n",
268        ] {
269            assert!(uri.parse::<ResourceId>().is_err(), "{uri:?}");
270            assert!(serde_json::from_value::<ResourceId>(serde_json::json!(uri)).is_err());
271        }
272        assert!(ResourceId::from_path("relative").is_err());
273        let id = ResourceId::from_path(std::env::temp_dir().join("a/../b")).unwrap();
274        assert_eq!(
275            id,
276            ResourceId::from_path(std::env::temp_dir().join("b")).unwrap()
277        );
278        assert_eq!(id.as_str().parse::<ResourceId>().unwrap(), id);
279    }
280
281    #[cfg(unix)]
282    #[test]
283    fn non_utf8_path_round_trips() {
284        use std::os::unix::ffi::OsStringExt;
285        let path = PathBuf::from(std::ffi::OsString::from_vec(
286            b"/tmp/invalid-\xff.md".to_vec(),
287        ));
288        let id = ResourceId::from_path(&path).unwrap();
289        assert!(id.as_str().contains("%FF"));
290        assert_eq!(id.to_file_path().unwrap(), path);
291        assert_eq!(id.to_string().parse::<ResourceId>().unwrap(), id);
292    }
293
294    #[cfg(windows)]
295    #[test]
296    fn windows_drive_path_round_trips() {
297        let path = Path::new(r"C:\notes\a b.md");
298        let id = ResourceId::from_path(path).unwrap();
299        assert_eq!(id.to_file_path().unwrap(), path);
300        assert!(ResourceId::from_path(r"\\server\share\note.md").is_err());
301    }
302
303    #[test]
304    fn image_region_serde() {
305        let region = ResourceLocator::ImageRegion {
306            x: 1,
307            y: 2,
308            width: 3,
309            height: 4,
310        };
311        let json = serde_json::to_value(&region).unwrap();
312        assert_eq!(json["kind"], "image_region");
313        assert_eq!(
314            serde_json::from_value::<ResourceLocator>(json).unwrap(),
315            region
316        );
317    }
318}
319
320#[cfg(test)]
321mod kind_count_tests {
322    use super::*;
323    #[test]
324    fn count_keys_match_every_locator_wire_kind() {
325        let locators = [
326            ResourceLocator::Text {
327                byte_start: 0,
328                byte_end: 1,
329                line_start: None,
330                line_end: None,
331            },
332            ResourceLocator::PageText {
333                page: 1,
334                byte_start: 0,
335                byte_end: 1,
336                text_version: "v1".into(),
337                page_text_hash: "hash".into(),
338            },
339            ResourceLocator::Page { page: 2 },
340            ResourceLocator::Time {
341                start_ms: 0,
342                end_ms: 1,
343            },
344            ResourceLocator::ImageRegion {
345                x: 0,
346                y: 0,
347                width: 1,
348                height: 1,
349            },
350            ResourceLocator::WholeResource,
351        ];
352        for locator in locators {
353            assert_eq!(
354                serde_json::to_value(&locator).unwrap()["kind"],
355                locator.kind()
356            );
357        }
358    }
359}