1use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use crate::ast::Ast;
11use crate::content::{ContentAddressable, ContentDigest};
12use crate::error::ScrybeError;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Document {
21 pub source: String,
23 pub path: Option<PathBuf>,
25 pub title: Option<String>,
27}
28
29impl Document {
30 pub fn new(source: impl Into<String>) -> Self {
32 Self {
33 source: source.into(),
34 path: None,
35 title: None,
36 }
37 }
38
39 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 pub fn len(&self) -> usize {
54 self.source.len()
55 }
56
57 pub fn is_empty(&self) -> bool {
59 self.source.is_empty()
60 }
61
62 pub fn ast(&self) -> Ast {
68 Ast::parse(&self.source)
69 }
70
71 pub fn title_from_ast(&self) -> Option<String> {
73 self.ast().title()
74 }
75
76 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 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 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 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 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 #[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 #[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}