//! `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 { store: S, } // ---- Constructors ---- impl Brittle { /// Create a new Brittle repository at the given path. pub fn create(path: &Path) -> Result { let store = FsStore::create(path)?; Ok(Self { store }) } /// Open an existing Brittle repository. pub fn open(path: &Path) -> Result { 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 { 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 { 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 Brittle { /// 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, 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, 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, entry_type: EntryType, ) -> Result { 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 { 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 { // 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, 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, ) -> 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, parent_id: Option, ) -> Result { 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 { Ok(self.store.load_library(id)?) } /// Rename a library. pub fn rename_library( &mut self, id: LibraryId, new_name: impl Into, ) -> Result { 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, ) -> Result { // 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, 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, 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, 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, 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, BrittleError> { use std::collections::BTreeSet; let mut ref_ids: BTreeSet = 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, 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, 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), 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), 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, ) -> Result { // 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, 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 { 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 { Ok(self.store.create_snapshot(message)?) } /// List all snapshots in reverse chronological order (most recent first). pub fn list_snapshots(&self) -> Result, 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 { 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 { 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()); } }