Skip to main content

scrybe_core/
document.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! The Document type — the central editing unit in Scrybe.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use crate::ast::Ast;
11use crate::content::{ContentAddressable, ContentId};
12use crate::error::ScrybeError;
13
14/// A Scrybe document — Markdown source with associated metadata.
15///
16/// Holds raw source text and a lazily-computed content identifier.
17/// Rendering happens in `scrybe-render` (P1.3).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Document {
20    /// The Markdown source text.
21    pub source: String,
22    /// Optional on-disk path. `None` for untitled / in-memory documents.
23    pub path: Option<PathBuf>,
24    /// Document title extracted from the first H1, if present.
25    pub title: Option<String>,
26}
27
28impl Document {
29    /// Creates a new in-memory document with the given source.
30    pub fn new(source: impl Into<String>) -> Self {
31        Self {
32            source: source.into(),
33            path: None,
34            title: None,
35        }
36    }
37
38    /// Creates a document from a file path and its source.
39    ///
40    /// The title is populated automatically from the first H1 heading.
41    pub fn from_file(path: PathBuf, source: impl Into<String>) -> Self {
42        let source = source.into();
43        let title = Ast::parse(&source).title();
44        Self {
45            source,
46            path: Some(path),
47            title,
48        }
49    }
50
51    /// Returns the byte length of the source.
52    pub fn len(&self) -> usize {
53        self.source.len()
54    }
55
56    /// Returns `true` if the source is empty.
57    pub fn is_empty(&self) -> bool {
58        self.source.is_empty()
59    }
60
61    // -----------------------------------------------------------------------
62    // AST helpers
63    // -----------------------------------------------------------------------
64
65    /// Parses the document source and returns its AST.
66    pub fn ast(&self) -> Ast {
67        Ast::parse(&self.source)
68    }
69
70    /// Returns the title extracted from the first H1 in the source, if any.
71    pub fn title_from_ast(&self) -> Option<String> {
72        self.ast().title()
73    }
74
75    // -----------------------------------------------------------------------
76    // CBOR serialization
77    // -----------------------------------------------------------------------
78
79    /// Serialises this document to deterministic CBOR bytes.
80    pub fn to_cbor(&self) -> Result<Vec<u8>, ScrybeError> {
81        let mut buf = Vec::new();
82        ciborium::into_writer(self, &mut buf).map_err(|e| ScrybeError::Cbor(e.to_string()))?;
83        Ok(buf)
84    }
85
86    /// Deserialises a document from CBOR bytes.
87    pub fn from_cbor(bytes: &[u8]) -> Result<Self, ScrybeError> {
88        ciborium::from_reader(bytes).map_err(|e| ScrybeError::Cbor(e.to_string()))
89    }
90}
91
92impl ContentAddressable for Document {
93    fn content_id(&self) -> ContentId {
94        ContentId::of(self.source.as_bytes())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_document_content_id_stable() {
104        let doc = Document::new("# Hello\n\nWorld.");
105        let id1 = doc.content_id();
106        let id2 = doc.content_id();
107        assert_eq!(id1, id2);
108    }
109
110    #[test]
111    fn test_document_is_empty() {
112        assert!(Document::new("").is_empty());
113        assert!(!Document::new("x").is_empty());
114    }
115
116    // -----------------------------------------------------------------------
117    // CBOR roundtrip
118    // -----------------------------------------------------------------------
119
120    #[test]
121    fn test_cbor_roundtrip_basic() {
122        let doc = Document::new("# Hello\n\nWorld.");
123        let bytes = doc.to_cbor().expect("encode");
124        let doc2 = Document::from_cbor(&bytes).expect("decode");
125        assert_eq!(doc.source, doc2.source);
126        assert_eq!(doc.title, doc2.title);
127        assert_eq!(doc.path, doc2.path);
128    }
129
130    #[test]
131    fn test_cbor_roundtrip_with_path() {
132        let doc = Document::from_file(PathBuf::from("/tmp/test.md"), "# Test\n\nContent.");
133        let bytes = doc.to_cbor().expect("encode");
134        let doc2 = Document::from_cbor(&bytes).expect("decode");
135        assert_eq!(doc.source, doc2.source);
136        assert_eq!(doc.path, doc2.path);
137    }
138
139    #[test]
140    fn test_cbor_roundtrip_empty() {
141        let doc = Document::new("");
142        let bytes = doc.to_cbor().expect("encode");
143        let doc2 = Document::from_cbor(&bytes).expect("decode");
144        assert!(doc2.is_empty());
145    }
146
147    #[test]
148    fn test_cbor_invalid_bytes() {
149        let result = Document::from_cbor(b"\xff\xfe garbage");
150        assert!(result.is_err());
151    }
152
153    // -----------------------------------------------------------------------
154    // AST integration
155    // -----------------------------------------------------------------------
156
157    #[test]
158    fn test_ast_returns_parsed_ast() {
159        let doc = Document::new("# Title\n\nParagraph.\n");
160        let ast = doc.ast();
161        assert!(!ast.nodes.is_empty());
162    }
163
164    #[test]
165    fn test_title_from_ast_h1() {
166        let doc = Document::new("# My Document\n\nSome text.\n");
167        assert_eq!(doc.title_from_ast(), Some("My Document".to_string()));
168    }
169
170    #[test]
171    fn test_title_from_ast_none_when_no_h1() {
172        let doc = Document::new("## Just a subheading\n");
173        assert_eq!(doc.title_from_ast(), None);
174    }
175
176    #[test]
177    fn test_from_file_populates_title() {
178        let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "# Auto Title\n\nBody text.\n");
179        assert_eq!(doc.title, Some("Auto Title".to_string()));
180    }
181
182    #[test]
183    fn test_from_file_no_h1_title_is_none() {
184        let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "No heading here.\n");
185        assert_eq!(doc.title, None);
186    }
187}