1use std::path::{Path, PathBuf};
2
3use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5
6use seyk_embed::Modality;
7
8pub const PDF_TEXT_VERSION: &str = "pdf-extract-0.12.1;plain-v1";
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(try_from = "String", into = "String")]
15pub struct ResourceId(String);
16
17impl ResourceId {
18 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum ResourceLocator {
150 Text {
152 byte_start: u64,
153 byte_end: u64,
154 line_start: Option<u64>,
155 line_end: Option<u64>,
156 },
157 PageText {
159 page: u32,
160 byte_start: u64,
161 byte_end: u64,
162 text_version: String,
163 page_text_hash: String,
164 },
165 Page { page: u32 },
167 Time { start_ms: u64, end_ms: u64 },
169 ImageRegion {
172 x: u32,
173 y: u32,
174 width: u32,
175 height: u32,
176 },
177 WholeResource,
179}
180
181impl ResourceLocator {
182 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ResourceMetadata {
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub media_type: Option<String>,
201 pub modality: Modality,
202 pub size_bytes: u64,
203 pub modified_ns: Option<i64>,
205 pub content_hash: String,
207}
208
209pub(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(®ion).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}