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