Files
brittle/brittle-core/src/lib.rs

1028 lines
34 KiB
Rust

//! `brittle-core` — the library crate for the Brittle literature management system.
//!
//! All operations go through the [`Brittle`] struct. The API is transport-agnostic:
//! every return type is plain data that any layer (UI, REST, CLI) can use directly.
pub mod bibtex;
pub mod error;
pub mod model;
pub mod store;
pub use brittle_model::ReferenceSummary;
pub use error::{BibtexError, BrittleError, StoreError, ValidationError};
pub use model::{
Annotation, AnnotationId, AnnotationSet, AnnotationType, Color, EntryType, Library, LibraryId,
PdfAttachment, Person, Point, Quad, Rect, Reference, ReferenceId, Snapshot, TextMarkupType,
};
pub use store::{FsStore, MemoryStore, Store};
use crate::bibtex::export_references;
use crate::error::EntityType;
use chrono::Utc;
use std::path::{Path, PathBuf};
/// The main entry point for Brittle.
///
/// Generic over [`Store`] to allow testing with [`MemoryStore`] and production
/// use with [`FsStore`] without duplicating business logic.
pub struct Brittle<S: Store> {
store: S,
}
// ---- Constructors ----
impl Brittle<FsStore> {
/// Create a new Brittle repository at the given path.
pub fn create(path: &Path) -> Result<Self, BrittleError> {
let store = FsStore::create(path)?;
Ok(Self { store })
}
/// Open an existing Brittle repository.
pub fn open(path: &Path) -> Result<Self, BrittleError> {
let store = FsStore::open(path)?;
Ok(Self { store })
}
/// Returns the root path of the open repository.
pub fn repository_root(&self) -> &Path {
self.store.root()
}
/// Return the absolute filesystem path to the PDF attached to a reference.
///
/// Returns [`ValidationError::NoPdfAttached`] if the reference has no PDF.
pub fn get_pdf_path(&self, ref_id: ReferenceId) -> Result<PathBuf, BrittleError> {
let reference = self.store.load_reference(ref_id)?;
reference
.pdf
.map(|att| self.store.root().join(&att.stored_path))
.ok_or_else(|| {
ValidationError::NoPdfAttached {
reference_id: ref_id.to_string(),
}
.into()
})
}
/// Attach a PDF to a reference.
///
/// Copies the file from `source_path` into the repository's `pdfs/` directory,
/// computes its SHA-256 content hash, and updates the reference record.
pub fn attach_pdf(
&mut self,
id: ReferenceId,
source_path: &Path,
) -> Result<PdfAttachment, BrittleError> {
use sha2::{Digest, Sha256};
if !source_path.exists() {
return Err(ValidationError::PdfNotFound {
path: source_path.to_owned(),
}
.into());
}
let mut reference = self.store.load_reference(id)?;
let dest_name = format!("{id}.pdf");
let dest_path = self.store.pdf_dir().join(&dest_name);
let stored_path = Path::new("pdfs").join(&dest_name);
let bytes = std::fs::read(source_path).map_err(StoreError::Io)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = format!("{:x}", hasher.finalize());
std::fs::write(&dest_path, &bytes).map_err(StoreError::Io)?;
let attachment = PdfAttachment {
stored_path,
content_hash: hash,
};
reference.pdf = Some(attachment.clone());
reference.modified_at = Utc::now();
self.store.save_reference(&reference)?;
Ok(attachment)
}
}
impl<S: Store> Brittle<S> {
/// Construct with an arbitrary store. Primarily for testing with [`MemoryStore`].
pub fn with_store(store: S) -> Self {
Self { store }
}
// ---- Search ----
/// Search all references. Returns summaries whose cite key, title, year, or
/// author family name contains `query` (case-insensitive). If `query` is
/// empty, returns all references.
pub fn search_references(&self, query: &str) -> Result<Vec<ReferenceSummary>, BrittleError> {
if query.is_empty() {
return self.list_references();
}
let q = query.to_lowercase();
let ids = self.store.list_reference_ids()?;
let mut results = Vec::new();
for id in ids {
let r = self.store.load_reference(id)?;
if reference_matches(&r, &q) {
results.push(ReferenceSummary::from(&r));
}
}
Ok(results)
}
/// Search references within a specific library.
pub fn search_library_references(
&self,
library_id: LibraryId,
query: &str,
) -> Result<Vec<ReferenceSummary>, BrittleError> {
if query.is_empty() {
return self.list_library_references(library_id);
}
let q = query.to_lowercase();
let library = self.store.load_library(library_id)?;
let mut results = Vec::new();
for ref_id in &library.members {
let r = self.store.load_reference(*ref_id)?;
if reference_matches(&r, &q) {
results.push(ReferenceSummary::from(&r));
}
}
Ok(results)
}
// ---- Reference CRUD ----
/// Create a new reference with the given cite key and entry type.
///
/// Returns an error if the cite key is empty or already in use.
pub fn create_reference(
&mut self,
cite_key: impl Into<String>,
entry_type: EntryType,
) -> Result<Reference, BrittleError> {
let cite_key = cite_key.into();
if cite_key.is_empty() {
return Err(ValidationError::EmptyCiteKey.into());
}
// Check for duplicate cite keys.
for id in self.store.list_reference_ids()? {
let existing = self.store.load_reference(id)?;
if existing.cite_key == cite_key {
return Err(ValidationError::DuplicateCiteKey { cite_key }.into());
}
}
let reference = Reference::new(cite_key, entry_type);
self.store.save_reference(&reference)?;
Ok(reference)
}
/// Get a reference by ID.
pub fn get_reference(&self, id: ReferenceId) -> Result<Reference, BrittleError> {
Ok(self.store.load_reference(id)?)
}
/// Replace the mutable fields of a reference. `id` and `created_at` are preserved.
/// `modified_at` is updated automatically.
pub fn update_reference(
&mut self,
mut reference: Reference,
) -> Result<Reference, BrittleError> {
// Ensure the reference exists.
let existing = self.store.load_reference(reference.id)?;
reference.created_at = existing.created_at;
reference.modified_at = Utc::now();
// Check that the (possibly changed) cite key doesn't conflict.
if reference.cite_key.is_empty() {
return Err(ValidationError::EmptyCiteKey.into());
}
for id in self.store.list_reference_ids()? {
if id == reference.id {
continue;
}
let other = self.store.load_reference(id)?;
if other.cite_key == reference.cite_key {
return Err(ValidationError::DuplicateCiteKey {
cite_key: reference.cite_key,
}
.into());
}
}
self.store.save_reference(&reference)?;
Ok(reference)
}
/// Delete a reference and cascade-remove all associated data:
/// library memberships and annotations.
pub fn delete_reference(&mut self, id: ReferenceId) -> Result<(), BrittleError> {
// Ensure it exists first.
self.store.load_reference(id)?;
// Remove from all libraries.
for lib_id in self.store.list_library_ids()? {
let mut lib = self.store.load_library(lib_id)?;
if lib.members.remove(&id) {
lib.modified_at = Utc::now();
self.store.save_library(&lib)?;
}
}
// Delete annotations.
self.store.delete_annotations(id)?;
// Delete the reference itself.
self.store.delete_reference(id)?;
Ok(())
}
/// List all references as lightweight summaries.
pub fn list_references(&self) -> Result<Vec<ReferenceSummary>, BrittleError> {
let ids = self.store.list_reference_ids()?;
let mut summaries = Vec::with_capacity(ids.len());
for id in ids {
let r = self.store.load_reference(id)?;
summaries.push(ReferenceSummary::from(&r));
}
Ok(summaries)
}
/// Set a single field on a reference. Use `"title"`, `"year"`, `"journal"`, etc.
pub fn set_field(
&mut self,
id: ReferenceId,
field: &str,
value: impl Into<String>,
) -> Result<(), BrittleError> {
let mut reference = self.store.load_reference(id)?;
reference.fields.insert(field.to_owned(), value.into());
reference.modified_at = Utc::now();
self.store.save_reference(&reference)?;
Ok(())
}
/// Remove a field from a reference.
pub fn remove_field(&mut self, id: ReferenceId, field: &str) -> Result<(), BrittleError> {
let mut reference = self.store.load_reference(id)?;
reference.fields.remove(field);
reference.modified_at = Utc::now();
self.store.save_reference(&reference)?;
Ok(())
}
// ---- Library CRUD ----
/// Create a new library.
pub fn create_library(
&mut self,
name: impl Into<String>,
parent_id: Option<LibraryId>,
) -> Result<Library, BrittleError> {
let name = name.into();
if name.is_empty() {
return Err(ValidationError::EmptyLibraryName.into());
}
// Verify parent exists if provided.
if let Some(pid) = parent_id {
self.store.load_library(pid)?;
}
let library = Library::new(name, parent_id);
self.store.save_library(&library)?;
Ok(library)
}
/// Get a library by ID.
pub fn get_library(&self, id: LibraryId) -> Result<Library, BrittleError> {
Ok(self.store.load_library(id)?)
}
/// Rename a library.
pub fn rename_library(
&mut self,
id: LibraryId,
new_name: impl Into<String>,
) -> Result<Library, BrittleError> {
let new_name = new_name.into();
if new_name.is_empty() {
return Err(ValidationError::EmptyLibraryName.into());
}
let mut library = self.store.load_library(id)?;
library.name = new_name;
library.modified_at = Utc::now();
self.store.save_library(&library)?;
Ok(library)
}
/// Move a library to a new parent. Validates that no cycle is created.
pub fn move_library(
&mut self,
id: LibraryId,
new_parent: Option<LibraryId>,
) -> Result<Library, BrittleError> {
// Verify new parent exists if provided.
if let Some(pid) = new_parent {
self.store.load_library(pid)?;
}
// Cycle detection: walk from new_parent up to root.
// If we encounter `id` in the ancestry chain, moving would create a cycle.
if let Some(pid) = new_parent {
let mut current = pid;
loop {
if current == id {
return Err(ValidationError::LibraryCycle {
library_id: id.to_string(),
parent_id: pid.to_string(),
}
.into());
}
let lib = self.store.load_library(current).map_err(|_| {
// Broken ancestor chain — treat as no cycle.
BrittleError::Validation(ValidationError::LibraryCycle {
library_id: id.to_string(),
parent_id: pid.to_string(),
})
});
match lib {
Ok(l) => match l.parent_id {
Some(next) => current = next,
None => break,
},
Err(_) => break,
}
}
}
let mut library = self.store.load_library(id)?;
library.parent_id = new_parent;
library.modified_at = Utc::now();
self.store.save_library(&library)?;
Ok(library)
}
/// Delete a library. Fails if it has children. References are disassociated, not deleted.
pub fn delete_library(&mut self, id: LibraryId) -> Result<(), BrittleError> {
// Ensure it exists.
self.store.load_library(id)?;
// Reject if any library has this one as its parent.
for lib_id in self.store.list_library_ids()? {
if lib_id == id {
continue;
}
let child = self.store.load_library(lib_id)?;
if child.parent_id == Some(id) {
return Err(ValidationError::LibraryHasChildren { id: id.to_string() }.into());
}
}
self.store.delete_library(id)?;
Ok(())
}
/// List all root libraries (those with no parent).
pub fn list_root_libraries(&self) -> Result<Vec<Library>, BrittleError> {
self.list_libraries_where(|lib| lib.parent_id.is_none())
}
/// List direct children of a library.
pub fn list_child_libraries(&self, parent_id: LibraryId) -> Result<Vec<Library>, BrittleError> {
// Ensure parent exists.
self.store.load_library(parent_id)?;
self.list_libraries_where(|lib| lib.parent_id == Some(parent_id))
}
fn list_libraries_where(
&self,
predicate: impl Fn(&Library) -> bool,
) -> Result<Vec<Library>, BrittleError> {
let ids = self.store.list_library_ids()?;
let mut libs = Vec::new();
for id in ids {
let lib = self.store.load_library(id)?;
if predicate(&lib) {
libs.push(lib);
}
}
Ok(libs)
}
// ---- Library Membership ----
/// Add a reference to a library.
pub fn add_to_library(
&mut self,
library_id: LibraryId,
reference_id: ReferenceId,
) -> Result<(), BrittleError> {
// Verify both exist.
self.store.load_reference(reference_id)?;
let mut library = self.store.load_library(library_id)?;
library.members.insert(reference_id);
library.modified_at = Utc::now();
self.store.save_library(&library)?;
Ok(())
}
/// Remove a reference from a library.
pub fn remove_from_library(
&mut self,
library_id: LibraryId,
reference_id: ReferenceId,
) -> Result<(), BrittleError> {
let mut library = self.store.load_library(library_id)?;
library.members.remove(&reference_id);
library.modified_at = Utc::now();
self.store.save_library(&library)?;
Ok(())
}
/// List all references directly in a library as lightweight summaries.
pub fn list_library_references(
&self,
library_id: LibraryId,
) -> Result<Vec<ReferenceSummary>, BrittleError> {
let library = self.store.load_library(library_id)?;
let mut summaries = Vec::new();
for ref_id in &library.members {
let r = self.store.load_reference(*ref_id)?;
summaries.push(ReferenceSummary::from(&r));
}
Ok(summaries)
}
/// List all references in a library and all its descendant libraries.
///
/// De-duplicates references that appear in multiple sub-libraries.
pub fn list_library_references_recursive(
&self,
library_id: LibraryId,
) -> Result<Vec<ReferenceSummary>, BrittleError> {
use std::collections::BTreeSet;
let mut ref_ids: BTreeSet<ReferenceId> = BTreeSet::new();
let mut queue = vec![library_id];
while let Some(id) = queue.pop() {
let lib = self.store.load_library(id)?;
ref_ids.extend(lib.members.iter().copied());
let children = self.list_child_libraries(id)?;
queue.extend(children.into_iter().map(|c| c.id));
}
let mut summaries = Vec::new();
for ref_id in ref_ids {
summaries.push(ReferenceSummary::from(&self.store.load_reference(ref_id)?));
}
Ok(summaries)
}
/// List all libraries that contain a given reference.
pub fn list_reference_libraries(
&self,
reference_id: ReferenceId,
) -> Result<Vec<Library>, BrittleError> {
// Ensure the reference exists.
self.store.load_reference(reference_id)?;
self.list_libraries_where(|lib| lib.members.contains(&reference_id))
}
// ---- Library ancestry & cascade delete ----
/// Return the ancestor chain of a library, ordered from root down to its
/// direct parent.
///
/// Returns an empty `Vec` for root libraries (those with no parent).
///
/// Example: given `Root > Science > Physics`, calling this on `Physics`
/// returns `[Root, Science]`.
pub fn get_library_ancestors(&self, id: LibraryId) -> Result<Vec<Library>, BrittleError> {
let mut lib = self.store.load_library(id)?;
let mut ancestors = Vec::new();
while let Some(parent_id) = lib.parent_id {
let parent = self.store.load_library(parent_id)?;
ancestors.push(parent.clone());
lib = parent;
}
ancestors.reverse();
Ok(ancestors)
}
/// Delete a library and **all its descendants**, disassociating member
/// references (references are not deleted, only removed from the deleted
/// libraries).
///
/// Use after confirming with the user. For the safe, rejection-on-children
/// variant see [`delete_library`].
pub fn force_delete_library(&mut self, id: LibraryId) -> Result<(), BrittleError> {
// Ensure the target exists before doing anything.
self.store.load_library(id)?;
// Collect the target and all its descendants via BFS.
let mut to_delete = vec![id];
let mut i = 0;
while i < to_delete.len() {
let current = to_delete[i];
for lib_id in self.store.list_library_ids()? {
if to_delete.contains(&lib_id) {
continue;
}
let lib = self.store.load_library(lib_id)?;
if lib.parent_id == Some(current) {
to_delete.push(lib_id);
}
}
i += 1;
}
// Library records hold membership; deleting the record removes
// memberships for free. References themselves are unaffected.
for lib_id in to_delete {
self.store.delete_library(lib_id)?;
}
Ok(())
}
// ---- BibTeX Export ----
/// Export a set of references as a BibTeX string.
/// Returns an error if any referenced ID is not found.
/// References with missing required fields are skipped; their errors are collected.
pub fn export_bibtex(
&self,
reference_ids: &[ReferenceId],
) -> Result<(String, Vec<BibtexError>), BrittleError> {
let mut refs = Vec::with_capacity(reference_ids.len());
for &id in reference_ids {
refs.push(self.store.load_reference(id)?);
}
Ok(export_references(&refs))
}
/// Export all references in a library as a BibTeX string.
pub fn export_library_bibtex(
&self,
library_id: LibraryId,
) -> Result<(String, Vec<BibtexError>), BrittleError> {
let library = self.store.load_library(library_id)?;
let ids: Vec<_> = library.members.iter().copied().collect();
self.export_bibtex(&ids)
}
// ---- Annotations ----
/// Add an annotation to a reference's PDF.
pub fn create_annotation(
&mut self,
reference_id: ReferenceId,
page: u32,
annotation_type: AnnotationType,
content: Option<String>,
) -> Result<Annotation, BrittleError> {
// Ensure the reference exists.
self.store.load_reference(reference_id)?;
let mut set = self.store.load_annotations(reference_id)?;
let mut ann = Annotation::new(reference_id, page, annotation_type);
ann.content = content;
set.annotations.push(ann.clone());
self.store.save_annotations(&set)?;
Ok(ann)
}
/// Get all annotations for a reference.
pub fn get_annotations(
&self,
reference_id: ReferenceId,
) -> Result<Vec<Annotation>, BrittleError> {
Ok(self.store.load_annotations(reference_id)?.annotations)
}
/// Update an annotation. Matches by `annotation.id`; fails if not found.
pub fn update_annotation(
&mut self,
mut annotation: Annotation,
) -> Result<Annotation, BrittleError> {
let ref_id = annotation.reference_id;
let mut set = self.store.load_annotations(ref_id)?;
let slot = set
.annotations
.iter_mut()
.find(|a| a.id == annotation.id)
.ok_or_else(|| {
BrittleError::Store(StoreError::NotFound {
entity_type: EntityType::Annotation,
id: annotation.id.to_string(),
})
})?;
annotation.created_at = slot.created_at;
annotation.modified_at = Utc::now();
*slot = annotation.clone();
self.store.save_annotations(&set)?;
Ok(annotation)
}
/// Delete an annotation by ID.
pub fn delete_annotation(
&mut self,
reference_id: ReferenceId,
annotation_id: AnnotationId,
) -> Result<(), BrittleError> {
let mut set = self.store.load_annotations(reference_id)?;
let before = set.annotations.len();
set.annotations.retain(|a| a.id != annotation_id);
if set.annotations.len() == before {
return Err(StoreError::NotFound {
entity_type: EntityType::Annotation,
id: annotation_id.to_string(),
}
.into());
}
self.store.save_annotations(&set)?;
Ok(())
}
// ---- Snapshots ----
/// Create a named snapshot of the current state.
pub fn create_snapshot(&mut self, message: &str) -> Result<Snapshot, BrittleError> {
Ok(self.store.create_snapshot(message)?)
}
/// List all snapshots in reverse chronological order (most recent first).
pub fn list_snapshots(&self) -> Result<Vec<Snapshot>, BrittleError> {
Ok(self.store.list_snapshots()?)
}
/// Restore to a previous snapshot.
///
/// Returns an error if there are uncommitted changes. Use [`discard_changes`] first.
pub fn restore_snapshot(&mut self, snapshot_id: &str) -> Result<(), BrittleError> {
if self.store.has_uncommitted_changes()? {
return Err(ValidationError::UncommittedChanges.into());
}
Ok(self.store.restore_snapshot(snapshot_id)?)
}
/// Returns `true` if there are unsaved changes not yet captured in a snapshot.
pub fn has_uncommitted_changes(&self) -> Result<bool, BrittleError> {
Ok(self.store.has_uncommitted_changes()?)
}
/// Discard all uncommitted changes, reverting to the state of the last snapshot.
pub fn discard_changes(&mut self) -> Result<(), BrittleError> {
let snapshots = self.store.list_snapshots()?;
if let Some(latest) = snapshots.into_iter().next() {
self.store.restore_snapshot(&latest.id)?;
}
Ok(())
}
}
/// Case-insensitive substring match across key reference fields.
fn reference_matches(r: &Reference, query: &str) -> bool {
r.cite_key.to_lowercase().contains(query)
|| r.title().is_some_and(|t| t.to_lowercase().contains(query))
|| r.year().is_some_and(|y| y.to_lowercase().contains(query))
|| r.authors
.iter()
.any(|a| a.family.to_lowercase().contains(query))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AnnotationType, Color, TextMarkupType};
fn make_brittle() -> Brittle<MemoryStore> {
Brittle::with_store(MemoryStore::new())
}
// ---- Reference tests ----
#[test]
fn create_and_get_reference() {
let mut b = make_brittle();
let r = b.create_reference("doe2024", EntryType::Article).unwrap();
let fetched = b.get_reference(r.id).unwrap();
assert_eq!(fetched.cite_key, "doe2024");
}
#[test]
fn empty_cite_key_rejected() {
let mut b = make_brittle();
assert!(matches!(
b.create_reference("", EntryType::Misc).unwrap_err(),
BrittleError::Validation(ValidationError::EmptyCiteKey)
));
}
#[test]
fn duplicate_cite_key_rejected() {
let mut b = make_brittle();
b.create_reference("same", EntryType::Article).unwrap();
assert!(matches!(
b.create_reference("same", EntryType::Book).unwrap_err(),
BrittleError::Validation(ValidationError::DuplicateCiteKey { .. })
));
}
#[test]
fn delete_reference_cascades_to_libraries_and_annotations() {
let mut b = make_brittle();
let r = b.create_reference("cascade2024", EntryType::Misc).unwrap();
let lib = b.create_library("Test Lib", None).unwrap();
b.add_to_library(lib.id, r.id).unwrap();
// Create annotation.
b.create_annotation(
r.id,
0,
AnnotationType::Note {
position: Point { x: 0.0, y: 0.0 },
},
None,
)
.unwrap();
b.delete_reference(r.id).unwrap();
// Reference gone.
assert!(b.get_reference(r.id).is_err());
// Library no longer contains the reference.
let lib2 = b.get_library(lib.id).unwrap();
assert!(!lib2.members.contains(&r.id));
// Annotations gone.
let anns = b.get_annotations(r.id).unwrap();
assert!(anns.is_empty());
}
#[test]
fn set_and_remove_field() {
let mut b = make_brittle();
let r = b
.create_reference("fields2024", EntryType::Article)
.unwrap();
b.set_field(r.id, "title", "Great Paper").unwrap();
let r2 = b.get_reference(r.id).unwrap();
assert_eq!(r2.fields.get("title").unwrap(), "Great Paper");
b.remove_field(r.id, "title").unwrap();
let r3 = b.get_reference(r.id).unwrap();
assert!(!r3.fields.contains_key("title"));
}
// ---- Library tests ----
#[test]
fn create_and_list_root_libraries() {
let mut b = make_brittle();
b.create_library("Science", None).unwrap();
b.create_library("Arts", None).unwrap();
let roots = b.list_root_libraries().unwrap();
assert_eq!(roots.len(), 2);
}
#[test]
fn empty_library_name_rejected() {
let mut b = make_brittle();
assert!(matches!(
b.create_library("", None).unwrap_err(),
BrittleError::Validation(ValidationError::EmptyLibraryName)
));
}
#[test]
fn nested_libraries_and_children() {
let mut b = make_brittle();
let parent = b.create_library("Science", None).unwrap();
b.create_library("Physics", Some(parent.id)).unwrap();
b.create_library("Biology", Some(parent.id)).unwrap();
let children = b.list_child_libraries(parent.id).unwrap();
assert_eq!(children.len(), 2);
}
#[test]
fn delete_library_with_children_fails() {
let mut b = make_brittle();
let parent = b.create_library("Parent", None).unwrap();
b.create_library("Child", Some(parent.id)).unwrap();
assert!(matches!(
b.delete_library(parent.id).unwrap_err(),
BrittleError::Validation(ValidationError::LibraryHasChildren { .. })
));
}
#[test]
fn move_library_cycle_detection() {
let mut b = make_brittle();
let a = b.create_library("A", None).unwrap();
let b_lib = b.create_library("B", Some(a.id)).unwrap();
// Moving A under B would create A -> B -> A cycle.
assert!(matches!(
b.move_library(a.id, Some(b_lib.id)).unwrap_err(),
BrittleError::Validation(ValidationError::LibraryCycle { .. })
));
}
// ---- Membership tests ----
#[test]
fn multi_membership() {
let mut b = make_brittle();
let r = b.create_reference("multi2024", EntryType::Article).unwrap();
let lib1 = b.create_library("Lib 1", None).unwrap();
let lib2 = b.create_library("Lib 2", None).unwrap();
b.add_to_library(lib1.id, r.id).unwrap();
b.add_to_library(lib2.id, r.id).unwrap();
let containing = b.list_reference_libraries(r.id).unwrap();
let ids: Vec<_> = containing.iter().map(|l| l.id).collect();
assert!(ids.contains(&lib1.id));
assert!(ids.contains(&lib2.id));
}
#[test]
fn list_library_references() {
let mut b = make_brittle();
let r1 = b.create_reference("r1", EntryType::Misc).unwrap();
let r2 = b.create_reference("r2", EntryType::Misc).unwrap();
let lib = b.create_library("All", None).unwrap();
b.add_to_library(lib.id, r1.id).unwrap();
b.add_to_library(lib.id, r2.id).unwrap();
let summaries = b.list_library_references(lib.id).unwrap();
assert_eq!(summaries.len(), 2);
}
// ---- BibTeX export tests ----
#[test]
fn export_bibtex_valid_reference() {
let mut b = make_brittle();
let mut r = b
.create_reference("turing1950", EntryType::Article)
.unwrap();
r.authors.push(Person::new("Turing"));
r.fields
.insert("title".into(), "Computing Machinery".into());
r.fields.insert("journal".into(), "Mind".into());
r.fields.insert("year".into(), "1950".into());
b.update_reference(r.clone()).unwrap();
let (bibtex, errors) = b.export_bibtex(&[r.id]).unwrap();
assert!(errors.is_empty());
assert!(bibtex.contains("@article{turing1950,"));
}
// ---- Annotation tests ----
#[test]
fn create_update_delete_annotation() {
let mut b = make_brittle();
let r = b.create_reference("ann2024", EntryType::Misc).unwrap();
let ann = b
.create_annotation(
r.id,
0,
AnnotationType::TextMarkup {
markup_type: TextMarkupType::Highlight,
quads: vec![],
color: Color::YELLOW,
selected_text: Some("key text".into()),
},
Some("Important!".into()),
)
.unwrap();
let anns = b.get_annotations(r.id).unwrap();
assert_eq!(anns.len(), 1);
assert_eq!(anns[0].content.as_deref(), Some("Important!"));
// Update content.
let mut updated = ann.clone();
updated.content = Some("Very important!".into());
b.update_annotation(updated).unwrap();
let anns2 = b.get_annotations(r.id).unwrap();
assert_eq!(anns2[0].content.as_deref(), Some("Very important!"));
// Delete.
b.delete_annotation(r.id, ann.id).unwrap();
assert!(b.get_annotations(r.id).unwrap().is_empty());
}
// ---- Library ancestors tests ----
#[test]
fn ancestors_of_root_library_is_empty() {
let mut b = make_brittle();
let root = b.create_library("Root", None).unwrap();
assert!(b.get_library_ancestors(root.id).unwrap().is_empty());
}
#[test]
fn ancestors_of_child_returns_parent() {
let mut b = make_brittle();
let root = b.create_library("Root", None).unwrap();
let child = b.create_library("Child", Some(root.id)).unwrap();
let ancestors = b.get_library_ancestors(child.id).unwrap();
assert_eq!(ancestors.len(), 1);
assert_eq!(ancestors[0].id, root.id);
}
#[test]
fn ancestors_are_ordered_root_first() {
let mut b = make_brittle();
let root = b.create_library("Root", None).unwrap();
let mid = b.create_library("Mid", Some(root.id)).unwrap();
let leaf = b.create_library("Leaf", Some(mid.id)).unwrap();
let ancestors = b.get_library_ancestors(leaf.id).unwrap();
assert_eq!(ancestors.len(), 2);
assert_eq!(ancestors[0].id, root.id);
assert_eq!(ancestors[1].id, mid.id);
}
#[test]
fn ancestors_of_nonexistent_library_returns_error() {
let b = make_brittle();
let fake_id = LibraryId::new();
assert!(b.get_library_ancestors(fake_id).is_err());
}
// ---- Force-delete library tests ----
#[test]
fn force_delete_removes_library_with_children() {
let mut b = make_brittle();
let parent = b.create_library("Parent", None).unwrap();
let child = b.create_library("Child", Some(parent.id)).unwrap();
b.force_delete_library(parent.id).unwrap();
assert!(b.get_library(parent.id).is_err());
assert!(b.get_library(child.id).is_err());
}
#[test]
fn force_delete_removes_entire_subtree() {
let mut b = make_brittle();
let root = b.create_library("Root", None).unwrap();
let a = b.create_library("A", Some(root.id)).unwrap();
let b_lib = b.create_library("B", Some(root.id)).unwrap();
let a1 = b.create_library("A1", Some(a.id)).unwrap();
b.force_delete_library(root.id).unwrap();
for id in [root.id, a.id, b_lib.id, a1.id] {
assert!(b.get_library(id).is_err());
}
}
#[test]
fn force_delete_leaves_references_intact() {
let mut b = make_brittle();
let lib = b.create_library("Lib", None).unwrap();
let r = b.create_reference("keep2024", EntryType::Misc).unwrap();
b.add_to_library(lib.id, r.id).unwrap();
b.force_delete_library(lib.id).unwrap();
// Library is gone; reference survives.
assert!(b.get_library(lib.id).is_err());
assert!(b.get_reference(r.id).is_ok());
}
#[test]
fn force_delete_nonexistent_library_returns_error() {
let mut b = make_brittle();
let fake_id = LibraryId::new();
assert!(b.force_delete_library(fake_id).is_err());
}
// ---- Snapshot tests ----
#[test]
fn snapshot_workflow() {
let mut b = make_brittle();
let r = b.create_reference("snap2024", EntryType::Misc).unwrap();
let snap = b.create_snapshot("baseline").unwrap();
b.delete_reference(r.id).unwrap();
assert!(b.get_reference(r.id).is_err());
// MemoryStore allows restore without uncommitted-changes check.
b.store.restore_snapshot(&snap.id).unwrap();
assert!(b.get_reference(r.id).is_ok());
}
}