Skip to main content

lepiter_core/
model.rs

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
10/// Canonical page identifier used throughout the API.
11pub type PageId = String;
12
13/// Metadata for a page discovered during index scanning.
14#[derive(Debug, Clone, Serialize)]
15pub struct PageMeta {
16    /// Canonical page id (preferred key over filename).
17    pub id: PageId,
18    /// Pre-computed lowercased id for case-insensitive comparisons.
19    #[serde(skip)]
20    pub id_lower: String,
21    /// Human-readable page title.
22    pub title: String,
23    /// Pre-computed lowercased title for case-insensitive comparisons.
24    #[serde(skip)]
25    pub title_lower: String,
26    /// Absolute or relative path to the source page file.
27    pub path: PathBuf,
28    /// Last edit timestamp, if present in source metadata.
29    pub updated_at: Option<DateTime<FixedOffset>>,
30    /// Optional page tags extracted from metadata.
31    pub tags: Vec<String>,
32    /// Pre-computed lowercased tags for case-insensitive comparisons.
33    #[serde(skip)]
34    pub tags_lower: Vec<String>,
35}
36
37/// Fully parsed page content.
38#[derive(Debug, Clone, Serialize)]
39pub struct Page {
40    /// Canonical page id.
41    pub id: PageId,
42    /// Page title.
43    pub title: String,
44    /// Last edit timestamp, if present.
45    pub updated_at: Option<DateTime<FixedOffset>>,
46    /// Page tags.
47    pub tags: Vec<String>,
48    /// Parsed block-level content.
49    pub content: Vec<Node>,
50}
51
52/// Block-oriented normalized node model used by consumers (e.g. TUI).
53#[derive(Debug, Clone, Serialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum Node {
56    /// Markdown-style heading.
57    Heading { level: u8, text: String },
58    /// Paragraph text.
59    Paragraph { text: String },
60    /// Plain text line.
61    Text { text: String },
62    /// List with item nodes.
63    List { items: Vec<Vec<Node>> },
64    /// Code block with optional language.
65    Code {
66        language: Option<String>,
67        code: String,
68    },
69    /// Link block.
70    Link { text: String, url: String },
71    /// Quote block.
72    Quote { text: String },
73    /// Rewrite block (search/replace transformation).
74    Rewrite {
75        language: Option<String>,
76        search: String,
77        replace: String,
78        scope: Option<String>,
79        is_method_pattern: Option<bool>,
80    },
81    /// Unknown/unsupported source node type preserved losslessly.
82    Unknown {
83        #[serde(rename = "source_type")]
84        typ: String,
85        raw: Value,
86    },
87}
88
89/// Non-fatal parse/indexing issue associated with a source file.
90#[derive(Debug, Clone)]
91pub struct ParseIssue {
92    /// File path where the issue occurred.
93    pub path: PathBuf,
94    /// Human-readable error description.
95    pub message: String,
96}
97
98/// Match category for search results.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum SearchMatchKind {
102    /// Match came from page title or id.
103    Title,
104    /// Match came from a page tag.
105    Tag,
106    /// Match came from rendered page content.
107    Content,
108}
109
110impl SearchMatchKind {
111    /// Relevance score used for ranking search results.
112    /// Higher is more relevant.
113    pub fn score(self) -> u32 {
114        match self {
115            SearchMatchKind::Title => 3,
116            SearchMatchKind::Tag => 2,
117            SearchMatchKind::Content => 1,
118        }
119    }
120
121    /// Returns `true` when the match came from metadata (title, id, or tags)
122    /// rather than page content.
123    pub fn is_meta(self) -> bool {
124        matches!(self, SearchMatchKind::Title | SearchMatchKind::Tag)
125    }
126}
127
128/// Search result entry for one page.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct SearchHit {
131    /// Canonical page id.
132    pub id: PageId,
133    /// How this page matched.
134    pub kind: SearchMatchKind,
135}
136
137/// Classification of a raw link target.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum LinkTargetKind {
140    /// Resolved to an internal page id.
141    InternalPage(PageId),
142    /// Resolved to an attachment file path in the knowledge base.
143    AttachmentPath(PathBuf),
144    /// Resolved to an external URL/scheme target.
145    ExternalUrl(String),
146    /// Could not classify target.
147    Unknown(String),
148}
149
150/// Resolved attachment target.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ResolvedAttachment {
153    /// Full path to the attachment.
154    pub path: PathBuf,
155    /// Whether the attachment exists on disk.
156    pub exists: bool,
157}
158
159/// Attachment resolution failures.
160#[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/// Resolves attachment targets relative to the knowledge base root.
175#[derive(Debug, Clone)]
176pub struct AttachmentResolver {
177    root: PathBuf,
178}
179
180impl AttachmentResolver {
181    /// Creates a resolver rooted at the knowledge base path.
182    pub fn new(root: impl AsRef<Path>) -> Self {
183        Self {
184            root: root.as_ref().to_path_buf(),
185        }
186    }
187
188    /// Resolves an attachment target to a path and existence flag.
189    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    /// Resolves an attachment target to a path only (ignores missing).
203    pub fn resolve_path(&self, raw: &str) -> Option<PathBuf> {
204        self.resolve(raw).ok().map(|resolved| resolved.path)
205    }
206
207    /// Resolves an attachment target and ensures the file exists.
208    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    /// Returns the resolver root.
218    pub fn root(&self) -> &Path {
219        &self.root
220    }
221}
222
223/// Result of resolving a page by title.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum TitleResolution {
226    /// A unique page id was resolved.
227    Unique(PageId),
228    /// No matching title found.
229    NotFound,
230    /// Multiple candidate page ids matched.
231    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        // Internal lowercase fields must not appear in output.
278        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}