use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ast::Ast;
use crate::content::{ContentAddressable, ContentId};
use crate::error::ScrybeError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Document {
pub source: String,
pub path: Option<PathBuf>,
pub title: Option<String>,
}
impl Document {
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
path: None,
title: None,
}
}
pub fn from_file(path: PathBuf, source: impl Into<String>) -> Self {
let source = source.into();
let title = Ast::parse(&source).title();
Self {
source,
path: Some(path),
title,
}
}
pub fn len(&self) -> usize {
self.source.len()
}
pub fn is_empty(&self) -> bool {
self.source.is_empty()
}
pub fn ast(&self) -> Ast {
Ast::parse(&self.source)
}
pub fn title_from_ast(&self) -> Option<String> {
self.ast().title()
}
pub fn to_cbor(&self) -> Result<Vec<u8>, ScrybeError> {
let mut buf = Vec::new();
ciborium::into_writer(self, &mut buf).map_err(|e| ScrybeError::Cbor(e.to_string()))?;
Ok(buf)
}
pub fn from_cbor(bytes: &[u8]) -> Result<Self, ScrybeError> {
ciborium::from_reader(bytes).map_err(|e| ScrybeError::Cbor(e.to_string()))
}
}
impl ContentAddressable for Document {
fn content_id(&self) -> ContentId {
ContentId::of(self.source.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_document_content_id_stable() {
let doc = Document::new("# Hello\n\nWorld.");
let id1 = doc.content_id();
let id2 = doc.content_id();
assert_eq!(id1, id2);
}
#[test]
fn test_document_is_empty() {
assert!(Document::new("").is_empty());
assert!(!Document::new("x").is_empty());
}
#[test]
fn test_cbor_roundtrip_basic() {
let doc = Document::new("# Hello\n\nWorld.");
let bytes = doc.to_cbor().expect("encode");
let doc2 = Document::from_cbor(&bytes).expect("decode");
assert_eq!(doc.source, doc2.source);
assert_eq!(doc.title, doc2.title);
assert_eq!(doc.path, doc2.path);
}
#[test]
fn test_cbor_roundtrip_with_path() {
let doc = Document::from_file(PathBuf::from("/tmp/test.md"), "# Test\n\nContent.");
let bytes = doc.to_cbor().expect("encode");
let doc2 = Document::from_cbor(&bytes).expect("decode");
assert_eq!(doc.source, doc2.source);
assert_eq!(doc.path, doc2.path);
}
#[test]
fn test_cbor_roundtrip_empty() {
let doc = Document::new("");
let bytes = doc.to_cbor().expect("encode");
let doc2 = Document::from_cbor(&bytes).expect("decode");
assert!(doc2.is_empty());
}
#[test]
fn test_cbor_invalid_bytes() {
let result = Document::from_cbor(b"\xff\xfe garbage");
assert!(result.is_err());
}
#[test]
fn test_ast_returns_parsed_ast() {
let doc = Document::new("# Title\n\nParagraph.\n");
let ast = doc.ast();
assert!(!ast.nodes.is_empty());
}
#[test]
fn test_title_from_ast_h1() {
let doc = Document::new("# My Document\n\nSome text.\n");
assert_eq!(doc.title_from_ast(), Some("My Document".to_string()));
}
#[test]
fn test_title_from_ast_none_when_no_h1() {
let doc = Document::new("## Just a subheading\n");
assert_eq!(doc.title_from_ast(), None);
}
#[test]
fn test_from_file_populates_title() {
let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "# Auto Title\n\nBody text.\n");
assert_eq!(doc.title, Some("Auto Title".to_string()));
}
#[test]
fn test_from_file_no_h1_title_is_none() {
let doc = Document::from_file(PathBuf::from("/tmp/doc.md"), "No heading here.\n");
assert_eq!(doc.title, None);
}
}