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, ContentDigest};
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 digest
17/// (BLAKE3 over the source bytes only — path and title are not hashed).
18/// Rendering happens in `scrybe-render` (P1.3).
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Document {
21    /// The Markdown source text.
22    pub source: String,
23    /// Optional on-disk path. `None` for untitled / in-memory documents.
24    pub path: Option<PathBuf>,
25    /// Document title extracted from the first H1, if present.
26    pub title: Option<String>,
27}
28
29impl Document {
30    /// Creates a new in-memory document with the given source.
31    pub fn new(source: impl Into<String>) -> Self {
32        Self {
33            source: source.into(),
34            path: None,
35            title: None,
36        }
37    }
38
39    /// Creates a document from a file path and its source.
40    ///
41    /// The title is populated automatically from the first H1 heading.
42    pub fn from_file(path: PathBuf, source: impl Into<String>) -> Self {
43        let source = source.into();
44        let title = Ast::parse(&source).title();
45        Self {
46            source,
47            path: Some(path),
48            title,
49        }
50    }
51
52    /// Returns the byte length of the source.
53    pub fn len(&self) -> usize {
54        self.source.len()
55    }
56
57    /// Returns `true` if the source is empty.
58    pub fn is_empty(&self) -> bool {
59        self.source.is_empty()
60    }
61
62    // -----------------------------------------------------------------------
63    // AST helpers
64    // -----------------------------------------------------------------------
65
66    /// Parses the document source and returns its AST.
67    pub fn ast(&self) -> Ast {
68        Ast::parse(&self.source)
69    }
70
71    /// Returns the title extracted from the first H1 in the source, if any.
72    pub fn title_from_ast(&self) -> Option<String> {
73        self.ast().title()
74    }
75
76    // -----------------------------------------------------------------------
77    // CBOR serialization
78    // -----------------------------------------------------------------------
79
80    /// Serialises this document to deterministic CBOR bytes.
81    pub fn to_cbor(&self) -> Result<Vec<u8>, ScrybeError> {
82        let mut buf = Vec::new();
83        ciborium::into_writer(self, &mut buf).map_err(|e| ScrybeError::Cbor(e.to_string()))?;
84        Ok(buf)
85    }
86
87    /// Deserialises a document from CBOR bytes.
88    pub fn from_cbor(bytes: &[u8]) -> Result<Self, ScrybeError> {
89        ciborium::from_reader(bytes).map_err(|e| ScrybeError::Cbor(e.to_string()))
90    }
91}
92
93impl ContentAddressable for Document {
94    /// Digest of the raw Markdown source bytes. Path, title, and other
95    /// metadata are deliberately excluded.
96    fn content_digest(&self) -> ContentDigest {
97        ContentDigest::of(self.source.as_bytes())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_document_content_digest_stable() {
107        let doc = Document::new("# Hello\n\nWorld.");
108        let d1 = doc.content_digest();
109        let d2 = doc.content_digest();
110        assert_eq!(d1, d2);
111    }
112
113    #[test]
114    fn test_document_digest_covers_source_only() {
115        // Same source, different path/title metadata → identical digest.
116        let plain = Document::new("# Same\n\nBody.");
117        let with_path = Document::from_file(PathBuf::from("/tmp/other.md"), "# Same\n\nBody.");
118        assert_eq!(plain.content_digest(), with_path.content_digest());
119    }
120
121    #[test]
122    #[allow(deprecated)]
123    fn test_document_deprecated_content_id_matches_digest() {
124        // Compat shim: the deprecated trait method returns the same value.
125        let doc = Document::new("# Hello\n\nWorld.");
126        assert_eq!(doc.content_id(), doc.content_digest());
127    }
128
129    #[test]
130    fn test_document_is_empty() {
131        assert!(Document::new("").is_empty());
132        assert!(!Document::new("x").is_empty());
133    }
134
135    // -----------------------------------------------------------------------
136    // CBOR roundtrip
137    // -----------------------------------------------------------------------
138
139    #[test]
140    fn test_cbor_roundtrip_basic() {
141        let doc = Document::new("# Hello\n\nWorld.");
142        let bytes = doc.to_cbor().expect("encode");
143        let doc2 = Document::from_cbor(&bytes).expect("decode");
144        assert_eq!(doc.source, doc2.source);
145        assert_eq!(doc.title, doc2.title);
146        assert_eq!(doc.path, doc2.path);
147    }
148
149    #[test]
150    fn test_cbor_roundtrip_with_path() {
151        let doc = Document::from_file(PathBuf::from("/tmp/test.md"), "# Test\n\nContent.");
152        let bytes = doc.to_cbor().expect("encode");
153        let doc2 = Document::from_cbor(&bytes).expect("decode");
154        assert_eq!(doc.source, doc2.source);
155        assert_eq!(doc.path, doc2.path);
156    }
157
158    #[test]
159    fn test_cbor_roundtrip_empty() {
160        let doc = Document::new("");
161        let bytes = doc.to_cbor().expect("encode");
162        let doc2 = Document::from_cbor(&bytes).expect("decode");
163        assert!(doc2.is_empty());
164    }
165
166    #[test]
167    fn test_cbor_invalid_bytes() {
168        let result = Document::from_cbor(b"\xff\xfe garbage");
169        assert!(result.is_err());
170    }
171
172    // -----------------------------------------------------------------------
173    // AST integration
174    // -----------------------------------------------------------------------
175
176    #[test]
177    fn test_ast_returns_parsed_ast() {
178        let doc = Document::new("# Title\n\nParagraph.\n");
179        let ast = doc.ast();
180        assert!(!ast.nodes.is_empty());
181    }
182
183    #[test]
184    fn test_title_from_ast_h1() {
185        let doc = Document::new("# My Document\n\nSome text.\n");
186        assert_eq!(doc.title_from_ast(), Some("My Document".to_string()));
187    }
188
189    #[test]
190    fn test_title_from_ast_none_when_no_h1() {
191        let doc = Document::new("## Just a subheading\n");
192        assert_eq!(doc.title_from_ast(), None);
193    }
194
195    #[test]
196    fn test_from_file_populates_title() {
197        let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "# Auto Title\n\nBody text.\n");
198        assert_eq!(doc.title, Some("Auto Title".to_string()));
199    }
200
201    #[test]
202    fn test_from_file_no_h1_title_is_none() {
203        let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "No heading here.\n");
204        assert_eq!(doc.title, None);
205    }
206}