1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, FixedOffset};
4use serde::Serialize;
5use serde_json::Value;
6use thiserror::Error;
7
8use crate::util::{extract_attachment_relative, sanitize_relative_path};
9
10pub type PageId = String;
12
13#[derive(Debug, Clone, Serialize)]
15pub struct PageMeta {
16 pub id: PageId,
18 #[serde(skip)]
20 pub id_lower: String,
21 pub title: String,
23 #[serde(skip)]
25 pub title_lower: String,
26 pub path: PathBuf,
28 pub updated_at: Option<DateTime<FixedOffset>>,
30 pub tags: Vec<String>,
32 #[serde(skip)]
34 pub tags_lower: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct Page {
40 pub id: PageId,
42 pub title: String,
44 pub updated_at: Option<DateTime<FixedOffset>>,
46 pub tags: Vec<String>,
48 pub content: Vec<Node>,
50}
51
52#[derive(Debug, Clone, Serialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum Node {
56 Heading { level: u8, text: String },
58 Paragraph { text: String },
60 Text { text: String },
62 List { items: Vec<Vec<Node>> },
64 Code {
66 language: Option<String>,
67 code: String,
68 },
69 Link { text: String, url: String },
71 Quote { text: String },
73 Rewrite {
75 language: Option<String>,
76 search: String,
77 replace: String,
78 scope: Option<String>,
79 is_method_pattern: Option<bool>,
80 },
81 Unknown {
83 #[serde(rename = "source_type")]
84 typ: String,
85 raw: Value,
86 },
87}
88
89#[derive(Debug, Clone)]
91pub struct ParseIssue {
92 pub path: PathBuf,
94 pub message: String,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum SearchMatchKind {
102 Title,
104 Tag,
106 Content,
108}
109
110impl SearchMatchKind {
111 pub fn score(self) -> u32 {
114 match self {
115 SearchMatchKind::Title => 3,
116 SearchMatchKind::Tag => 2,
117 SearchMatchKind::Content => 1,
118 }
119 }
120
121 pub fn is_meta(self) -> bool {
124 matches!(self, SearchMatchKind::Title | SearchMatchKind::Tag)
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct SearchHit {
131 pub id: PageId,
133 pub kind: SearchMatchKind,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum LinkTargetKind {
140 InternalPage(PageId),
142 AttachmentPath(PathBuf),
144 ExternalUrl(String),
146 Unknown(String),
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ResolvedAttachment {
153 pub path: PathBuf,
155 pub exists: bool,
157}
158
159#[derive(Debug, Error)]
161pub enum AttachmentError {
162 #[error("attachment target was empty")]
163 Empty,
164 #[error("attachment target not recognized: {0}")]
165 NotAttachment(String),
166 #[error("attachment path escapes knowledge base root: {0}")]
167 EscapesRoot(String),
168 #[error("attachment not found: {0}")]
169 Missing(PathBuf),
170}
171
172pub(crate) type AttachmentResult<T> = std::result::Result<T, AttachmentError>;
173
174#[derive(Debug, Clone)]
176pub struct AttachmentResolver {
177 root: PathBuf,
178}
179
180impl AttachmentResolver {
181 pub fn new(root: impl AsRef<Path>) -> Self {
183 Self {
184 root: root.as_ref().to_path_buf(),
185 }
186 }
187
188 pub fn resolve(&self, raw: &str) -> AttachmentResult<ResolvedAttachment> {
190 let target = raw.trim();
191 if target.is_empty() {
192 return Err(AttachmentError::Empty);
193 }
194 let rel = extract_attachment_relative(target)
195 .ok_or_else(|| AttachmentError::NotAttachment(target.to_string()))?;
196 let rel = sanitize_relative_path(rel)?;
197 let path = self.root.join(rel);
198 let exists = path.exists();
199 Ok(ResolvedAttachment { path, exists })
200 }
201
202 pub fn resolve_path(&self, raw: &str) -> Option<PathBuf> {
204 self.resolve(raw).ok().map(|resolved| resolved.path)
205 }
206
207 pub fn resolve_existing(&self, raw: &str) -> AttachmentResult<PathBuf> {
209 let resolved = self.resolve(raw)?;
210 if resolved.exists {
211 Ok(resolved.path)
212 } else {
213 Err(AttachmentError::Missing(resolved.path))
214 }
215 }
216
217 pub fn root(&self) -> &Path {
219 &self.root
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum TitleResolution {
226 Unique(PageId),
228 NotFound,
230 Ambiguous(Vec<PageId>),
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use std::fs;
238
239 fn temp_dir_path(name: &str) -> PathBuf {
240 use std::time::{SystemTime, UNIX_EPOCH};
241 let ts = SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .expect("time")
244 .as_nanos();
245 std::env::temp_dir().join(format!("lepiter-core-{name}-{ts}"))
246 }
247
248 #[test]
249 fn search_match_kind_score_ordering() {
250 assert!(SearchMatchKind::Title.score() > SearchMatchKind::Tag.score());
251 assert!(SearchMatchKind::Tag.score() > SearchMatchKind::Content.score());
252 }
253
254 #[test]
255 fn search_match_kind_is_meta() {
256 assert!(SearchMatchKind::Title.is_meta());
257 assert!(SearchMatchKind::Tag.is_meta());
258 assert!(!SearchMatchKind::Content.is_meta());
259 }
260
261 #[test]
262 fn page_meta_serializes_without_internal_fields() {
263 let meta = PageMeta {
264 id: "abc-123".to_string(),
265 id_lower: "abc-123".to_string(),
266 title: "My Page".to_string(),
267 title_lower: "my page".to_string(),
268 path: PathBuf::from("/kb/abc-123.lepiter"),
269 updated_at: None,
270 tags: vec!["rust".to_string()],
271 tags_lower: vec!["rust".to_string()],
272 };
273 let json: serde_json::Value = serde_json::to_value(&meta).unwrap();
274 assert_eq!(json["id"], "abc-123");
275 assert_eq!(json["title"], "My Page");
276 assert_eq!(json["tags"], serde_json::json!(["rust"]));
277 assert!(json.get("id_lower").is_none());
279 assert!(json.get("title_lower").is_none());
280 assert!(json.get("tags_lower").is_none());
281 }
282
283 #[test]
284 fn page_serializes_with_content() {
285 let page = Page {
286 id: "p1".to_string(),
287 title: "Test".to_string(),
288 updated_at: None,
289 tags: Vec::new(),
290 content: vec![
291 Node::Paragraph {
292 text: "hello".to_string(),
293 },
294 Node::Code {
295 language: Some("rust".to_string()),
296 code: "fn main() {}".to_string(),
297 },
298 ],
299 };
300 let json: serde_json::Value = serde_json::to_value(&page).unwrap();
301 let content = json["content"].as_array().unwrap();
302 assert_eq!(content.len(), 2);
303 assert_eq!(content[0]["type"], "paragraph");
304 assert_eq!(content[0]["text"], "hello");
305 assert_eq!(content[1]["type"], "code");
306 assert_eq!(content[1]["language"], "rust");
307 }
308
309 #[test]
310 fn node_variants_serialize_with_type_tag() {
311 let cases: Vec<(Node, &str)> = vec![
312 (
313 Node::Heading {
314 level: 2,
315 text: "title".to_string(),
316 },
317 "heading",
318 ),
319 (
320 Node::Paragraph {
321 text: "p".to_string(),
322 },
323 "paragraph",
324 ),
325 (
326 Node::Text {
327 text: "t".to_string(),
328 },
329 "text",
330 ),
331 (Node::List { items: vec![] }, "list"),
332 (
333 Node::Code {
334 language: None,
335 code: "x".to_string(),
336 },
337 "code",
338 ),
339 (
340 Node::Link {
341 text: "a".to_string(),
342 url: "b".to_string(),
343 },
344 "link",
345 ),
346 (
347 Node::Quote {
348 text: "q".to_string(),
349 },
350 "quote",
351 ),
352 (
353 Node::Unknown {
354 typ: "wardleyMap".to_string(),
355 raw: serde_json::json!({}),
356 },
357 "unknown",
358 ),
359 ];
360 for (node, expected_type) in cases {
361 let json: serde_json::Value = serde_json::to_value(&node).unwrap();
362 assert_eq!(json["type"], expected_type, "wrong type tag for {:?}", node);
363 }
364 }
365
366 #[test]
367 fn unknown_node_serializes_source_type() {
368 let node = Node::Unknown {
369 typ: "wardleyMap".to_string(),
370 raw: serde_json::json!({"data": 1}),
371 };
372 let json: serde_json::Value = serde_json::to_value(&node).unwrap();
373 assert_eq!(json["source_type"], "wardleyMap");
374 assert_eq!(json["raw"]["data"], 1);
375 }
376
377 #[test]
378 fn search_match_kind_serializes_lowercase() {
379 assert_eq!(
380 serde_json::to_value(SearchMatchKind::Title).unwrap(),
381 serde_json::json!("title")
382 );
383 assert_eq!(
384 serde_json::to_value(SearchMatchKind::Tag).unwrap(),
385 serde_json::json!("tag")
386 );
387 assert_eq!(
388 serde_json::to_value(SearchMatchKind::Content).unwrap(),
389 serde_json::json!("content")
390 );
391 }
392
393 #[test]
394 fn search_hit_serializes() {
395 let hit = SearchHit {
396 id: "p1".to_string(),
397 kind: SearchMatchKind::Tag,
398 };
399 let json: serde_json::Value = serde_json::to_value(&hit).unwrap();
400 assert_eq!(json["id"], "p1");
401 assert_eq!(json["kind"], "tag");
402 }
403
404 #[test]
405 fn attachment_resolver_reports_missing_files() -> anyhow::Result<()> {
406 let root = temp_dir_path("attachments");
407 let attachments = root.join("attachments");
408 fs::create_dir_all(&attachments)?;
409 fs::write(attachments.join("ok.txt"), b"ok")?;
410
411 let resolver = AttachmentResolver::new(&root);
412 let resolved = resolver.resolve("attachments/ok.txt")?;
413 assert!(resolved.exists);
414
415 let missing = resolver.resolve_existing("attachments/missing.txt");
416 assert!(matches!(missing, Err(AttachmentError::Missing(_))));
417
418 fs::remove_dir_all(&root)?;
419 Ok(())
420 }
421}