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 {
243 tempfile::Builder::new()
244 .prefix(&format!("lepiter-core-{name}-"))
245 .tempdir()
246 .expect("temp dir")
247 .keep()
248 }
249
250 #[test]
251 fn search_match_kind_score_ordering() {
252 assert!(SearchMatchKind::Title.score() > SearchMatchKind::Tag.score());
253 assert!(SearchMatchKind::Tag.score() > SearchMatchKind::Content.score());
254 }
255
256 #[test]
257 fn search_match_kind_is_meta() {
258 assert!(SearchMatchKind::Title.is_meta());
259 assert!(SearchMatchKind::Tag.is_meta());
260 assert!(!SearchMatchKind::Content.is_meta());
261 }
262
263 #[test]
264 fn page_meta_serializes_without_internal_fields() {
265 let meta = PageMeta {
266 id: "abc-123".to_string(),
267 id_lower: "abc-123".to_string(),
268 title: "My Page".to_string(),
269 title_lower: "my page".to_string(),
270 path: PathBuf::from("/kb/abc-123.lepiter"),
271 updated_at: None,
272 tags: vec!["rust".to_string()],
273 tags_lower: vec!["rust".to_string()],
274 };
275 let json: serde_json::Value = serde_json::to_value(&meta).unwrap();
276 assert_eq!(json["id"], "abc-123");
277 assert_eq!(json["title"], "My Page");
278 assert_eq!(json["tags"], serde_json::json!(["rust"]));
279 assert!(json.get("id_lower").is_none());
281 assert!(json.get("title_lower").is_none());
282 assert!(json.get("tags_lower").is_none());
283 }
284
285 #[test]
286 fn page_serializes_with_content() {
287 let page = Page {
288 id: "p1".to_string(),
289 title: "Test".to_string(),
290 updated_at: None,
291 tags: Vec::new(),
292 content: vec![
293 Node::Paragraph {
294 text: "hello".to_string(),
295 },
296 Node::Code {
297 language: Some("rust".to_string()),
298 code: "fn main() {}".to_string(),
299 },
300 ],
301 };
302 let json: serde_json::Value = serde_json::to_value(&page).unwrap();
303 let content = json["content"].as_array().unwrap();
304 assert_eq!(content.len(), 2);
305 assert_eq!(content[0]["type"], "paragraph");
306 assert_eq!(content[0]["text"], "hello");
307 assert_eq!(content[1]["type"], "code");
308 assert_eq!(content[1]["language"], "rust");
309 }
310
311 #[test]
312 fn node_variants_serialize_with_type_tag() {
313 let cases: Vec<(Node, &str)> = vec![
314 (
315 Node::Heading {
316 level: 2,
317 text: "title".to_string(),
318 },
319 "heading",
320 ),
321 (
322 Node::Paragraph {
323 text: "p".to_string(),
324 },
325 "paragraph",
326 ),
327 (
328 Node::Text {
329 text: "t".to_string(),
330 },
331 "text",
332 ),
333 (Node::List { items: vec![] }, "list"),
334 (
335 Node::Code {
336 language: None,
337 code: "x".to_string(),
338 },
339 "code",
340 ),
341 (
342 Node::Link {
343 text: "a".to_string(),
344 url: "b".to_string(),
345 },
346 "link",
347 ),
348 (
349 Node::Quote {
350 text: "q".to_string(),
351 },
352 "quote",
353 ),
354 (
355 Node::Unknown {
356 typ: "wardleyMap".to_string(),
357 raw: serde_json::json!({}),
358 },
359 "unknown",
360 ),
361 ];
362 for (node, expected_type) in cases {
363 let json: serde_json::Value = serde_json::to_value(&node).unwrap();
364 assert_eq!(json["type"], expected_type, "wrong type tag for {:?}", node);
365 }
366 }
367
368 #[test]
369 fn unknown_node_serializes_source_type() {
370 let node = Node::Unknown {
371 typ: "wardleyMap".to_string(),
372 raw: serde_json::json!({"data": 1}),
373 };
374 let json: serde_json::Value = serde_json::to_value(&node).unwrap();
375 assert_eq!(json["source_type"], "wardleyMap");
376 assert_eq!(json["raw"]["data"], 1);
377 }
378
379 #[test]
380 fn search_match_kind_serializes_lowercase() {
381 assert_eq!(
382 serde_json::to_value(SearchMatchKind::Title).unwrap(),
383 serde_json::json!("title")
384 );
385 assert_eq!(
386 serde_json::to_value(SearchMatchKind::Tag).unwrap(),
387 serde_json::json!("tag")
388 );
389 assert_eq!(
390 serde_json::to_value(SearchMatchKind::Content).unwrap(),
391 serde_json::json!("content")
392 );
393 }
394
395 #[test]
396 fn search_hit_serializes() {
397 let hit = SearchHit {
398 id: "p1".to_string(),
399 kind: SearchMatchKind::Tag,
400 };
401 let json: serde_json::Value = serde_json::to_value(&hit).unwrap();
402 assert_eq!(json["id"], "p1");
403 assert_eq!(json["kind"], "tag");
404 }
405
406 #[test]
407 fn attachment_resolver_reports_missing_files() -> anyhow::Result<()> {
408 let root = temp_dir_path("attachments");
409 let attachments = root.join("attachments");
410 fs::create_dir_all(&attachments)?;
411 fs::write(attachments.join("ok.txt"), b"ok")?;
412
413 let resolver = AttachmentResolver::new(&root);
414 let resolved = resolver.resolve("attachments/ok.txt")?;
415 assert!(resolved.exists);
416
417 let missing = resolver.resolve_existing("attachments/missing.txt");
418 assert!(matches!(missing, Err(AttachmentError::Missing(_))));
419
420 fs::remove_dir_all(&root)?;
421 Ok(())
422 }
423}