diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f2e6ea8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,84 +0,0 @@ -# Brittle — Literature Management System - -A desktop application for managing academic references, PDFs, and annotations. Written in Rust. - -## Project Vision - -Brittle is a personal literature management tool in the spirit of Zotero. Core capabilities: - -- Organize references in nested libraries (libraries contain sub-libraries) -- Display and annotate PDFs (create, edit, view annotations) -- Export references as BibTeX - -This is a hobby project built primarily through AI-assisted development. The human operator makes architectural decisions; the agent writes the code. - -## Architecture - -### Mandatory: Library-First Design - -The backend is a **Rust crate** (`brittle-core`) that exposes a public API. This is the primary interface. A frontend like [iced](https://github.com/iced-rs/iced) links directly against this crate. - -The API must be designed so that wrapping it in REST, gRPC, IPC, or any other transport layer requires **zero changes to `brittle-core`**. This means: - -- No UI concepts leak into the core (no widget types, no rendering logic, no event loops) -- Return types must be transport-agnostic: no framework-specific channels, callbacks, or handle types. Plain data that any layer can serialize or forward -- Errors are structured and meaningful, not stringly-typed -- The public API surface is the **single source of truth** for what Brittle can do - -If a frontend needs something that isn't in the core API, the correct response is to extend the core API — not to hack around it. - -### Dependency Philosophy - -Use external crates where they provide real value (PDF rendering, BibTeX parsing, SQLite bindings, etc.). Do not pull in a dependency for something the standard library handles or for trivial utility code. When choosing between crates, prefer those that are well-maintained, have minimal transitive dependencies, and are widely used in the Rust ecosystem. - -### Database Versioning - -The database must support some form of version control or change tracking. The specific mechanism (event sourcing, snapshot-based, migration-based, audit log, etc.) is an **open design question** to be resolved during planning before implementation begins. - -## Development Workflow - -Every non-trivial feature follows this process end-to-end. Do not skip steps. - -### 1. Requirements (what, not how) - -Start from user-facing behavior. What does this feature need to do? Define concrete acceptance criteria. Do not think about implementation yet. - -### 2. Architectural Fit - -Before designing anything, assess how this feature relates to what already exists: - -- **Fit**: Does it integrate cleanly with the current structure? -- **Risk**: If restructuring is needed, what existing functionality could break? -- **Benefit**: What do we gain from restructuring vs. working within the current design? -- **Verdict**: Restructure, adapt, or flag as tech debt? - -Write this assessment as part of the plan. Do not silently restructure. - -### 3. Design (top-down) - -Now decide *how*: types, traits, modules, public API surface. Work downward from the API the feature needs, not upward from utility code you think might be useful. - -### 4. Tests - -Write failing tests that encode the acceptance criteria from step 1. These tests define "done." - -### 5. Implementation - -Make the tests pass. Then refactor with confidence. - -Use **Plan mode** at the start of any multi-file task. - -## Agent Behavior - -Do not blindly implement what is asked. Consider whether the request is actually what the user needs, whether there's a better approach, and whether there are edge cases or implications the user may not have considered. - -If you see a better path, **argue for it**. Explain your reasoning clearly. But if the user disagrees after hearing your case, accept their decision and implement it well. - -## Code Style - -- Prioritize readability and testability over cleverness -- Use meaningful names; avoid abbreviations unless they're domain-standard (e.g., `bib`, `doi`, `pdf`) -- Keep functions focused — if a function needs a paragraph-long comment to explain, it should probably be split -- Use Rust idioms: `Result` for fallible operations, strong types over stringly-typed data, `enum` for state machines -- Run `cargo clippy` and `cargo fmt` before considering any task complete -- Document public API items with doc comments diff --git a/Cargo.lock b/Cargo.lock index 871d029..dfe7c41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,7 @@ dependencies = [ name = "brittle-core" version = "0.1.0" dependencies = [ + "brittle-model", "chrono", "git2", "serde", @@ -173,7 +174,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml 0.8.2", - "ureq", "uuid", ] @@ -181,6 +181,24 @@ dependencies = [ name = "brittle-keymap" version = "0.1.0" +[[package]] +name = "brittle-model" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "brittle-seed" +version = "0.1.0" +dependencies = [ + "brittle-core", + "ureq", +] + [[package]] name = "brotli" version = "8.0.2" diff --git a/Cargo.toml b/Cargo.toml index cd45bd8..bd3254e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["brittle-core", "brittle-keymap", "src-tauri"] +members = ["brittle-model", "brittle-core", "brittle-keymap", "src-tauri", "tools/brittle-seed"] resolver = "2" diff --git a/brittle-core/Cargo.toml b/brittle-core/Cargo.toml index c773355..ff2730f 100644 --- a/brittle-core/Cargo.toml +++ b/brittle-core/Cargo.toml @@ -4,19 +4,15 @@ version = "0.1.0" edition = "2024" [dependencies] +brittle-model = { path = "../brittle-model" } chrono = { version = "0.4", features = ["serde"] } git2 = { version = "0.20", features = ["vendored-libgit2"] } serde = { version = "1", features = ["derive"] } sha2 = "0.10" thiserror = "2" toml = "0.8" -ureq = "2" uuid = { version = "1", features = ["v7", "serde"] } -[[bin]] -name = "brittle-seed" -path = "src/bin/seed.rs" - [dev-dependencies] serde_json = "1" tempfile = "3" diff --git a/brittle-core/src/lib.rs b/brittle-core/src/lib.rs index 4ffd5cc..bb7b00a 100644 --- a/brittle-core/src/lib.rs +++ b/brittle-core/src/lib.rs @@ -8,6 +8,7 @@ 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, @@ -18,33 +19,8 @@ pub use store::{FsStore, MemoryStore, Store}; use crate::bibtex::export_references; use crate::error::EntityType; use chrono::Utc; -use serde::Serialize; use std::path::{Path, PathBuf}; -/// A lightweight summary of a reference, suitable for list views. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ReferenceSummary { - pub id: ReferenceId, - pub cite_key: String, - pub entry_type: EntryType, - pub title: Option, - pub authors: Vec, - pub year: Option, -} - -impl From<&Reference> for ReferenceSummary { - fn from(r: &Reference) -> Self { - Self { - id: r.id, - cite_key: r.cite_key.clone(), - entry_type: r.entry_type.clone(), - title: r.title().map(str::to_owned), - authors: r.authors.clone(), - year: r.year().map(str::to_owned), - } - } -} - /// The main entry point for Brittle. /// /// Generic over [`Store`] to allow testing with [`MemoryStore`] and production diff --git a/brittle-core/src/model/annotation.rs b/brittle-core/src/model/annotation.rs index ceea416..8dfc910 100644 --- a/brittle-core/src/model/annotation.rs +++ b/brittle-core/src/model/annotation.rs @@ -1,229 +1 @@ -use crate::model::ids::{AnnotationId, ReferenceId}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// A point in PDF coordinate space. -/// Origin is bottom-left; units are points (1/72 inch), matching ISO 32000. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Point { - pub x: f64, - pub y: f64, -} - -/// A rectangle in PDF coordinate space. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -/// A quadrilateral for text markup annotations (highlight, underline, etc.). -/// Four points define one region, typically one line of text. -/// Matches the PDF spec QuadPoints representation (4 vertices per quad). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Quad { - pub points: [Point; 4], -} - -/// RGBA color. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct Color { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl Color { - pub const YELLOW: Color = Color { - r: 255, - g: 255, - b: 0, - a: 128, - }; - pub const RED: Color = Color { - r: 255, - g: 0, - b: 0, - a: 128, - }; - pub const GREEN: Color = Color { - r: 0, - g: 255, - b: 0, - a: 128, - }; -} - -/// The four text markup annotation types defined in ISO 32000. -/// All share the same QuadPoints-based geometry. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TextMarkupType { - Highlight, - Underline, - Squiggly, - StrikeOut, -} - -/// The kind of annotation and its type-specific geometry/data. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum AnnotationType { - /// Text markup (highlight, underline, squiggly, strikeout). - /// Uses QuadPoints per PDF spec for precise multi-line region selection. - TextMarkup { - markup_type: TextMarkupType, - quads: Vec, - color: Color, - /// The selected text, stored for search and export without re-reading the PDF. - selected_text: Option, - }, - /// Sticky note (popup comment). - Note { position: Point }, - /// Inline text box. - FreeText { rect: Rect }, - /// Freehand ink drawing (e.g., circling a diagram). - Ink { - /// Multiple strokes, each a sequence of connected points. - paths: Vec>, - color: Color, - /// Stroke width in points. - width: f64, - }, - /// Area/image selection for extracting figures from PDFs. - Area { rect: Rect }, -} - -/// A single annotation on a PDF page. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Annotation { - pub id: AnnotationId, - pub reference_id: ReferenceId, - /// 0-indexed physical page number. - pub page: u32, - /// Display page label (e.g., "iv", "23") — may differ from the physical page index. - pub page_label: Option, - pub annotation_type: AnnotationType, - /// Free-form text: note body, comment on a highlight, etc. - pub content: Option, - pub created_at: DateTime, - pub modified_at: DateTime, -} - -impl Annotation { - pub fn new(reference_id: ReferenceId, page: u32, annotation_type: AnnotationType) -> Self { - let now = Utc::now(); - Self { - id: AnnotationId::new(), - reference_id, - page, - page_label: None, - annotation_type, - content: None, - created_at: now, - modified_at: now, - } - } -} - -/// All annotations for a single reference, stored as one file. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AnnotationSet { - pub reference_id: ReferenceId, - pub annotations: Vec, -} - -impl AnnotationSet { - pub fn new(reference_id: ReferenceId) -> Self { - Self { - reference_id, - annotations: Vec::new(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_highlight() -> AnnotationType { - AnnotationType::TextMarkup { - markup_type: TextMarkupType::Highlight, - quads: vec![Quad { - points: [ - Point { x: 10.0, y: 20.0 }, - Point { x: 100.0, y: 20.0 }, - Point { x: 10.0, y: 30.0 }, - Point { x: 100.0, y: 30.0 }, - ], - }], - color: Color::YELLOW, - selected_text: Some("important text".into()), - } - } - - #[test] - fn annotation_serde_round_trip_highlight() { - let ref_id = ReferenceId::new(); - let set = AnnotationSet { - reference_id: ref_id, - annotations: vec![Annotation::new(ref_id, 3, make_highlight())], - }; - - let toml_str = toml::to_string(&set).expect("serialize"); - let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize"); - - assert_eq!(set.reference_id, set2.reference_id); - assert_eq!(set.annotations.len(), set2.annotations.len()); - assert_eq!(set.annotations[0].page, set2.annotations[0].page); - } - - #[test] - fn annotation_serde_round_trip_ink() { - let ref_id = ReferenceId::new(); - let ink = AnnotationType::Ink { - paths: vec![vec![Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 10.0 }]], - color: Color::RED, - width: 2.0, - }; - let set = AnnotationSet { - reference_id: ref_id, - annotations: vec![Annotation::new(ref_id, 0, ink)], - }; - - let toml_str = toml::to_string(&set).expect("serialize"); - let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize"); - assert_eq!(set, set2); - } - - #[test] - fn all_markup_types_serialize() { - let ref_id = ReferenceId::new(); - for markup_type in [ - TextMarkupType::Highlight, - TextMarkupType::Underline, - TextMarkupType::Squiggly, - TextMarkupType::StrikeOut, - ] { - let ann = Annotation::new( - ref_id, - 0, - AnnotationType::TextMarkup { - markup_type, - quads: vec![], - color: Color::GREEN, - selected_text: None, - }, - ); - let set = AnnotationSet { - reference_id: ref_id, - annotations: vec![ann], - }; - let toml_str = toml::to_string(&set).expect("serialize"); - let _: AnnotationSet = toml::from_str(&toml_str).expect("deserialize"); - } - } -} +pub use brittle_model::annotation::*; diff --git a/brittle-core/src/model/ids.rs b/brittle-core/src/model/ids.rs index 6e052fb..d7610c9 100644 --- a/brittle-core/src/model/ids.rs +++ b/brittle-core/src/model/ids.rs @@ -1,67 +1 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; -use uuid::Uuid; - -macro_rules! define_id { - ($name:ident) => { - #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, - )] - pub struct $name(pub Uuid); - - impl $name { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } - } - - impl Default for $name { - fn default() -> Self { - Self::new() - } - } - - impl From for $name { - fn from(uuid: Uuid) -> Self { - Self(uuid) - } - } - - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } - } - }; -} - -define_id!(ReferenceId); -define_id!(LibraryId); -define_id!(AnnotationId); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_generates_unique_ids() { - let a = ReferenceId::new(); - let b = ReferenceId::new(); - assert_ne!(a, b); - } - - #[test] - fn display_is_uuid_format() { - let id = ReferenceId::new(); - let s = id.to_string(); - assert_eq!(s.len(), 36); // UUID hyphenated format - } - - #[test] - fn serde_round_trip() { - let id = LibraryId::new(); - let json = serde_json::to_string(&id).unwrap(); - let id2: LibraryId = serde_json::from_str(&json).unwrap(); - assert_eq!(id, id2); - } -} +pub use brittle_model::ids::*; diff --git a/brittle-core/src/model/library.rs b/brittle-core/src/model/library.rs index 9f544be..b22fdc1 100644 --- a/brittle-core/src/model/library.rs +++ b/brittle-core/src/model/library.rs @@ -1,66 +1 @@ -use crate::model::ids::{LibraryId, ReferenceId}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; - -/// A named collection of references. Forms a tree via `parent_id`. -/// -/// References are not "owned" by a library — they exist in a flat pool. -/// A reference can appear in multiple libraries (multi-membership). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Library { - pub id: LibraryId, - pub name: String, - /// `None` means this is a root library (no parent). - pub parent_id: Option, - /// The set of references that are members of this library. - /// BTreeSet for deterministic serialization order. - pub members: BTreeSet, - pub created_at: DateTime, - pub modified_at: DateTime, -} - -impl Library { - pub fn new(name: impl Into, parent_id: Option) -> Self { - let now = Utc::now(); - Self { - id: LibraryId::new(), - name: name.into(), - parent_id, - members: BTreeSet::new(), - created_at: now, - modified_at: now, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn library_serde_round_trip() { - let mut lib = Library::new("Machine Learning", None); - let ref_id = ReferenceId::new(); - lib.members.insert(ref_id); - - let toml_str = toml::to_string(&lib).expect("serialize to TOML"); - let lib2: Library = toml::from_str(&toml_str).expect("deserialize from TOML"); - - assert_eq!(lib.id, lib2.id); - assert_eq!(lib.name, lib2.name); - assert_eq!(lib.members, lib2.members); - assert_eq!(lib.parent_id, lib2.parent_id); - } - - #[test] - fn nested_library_serde_round_trip() { - let parent = Library::new("Science", None); - let child = Library::new("Physics", Some(parent.id)); - - let toml_str = toml::to_string(&child).expect("serialize to TOML"); - let child2: Library = toml::from_str(&toml_str).expect("deserialize from TOML"); - - assert_eq!(child2.parent_id, Some(parent.id)); - } -} +pub use brittle_model::library::*; diff --git a/brittle-core/src/model/mod.rs b/brittle-core/src/model/mod.rs index 9a54225..14585a2 100644 --- a/brittle-core/src/model/mod.rs +++ b/brittle-core/src/model/mod.rs @@ -11,3 +11,70 @@ pub use ids::{AnnotationId, LibraryId, ReferenceId}; pub use library::Library; pub use reference::{EntryType, PdfAttachment, Person, Reference}; pub use snapshot::Snapshot; + +/// TOML round-trip tests for model types used in on-disk storage. +/// +/// These live in brittle-core (not brittle-model) because TOML is a +/// brittle-core persistence concern — brittle-model itself is TOML-agnostic. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_toml_round_trip() { + let mut r = Reference::new("doe2024", EntryType::Article); + r.authors.push(Person { + family: "Doe".into(), + given: Some("Jane".into()), + prefix: None, + suffix: None, + }); + r.fields.insert("title".into(), "A Great Paper".into()); + r.fields.insert("year".into(), "2024".into()); + + let toml_str = toml::to_string(&r).expect("serialize to TOML"); + let r2: Reference = toml::from_str(&toml_str).expect("deserialize from TOML"); + + assert_eq!(r.id, r2.id); + assert_eq!(r.cite_key, r2.cite_key); + assert_eq!(r.authors, r2.authors); + assert_eq!(r.fields, r2.fields); + } + + #[test] + fn library_toml_round_trip() { + let mut lib = Library::new("Machine Learning", None); + lib.members.insert(ReferenceId::new()); + + let toml_str = toml::to_string(&lib).expect("serialize to TOML"); + let lib2: Library = toml::from_str(&toml_str).expect("deserialize from TOML"); + + assert_eq!(lib.id, lib2.id); + assert_eq!(lib.name, lib2.name); + assert_eq!(lib.members, lib2.members); + } + + #[test] + fn annotation_toml_round_trip() { + let ref_id = ReferenceId::new(); + let set = AnnotationSet { + reference_id: ref_id, + annotations: vec![Annotation::new( + ref_id, + 3, + AnnotationType::TextMarkup { + markup_type: TextMarkupType::Highlight, + quads: vec![], + color: Color::YELLOW, + selected_text: Some("key phrase".into()), + }, + )], + }; + + let toml_str = toml::to_string(&set).expect("serialize to TOML"); + let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize from TOML"); + + assert_eq!(set.reference_id, set2.reference_id); + assert_eq!(set.annotations[0].page, set2.annotations[0].page); + } +} diff --git a/brittle-core/src/model/reference.rs b/brittle-core/src/model/reference.rs index 1341eb2..015cdff 100644 --- a/brittle-core/src/model/reference.rs +++ b/brittle-core/src/model/reference.rs @@ -1,241 +1 @@ -use crate::model::ids::ReferenceId; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fmt; -use std::path::PathBuf; - -/// A person (author, editor, translator, etc.). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Person { - pub family: String, - pub given: Option, - /// Name prefix: "von", "de", "van der", etc. - pub prefix: Option, - /// Name suffix: "Jr.", "III", etc. - pub suffix: Option, -} - -impl Person { - pub fn new(family: impl Into) -> Self { - Self { - family: family.into(), - given: None, - prefix: None, - suffix: None, - } - } - - /// Format for display: "Given prefix Family, Suffix" — natural reading order. - pub fn display_name(&self) -> String { - let mut parts = Vec::new(); - if let Some(given) = &self.given { - parts.push(given.as_str()); - } - if let Some(prefix) = &self.prefix { - parts.push(prefix.as_str()); - } - parts.push(self.family.as_str()); - let mut name = parts.join(" "); - if let Some(suffix) = &self.suffix { - name.push_str(", "); - name.push_str(suffix); - } - name - } - - /// Format as BibTeX expects: "{prefix} {family}, {suffix}, {given}". - /// Falls back gracefully when optional parts are absent. - pub fn to_bibtex(&self) -> String { - let mut family_part = String::new(); - if let Some(prefix) = &self.prefix { - family_part.push_str(prefix); - family_part.push(' '); - } - family_part.push_str(&self.family); - - match (&self.suffix, &self.given) { - (Some(suffix), Some(given)) => { - format!("{family_part}, {suffix}, {given}") - } - (Some(suffix), None) => format!("{family_part}, {suffix}"), - (None, Some(given)) => format!("{family_part}, {given}"), - (None, None) => family_part, - } - } -} - -impl fmt::Display for Person { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.display_name()) - } -} - -/// Standard BibTeX and common BibLaTeX entry types. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum EntryType { - Article, - Book, - Booklet, - InBook, - InCollection, - InProceedings, - Manual, - MastersThesis, - Misc, - PhdThesis, - Proceedings, - TechReport, - Unpublished, - Online, -} - -impl EntryType { - /// The BibTeX entry type name as it appears in `.bib` files. - pub fn bibtex_name(&self) -> &'static str { - match self { - EntryType::Article => "article", - EntryType::Book => "book", - EntryType::Booklet => "booklet", - EntryType::InBook => "inbook", - EntryType::InCollection => "incollection", - EntryType::InProceedings => "inproceedings", - EntryType::Manual => "manual", - EntryType::MastersThesis => "mastersthesis", - EntryType::Misc => "misc", - EntryType::PhdThesis => "phdthesis", - EntryType::Proceedings => "proceedings", - EntryType::TechReport => "techreport", - EntryType::Unpublished => "unpublished", - EntryType::Online => "online", - } - } -} - -/// A PDF file stored inside the Brittle repository. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PdfAttachment { - /// Path relative to the repository root (e.g., `"pdfs/550e8400-....pdf"`). - pub stored_path: PathBuf, - /// SHA-256 hex digest of the file contents for integrity verification. - pub content_hash: String, -} - -/// A citable work. The core entity of Brittle. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Reference { - pub id: ReferenceId, - /// The BibTeX cite key (e.g., `"knuth1984texbook"`). User-facing and mutable. - pub cite_key: String, - pub entry_type: EntryType, - /// Authors listed in order. - pub authors: Vec, - /// Editors (for edited books, proceedings, etc.). - pub editors: Vec, - /// All other fields (title, year, journal, volume, etc.) as plain strings. - /// BTreeMap for deterministic serialization order (important for git diffs). - pub fields: BTreeMap, - pub pdf: Option, - pub created_at: DateTime, - pub modified_at: DateTime, -} - -impl Reference { - pub fn new(cite_key: impl Into, entry_type: EntryType) -> Self { - let now = Utc::now(); - Self { - id: ReferenceId::new(), - cite_key: cite_key.into(), - entry_type, - authors: Vec::new(), - editors: Vec::new(), - fields: BTreeMap::new(), - pdf: None, - created_at: now, - modified_at: now, - } - } - - /// Returns the value of the `title` field, if present. - pub fn title(&self) -> Option<&str> { - self.fields.get("title").map(String::as_str) - } - - /// Returns the value of the `year` field, if present. - pub fn year(&self) -> Option<&str> { - self.fields.get("year").map(String::as_str) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn person_bibtex_full() { - let p = Person { - family: "Dijkstra".into(), - given: Some("Edsger W.".into()), - prefix: None, - suffix: None, - }; - assert_eq!(p.to_bibtex(), "Dijkstra, Edsger W."); - } - - #[test] - fn person_bibtex_with_prefix() { - let p = Person { - family: "Beethoven".into(), - given: Some("Ludwig".into()), - prefix: Some("van".into()), - suffix: None, - }; - assert_eq!(p.to_bibtex(), "van Beethoven, Ludwig"); - } - - #[test] - fn person_bibtex_with_suffix() { - let p = Person { - family: "King".into(), - given: Some("Martin Luther".into()), - prefix: None, - suffix: Some("Jr.".into()), - }; - assert_eq!(p.to_bibtex(), "King, Jr., Martin Luther"); - } - - #[test] - fn person_bibtex_family_only() { - let p = Person::new("Aristotle"); - assert_eq!(p.to_bibtex(), "Aristotle"); - } - - #[test] - fn reference_serde_round_trip() { - let mut r = Reference::new("doe2024", EntryType::Article); - r.authors.push(Person { - family: "Doe".into(), - given: Some("Jane".into()), - prefix: None, - suffix: None, - }); - r.fields.insert("title".into(), "A Great Paper".into()); - r.fields.insert("year".into(), "2024".into()); - - let toml_str = toml::to_string(&r).expect("serialize to TOML"); - let r2: Reference = toml::from_str(&toml_str).expect("deserialize from TOML"); - - assert_eq!(r.id, r2.id); - assert_eq!(r.cite_key, r2.cite_key); - assert_eq!(r.authors, r2.authors); - assert_eq!(r.fields, r2.fields); - } - - #[test] - fn entry_type_bibtex_names() { - assert_eq!(EntryType::Article.bibtex_name(), "article"); - assert_eq!(EntryType::InProceedings.bibtex_name(), "inproceedings"); - assert_eq!(EntryType::PhdThesis.bibtex_name(), "phdthesis"); - } -} +pub use brittle_model::reference::*; diff --git a/brittle-core/src/model/snapshot.rs b/brittle-core/src/model/snapshot.rs index c84bc33..f92df3d 100644 --- a/brittle-core/src/model/snapshot.rs +++ b/brittle-core/src/model/snapshot.rs @@ -1,12 +1 @@ -use chrono::{DateTime, Utc}; -use serde::Serialize; - -/// Metadata about a stored snapshot (git commit). -/// Not serialized to files — read directly from git history. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct Snapshot { - /// Git commit SHA (hex string). - pub id: String, - pub message: String, - pub timestamp: DateTime, -} +pub use brittle_model::snapshot::*; diff --git a/brittle-model/Cargo.toml b/brittle-model/Cargo.toml new file mode 100644 index 0000000..840ffd3 --- /dev/null +++ b/brittle-model/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "brittle-model" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +uuid = { version = "1", features = ["v7", "serde"] } + +[dev-dependencies] +serde_json = "1" diff --git a/brittle-model/src/annotation.rs b/brittle-model/src/annotation.rs new file mode 100644 index 0000000..92ee074 --- /dev/null +++ b/brittle-model/src/annotation.rs @@ -0,0 +1,214 @@ +use crate::ids::{AnnotationId, ReferenceId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A point in PDF coordinate space. +/// Origin is bottom-left; units are points (1/72 inch), matching ISO 32000. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +/// A rectangle in PDF coordinate space. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// A quadrilateral for text markup annotations (highlight, underline, etc.). +/// Four points define one region, typically one line of text. +/// Matches the PDF spec QuadPoints representation (4 vertices per quad). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Quad { + pub points: [Point; 4], +} + +/// RGBA color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Color { + pub const YELLOW: Color = Color { r: 255, g: 255, b: 0, a: 128 }; + pub const RED: Color = Color { r: 255, g: 0, b: 0, a: 128 }; + pub const GREEN: Color = Color { r: 0, g: 255, b: 0, a: 128 }; +} + +/// The four text markup annotation types defined in ISO 32000. +/// All share the same QuadPoints-based geometry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextMarkupType { + Highlight, + Underline, + Squiggly, + StrikeOut, +} + +/// The kind of annotation and its type-specific geometry/data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum AnnotationType { + /// Text markup (highlight, underline, squiggly, strikeout). + /// Uses QuadPoints per PDF spec for precise multi-line region selection. + TextMarkup { + markup_type: TextMarkupType, + quads: Vec, + color: Color, + /// The selected text, stored for search and export without re-reading the PDF. + selected_text: Option, + }, + /// Sticky note (popup comment). + Note { position: Point }, + /// Inline text box. + FreeText { rect: Rect }, + /// Freehand ink drawing (e.g., circling a diagram). + Ink { + /// Multiple strokes, each a sequence of connected points. + paths: Vec>, + color: Color, + /// Stroke width in points. + width: f64, + }, + /// Area/image selection for extracting figures from PDFs. + Area { rect: Rect }, +} + +/// A single annotation on a PDF page. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Annotation { + pub id: AnnotationId, + pub reference_id: ReferenceId, + /// 0-indexed physical page number. + pub page: u32, + /// Display page label (e.g., "iv", "23") — may differ from the physical page index. + pub page_label: Option, + pub annotation_type: AnnotationType, + /// Free-form text: note body, comment on a highlight, etc. + pub content: Option, + pub created_at: DateTime, + pub modified_at: DateTime, +} + +impl Annotation { + pub fn new(reference_id: ReferenceId, page: u32, annotation_type: AnnotationType) -> Self { + let now = Utc::now(); + Self { + id: AnnotationId::new(), + reference_id, + page, + page_label: None, + annotation_type, + content: None, + created_at: now, + modified_at: now, + } + } +} + +/// All annotations for a single reference, stored as one file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnnotationSet { + pub reference_id: ReferenceId, + pub annotations: Vec, +} + +impl AnnotationSet { + pub fn new(reference_id: ReferenceId) -> Self { + Self { + reference_id, + annotations: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_highlight() -> AnnotationType { + AnnotationType::TextMarkup { + markup_type: TextMarkupType::Highlight, + quads: vec![Quad { + points: [ + Point { x: 10.0, y: 20.0 }, + Point { x: 100.0, y: 20.0 }, + Point { x: 10.0, y: 30.0 }, + Point { x: 100.0, y: 30.0 }, + ], + }], + color: Color::YELLOW, + selected_text: Some("important text".into()), + } + } + + #[test] + fn annotation_serde_round_trip_highlight() { + let ref_id = ReferenceId::new(); + let set = AnnotationSet { + reference_id: ref_id, + annotations: vec![Annotation::new(ref_id, 3, make_highlight())], + }; + + let json = serde_json::to_string(&set).expect("serialize"); + let set2: AnnotationSet = serde_json::from_str(&json).expect("deserialize"); + + assert_eq!(set.reference_id, set2.reference_id); + assert_eq!(set.annotations.len(), set2.annotations.len()); + assert_eq!(set.annotations[0].page, set2.annotations[0].page); + } + + #[test] + fn annotation_serde_round_trip_ink() { + let ref_id = ReferenceId::new(); + let ink = AnnotationType::Ink { + paths: vec![vec![Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 10.0 }]], + color: Color::RED, + width: 2.0, + }; + let set = AnnotationSet { + reference_id: ref_id, + annotations: vec![Annotation::new(ref_id, 0, ink)], + }; + + let json = serde_json::to_string(&set).expect("serialize"); + let set2: AnnotationSet = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(set, set2); + } + + #[test] + fn all_markup_types_serialize() { + let ref_id = ReferenceId::new(); + for markup_type in [ + TextMarkupType::Highlight, + TextMarkupType::Underline, + TextMarkupType::Squiggly, + TextMarkupType::StrikeOut, + ] { + let ann = Annotation::new( + ref_id, + 0, + AnnotationType::TextMarkup { + markup_type, + quads: vec![], + color: Color::GREEN, + selected_text: None, + }, + ); + let set = AnnotationSet { + reference_id: ref_id, + annotations: vec![ann], + }; + let json = serde_json::to_string(&set).expect("serialize"); + let _: AnnotationSet = serde_json::from_str(&json).expect("deserialize"); + } + } +} diff --git a/brittle-model/src/ids.rs b/brittle-model/src/ids.rs new file mode 100644 index 0000000..6e052fb --- /dev/null +++ b/brittle-model/src/ids.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use uuid::Uuid; + +macro_rules! define_id { + ($name:ident) => { + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + )] + pub struct $name(pub Uuid); + + impl $name { + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl From for $name { + fn from(uuid: Uuid) -> Self { + Self(uuid) + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +define_id!(ReferenceId); +define_id!(LibraryId); +define_id!(AnnotationId); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_generates_unique_ids() { + let a = ReferenceId::new(); + let b = ReferenceId::new(); + assert_ne!(a, b); + } + + #[test] + fn display_is_uuid_format() { + let id = ReferenceId::new(); + let s = id.to_string(); + assert_eq!(s.len(), 36); // UUID hyphenated format + } + + #[test] + fn serde_round_trip() { + let id = LibraryId::new(); + let json = serde_json::to_string(&id).unwrap(); + let id2: LibraryId = serde_json::from_str(&json).unwrap(); + assert_eq!(id, id2); + } +} diff --git a/brittle-model/src/lib.rs b/brittle-model/src/lib.rs new file mode 100644 index 0000000..7049e30 --- /dev/null +++ b/brittle-model/src/lib.rs @@ -0,0 +1,137 @@ +//! `brittle-model` — shared data types for the Brittle reference manager. +//! +//! This crate contains all model types that cross the IPC boundary between the +//! Tauri backend (`brittle-core`) and the Leptos/WASM frontend. It has no +//! native-only dependencies and compiles to both `wasm32-unknown-unknown` and +//! native targets. +//! +//! Both `brittle-core` and `brittle-ui` depend on this crate. + +pub mod annotation; +pub mod ids; +pub mod library; +pub mod reference; +pub mod snapshot; + +pub use annotation::{ + Annotation, AnnotationSet, AnnotationType, Color, Point, Quad, Rect, TextMarkupType, +}; +pub use ids::{AnnotationId, LibraryId, ReferenceId}; +pub use library::Library; +pub use reference::{EntryType, PdfAttachment, Person, Reference}; +pub use snapshot::Snapshot; + +use serde::Serialize; + +/// A lightweight summary of a reference, suitable for list views. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +pub struct ReferenceSummary { + pub id: ReferenceId, + pub cite_key: String, + pub entry_type: EntryType, + pub title: Option, + pub authors: Vec, + pub year: Option, +} + +impl ReferenceSummary { + /// Title or "[no title]" fallback. + pub fn title_display(&self) -> &str { + self.title.as_deref().unwrap_or("[no title]") + } + + /// Compact author string: "Family", "A & B", or "A et al." + pub fn author_display(&self) -> String { + match self.authors.len() { + 0 => "—".into(), + 1 => self.authors[0].family.clone(), + 2 => format!("{} & {}", self.authors[0].family, self.authors[1].family), + _ => format!("{} et al.", self.authors[0].family), + } + } +} + +impl From<&Reference> for ReferenceSummary { + fn from(r: &Reference) -> Self { + Self { + id: r.id, + cite_key: r.cite_key.clone(), + entry_type: r.entry_type.clone(), + title: r.title().map(str::to_owned), + authors: r.authors.clone(), + year: r.year().map(str::to_owned), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(family: &str) -> Person { + Person { family: family.into(), given: None, prefix: None, suffix: None } + } + + fn base_summary() -> ReferenceSummary { + ReferenceSummary { + id: ReferenceId::new(), + cite_key: "x".into(), + entry_type: EntryType::Article, + title: None, + authors: vec![], + year: None, + } + } + + #[test] + fn title_display_fallback() { + let rs = base_summary(); + assert_eq!(rs.title_display(), "[no title]"); + } + + #[test] + fn title_display_with_title() { + let rs = ReferenceSummary { title: Some("My Paper".into()), ..base_summary() }; + assert_eq!(rs.title_display(), "My Paper"); + } + + #[test] + fn author_display_empty() { + assert_eq!(base_summary().author_display(), "—"); + } + + #[test] + fn author_display_one() { + let rs = ReferenceSummary { authors: vec![p("Smith")], ..base_summary() }; + assert_eq!(rs.author_display(), "Smith"); + } + + #[test] + fn author_display_two() { + let rs = ReferenceSummary { authors: vec![p("Smith"), p("Jones")], ..base_summary() }; + assert_eq!(rs.author_display(), "Smith & Jones"); + } + + #[test] + fn author_display_many() { + let rs = ReferenceSummary { + authors: vec![p("Smith"), p("Jones"), p("Brown")], + ..base_summary() + }; + assert_eq!(rs.author_display(), "Smith et al."); + } + + #[test] + fn summary_from_reference() { + let mut r = Reference::new("einstein1905", EntryType::Article); + r.fields.insert("title".into(), "On the Electrodynamics of Moving Bodies".into()); + r.fields.insert("year".into(), "1905".into()); + r.authors.push(Person::new("Einstein")); + + let s = ReferenceSummary::from(&r); + assert_eq!(s.cite_key, "einstein1905"); + assert_eq!(s.title_display(), "On the Electrodynamics of Moving Bodies"); + assert_eq!(s.author_display(), "Einstein"); + assert_eq!(s.year.as_deref(), Some("1905")); + } +} diff --git a/brittle-model/src/library.rs b/brittle-model/src/library.rs new file mode 100644 index 0000000..217f032 --- /dev/null +++ b/brittle-model/src/library.rs @@ -0,0 +1,66 @@ +use crate::ids::{LibraryId, ReferenceId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// A named collection of references. Forms a tree via `parent_id`. +/// +/// References are not "owned" by a library — they exist in a flat pool. +/// A reference can appear in multiple libraries (multi-membership). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Library { + pub id: LibraryId, + pub name: String, + /// `None` means this is a root library (no parent). + pub parent_id: Option, + /// The set of references that are members of this library. + /// BTreeSet for deterministic serialization order. + pub members: BTreeSet, + pub created_at: DateTime, + pub modified_at: DateTime, +} + +impl Library { + pub fn new(name: impl Into, parent_id: Option) -> Self { + let now = Utc::now(); + Self { + id: LibraryId::new(), + name: name.into(), + parent_id, + members: BTreeSet::new(), + created_at: now, + modified_at: now, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_serde_round_trip() { + let mut lib = Library::new("Machine Learning", None); + let ref_id = ReferenceId::new(); + lib.members.insert(ref_id); + + let json = serde_json::to_string(&lib).expect("serialize to JSON"); + let lib2: Library = serde_json::from_str(&json).expect("deserialize from JSON"); + + assert_eq!(lib.id, lib2.id); + assert_eq!(lib.name, lib2.name); + assert_eq!(lib.members, lib2.members); + assert_eq!(lib.parent_id, lib2.parent_id); + } + + #[test] + fn nested_library_serde_round_trip() { + let parent = Library::new("Science", None); + let child = Library::new("Physics", Some(parent.id)); + + let json = serde_json::to_string(&child).expect("serialize to JSON"); + let child2: Library = serde_json::from_str(&json).expect("deserialize from JSON"); + + assert_eq!(child2.parent_id, Some(parent.id)); + } +} diff --git a/brittle-model/src/reference.rs b/brittle-model/src/reference.rs new file mode 100644 index 0000000..9e3d685 --- /dev/null +++ b/brittle-model/src/reference.rs @@ -0,0 +1,275 @@ +use crate::ids::ReferenceId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; + +/// A person (author, editor, translator, etc.). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Person { + pub family: String, + pub given: Option, + /// Name prefix: "von", "de", "van der", etc. + pub prefix: Option, + /// Name suffix: "Jr.", "III", etc. + pub suffix: Option, +} + +impl Person { + pub fn new(family: impl Into) -> Self { + Self { + family: family.into(), + given: None, + prefix: None, + suffix: None, + } + } + + /// Format for display: "Given prefix Family, Suffix" — natural reading order. + pub fn display_name(&self) -> String { + let mut parts = Vec::new(); + if let Some(given) = &self.given { + parts.push(given.as_str()); + } + if let Some(prefix) = &self.prefix { + parts.push(prefix.as_str()); + } + parts.push(self.family.as_str()); + let mut name = parts.join(" "); + if let Some(suffix) = &self.suffix { + name.push_str(", "); + name.push_str(suffix); + } + name + } + + /// Format as BibTeX expects: "{prefix} {family}, {suffix}, {given}". + /// Falls back gracefully when optional parts are absent. + pub fn to_bibtex(&self) -> String { + let mut family_part = String::new(); + if let Some(prefix) = &self.prefix { + family_part.push_str(prefix); + family_part.push(' '); + } + family_part.push_str(&self.family); + + match (&self.suffix, &self.given) { + (Some(suffix), Some(given)) => { + format!("{family_part}, {suffix}, {given}") + } + (Some(suffix), None) => format!("{family_part}, {suffix}"), + (None, Some(given)) => format!("{family_part}, {given}"), + (None, None) => family_part, + } + } +} + +impl fmt::Display for Person { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.display_name()) + } +} + +/// Standard BibTeX and common BibLaTeX entry types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EntryType { + Article, + Book, + Booklet, + InBook, + InCollection, + InProceedings, + Manual, + MastersThesis, + Misc, + PhdThesis, + Proceedings, + TechReport, + Unpublished, + Online, +} + +impl EntryType { + /// The BibTeX entry type name as it appears in `.bib` files. + pub fn bibtex_name(&self) -> &'static str { + match self { + EntryType::Article => "article", + EntryType::Book => "book", + EntryType::Booklet => "booklet", + EntryType::InBook => "inbook", + EntryType::InCollection => "incollection", + EntryType::InProceedings => "inproceedings", + EntryType::Manual => "manual", + EntryType::MastersThesis => "mastersthesis", + EntryType::Misc => "misc", + EntryType::PhdThesis => "phdthesis", + EntryType::Proceedings => "proceedings", + EntryType::TechReport => "techreport", + EntryType::Unpublished => "unpublished", + EntryType::Online => "online", + } + } +} + +impl fmt::Display for EntryType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.bibtex_name()) + } +} + +/// A PDF file stored inside the Brittle repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdfAttachment { + /// Path relative to the repository root (e.g., `"pdfs/550e8400-....pdf"`). + pub stored_path: PathBuf, + /// SHA-256 hex digest of the file contents for integrity verification. + pub content_hash: String, +} + +/// A citable work. The core entity of Brittle. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Reference { + pub id: ReferenceId, + /// The BibTeX cite key (e.g., `"knuth1984texbook"`). User-facing and mutable. + pub cite_key: String, + pub entry_type: EntryType, + /// Authors listed in order. + pub authors: Vec, + /// Editors (for edited books, proceedings, etc.). + pub editors: Vec, + /// All other fields (title, year, journal, volume, etc.) as plain strings. + /// BTreeMap for deterministic serialization order (important for git diffs). + pub fields: BTreeMap, + pub pdf: Option, + pub created_at: DateTime, + pub modified_at: DateTime, +} + +impl Reference { + pub fn new(cite_key: impl Into, entry_type: EntryType) -> Self { + let now = Utc::now(); + Self { + id: ReferenceId::new(), + cite_key: cite_key.into(), + entry_type, + authors: Vec::new(), + editors: Vec::new(), + fields: BTreeMap::new(), + pdf: None, + created_at: now, + modified_at: now, + } + } + + /// Returns the value of the `title` field, if present. + pub fn title(&self) -> Option<&str> { + self.fields.get("title").map(String::as_str) + } + + /// Returns the value of the `year` field, if present. + pub fn year(&self) -> Option<&str> { + self.fields.get("year").map(String::as_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn person_bibtex_full() { + let p = Person { + family: "Dijkstra".into(), + given: Some("Edsger W.".into()), + prefix: None, + suffix: None, + }; + assert_eq!(p.to_bibtex(), "Dijkstra, Edsger W."); + } + + #[test] + fn person_bibtex_with_prefix() { + let p = Person { + family: "Beethoven".into(), + given: Some("Ludwig".into()), + prefix: Some("van".into()), + suffix: None, + }; + assert_eq!(p.to_bibtex(), "van Beethoven, Ludwig"); + } + + #[test] + fn person_bibtex_with_suffix() { + let p = Person { + family: "King".into(), + given: Some("Martin Luther".into()), + prefix: None, + suffix: Some("Jr.".into()), + }; + assert_eq!(p.to_bibtex(), "King, Jr., Martin Luther"); + } + + #[test] + fn person_bibtex_family_only() { + let p = Person::new("Aristotle"); + assert_eq!(p.to_bibtex(), "Aristotle"); + } + + #[test] + fn person_display_name_full() { + let p = Person { + family: "Turing".into(), + given: Some("Alan".into()), + prefix: None, + suffix: None, + }; + assert_eq!(p.display_name(), "Alan Turing"); + } + + #[test] + fn person_display_name_with_prefix_and_suffix() { + let p = Person { + family: "King".into(), + given: Some("Martin Luther".into()), + prefix: None, + suffix: Some("Jr.".into()), + }; + assert_eq!(p.display_name(), "Martin Luther King, Jr."); + } + + #[test] + fn reference_serde_round_trip() { + let mut r = Reference::new("doe2024", EntryType::Article); + r.authors.push(Person { + family: "Doe".into(), + given: Some("Jane".into()), + prefix: None, + suffix: None, + }); + r.fields.insert("title".into(), "A Great Paper".into()); + r.fields.insert("year".into(), "2024".into()); + + let json = serde_json::to_string(&r).expect("serialize to JSON"); + let r2: Reference = serde_json::from_str(&json).expect("deserialize from JSON"); + + assert_eq!(r.id, r2.id); + assert_eq!(r.cite_key, r2.cite_key); + assert_eq!(r.authors, r2.authors); + assert_eq!(r.fields, r2.fields); + } + + #[test] + fn entry_type_bibtex_names() { + assert_eq!(EntryType::Article.bibtex_name(), "article"); + assert_eq!(EntryType::InProceedings.bibtex_name(), "inproceedings"); + assert_eq!(EntryType::PhdThesis.bibtex_name(), "phdthesis"); + } + + #[test] + fn entry_type_display() { + assert_eq!(EntryType::Article.to_string(), "article"); + assert_eq!(EntryType::InProceedings.to_string(), "inproceedings"); + } +} diff --git a/brittle-model/src/snapshot.rs b/brittle-model/src/snapshot.rs new file mode 100644 index 0000000..ef5c287 --- /dev/null +++ b/brittle-model/src/snapshot.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Metadata about a stored snapshot (git commit). +/// Not serialized to files — read directly from git history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Snapshot { + /// Git commit SHA (hex string). + pub id: String, + pub message: String, + pub timestamp: DateTime, +} diff --git a/src-tauri/src/commands/reference.rs b/src-tauri/src/commands/reference.rs index 42c24bc..0afce7e 100644 --- a/src-tauri/src/commands/reference.rs +++ b/src-tauri/src/commands/reference.rs @@ -78,10 +78,5 @@ pub fn list_library_references_recursive( state: State, library_id: LibraryId, ) -> Result, String> { - let result = state.with_repo_read(|b| b.list_library_references_recursive(library_id)); - match &result { - Ok(refs) => eprintln!("[brittle] list_library_references_recursive({library_id}): {} refs", refs.len()), - Err(e) => eprintln!("[brittle] list_library_references_recursive({library_id}): ERROR: {e}"), - } - result + state.with_repo_read(|b| b.list_library_references_recursive(library_id)) } diff --git a/src-tauri/src/pdf_protocol.rs b/src-tauri/src/pdf_protocol.rs index f5e3b33..e254f44 100644 --- a/src-tauri/src/pdf_protocol.rs +++ b/src-tauri/src/pdf_protocol.rs @@ -50,11 +50,11 @@ pub fn handle(app: &AppHandle, req: &Request>) -> Respons // ── Route handlers ──────────────────────────────────────────────────────────── -fn serve_viewer(ref_id: &str) -> Response> { - // Substitute the ref_id into the HTML template so the viewer knows which PDF to load. - let html = String::from_utf8_lossy(VIEWER_HTML) - .replace("ref_id=\"\"", &format!("ref_id=\"{}\"", ref_id)); - response_ok(html.into_bytes(), "text/html; charset=utf-8") +fn serve_viewer(_ref_id: &str) -> Response> { + // The viewer reads `ref_id` from its own URL query string via JavaScript + // (`new URLSearchParams(location.search).get("ref_id")`), so no substitution + // into the HTML template is needed. + response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8") } fn serve_pdfjs_file(rel_path: &str) -> Response> { @@ -148,7 +148,6 @@ fn response_500(msg: &str) -> Response> { // ── Pure routing logic (unit-testable) ─────────────────────────────────────── pub mod routing { - use std::path::Path; #[derive(Debug, PartialEq)] pub enum Route { @@ -185,23 +184,6 @@ pub mod routing { .to_owned() } - /// Return the appropriate MIME type for a file path based on its extension. - pub fn mime_for_path(path: &Path) -> &'static str { - match path.extension().and_then(|e| e.to_str()) { - Some("js") => "application/javascript; charset=utf-8", - Some("mjs") => "application/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("html") => "text/html; charset=utf-8", - Some("pdf") => "application/pdf", - Some("woff2") => "font/woff2", - Some("woff") => "font/woff", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("svg") => "image/svg+xml", - Some("map") => "application/json", - _ => "application/octet-stream", - } - } #[cfg(test)] mod tests { @@ -276,24 +258,6 @@ pub mod routing { assert_eq!(extract_ref_id(Some("foo=bar")), ""); } - #[test] - fn mime_for_js_files() { - assert!(mime_for_path(Path::new("pdf.min.js")).contains("javascript")); - } - - #[test] - fn mime_for_css_files() { - assert!(mime_for_path(Path::new("viewer.css")).contains("css")); - } - - #[test] - fn mime_for_unknown_extension() { - assert_eq!( - mime_for_path(Path::new("data.bin")), - "application/octet-stream" - ); - } - #[test] fn path_traversal_rel_path_contains_dotdot() { // The handler rejects rel_paths containing ".."; verify the routing diff --git a/src/Cargo.lock b/src/Cargo.lock index fdc957b..5925574 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "any_spawner" version = "0.2.0" @@ -80,6 +89,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -96,16 +111,27 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" name = "brittle-keymap" version = "0.1.0" +[[package]] +name = "brittle-model" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "uuid", +] + [[package]] name = "brittle-ui" version = "0.1.0" dependencies = [ "brittle-keymap", + "brittle-model", "js-sys", "leptos", "serde", "serde-wasm-bindgen", "serde_json", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -129,12 +155,36 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "codee" version = "0.3.5" @@ -218,6 +268,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -315,6 +371,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" @@ -531,6 +593,30 @@ dependencies = [ "throw_error", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -879,6 +965,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "oco_ref" version = "0.2.1" @@ -1357,6 +1452,12 @@ dependencies = [ "syn", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.12" @@ -1612,6 +1713,7 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1774,12 +1876,65 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/src/Cargo.toml b/src/Cargo.toml index ab24dbe..107c003 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -6,7 +6,9 @@ edition = "2021" [workspace] [dependencies] +brittle-model = { path = "../brittle-model" } brittle-keymap = { path = "../brittle-keymap" } +uuid = { version = "1", features = ["v7", "js"] } js-sys = "0.3" leptos = { version = "0.7", features = ["csr"] } wasm-bindgen = "0.2" diff --git a/src/src/lib_tab.rs b/src/src/lib_tab.rs index cbe9cbf..e4a98d9 100644 --- a/src/src/lib_tab.rs +++ b/src/src/lib_tab.rs @@ -6,10 +6,11 @@ use brittle_keymap::actions; use leptos::prelude::*; use leptos::task::spawn_local; +use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary}; + use crate::{ command_bar::SearchQuery, lib_tree::{flatten_tree, LibraryTree, TreeRow}, - models::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary}, pub_detail::PubDetail, pub_list::PubList, ActionEvent, @@ -85,7 +86,11 @@ pub fn LibTab() -> impl IntoView { // The library selected by the tree cursor. let selected_library = Memo::new(move |_| { let pos = tree_cursor.get(); - tree_rows.with(|rows| rows.get(pos).map(|r| LibraryId(r.id.clone()))) + tree_rows.with(|rows| { + rows.get(pos).and_then(|r| { + uuid::Uuid::parse_str(&r.id).ok().map(LibraryId) + }) + }) }); // The reference ID selected by the list cursor. @@ -154,11 +159,11 @@ pub fn LibTab() -> impl IntoView { let title = detail_ref.with_untracked(|r| { r.as_ref() .map(|r| r.cite_key.clone()) - .unwrap_or_else(|| ref_id.0.clone()) + .unwrap_or_else(|| ref_id.to_string()) }); if let Some(ctx) = use_context::() { ctx.0.set(Some(crate::PdfOpenRequest { - ref_id: ref_id.0.clone(), + ref_id: ref_id.to_string(), title, })); } @@ -335,3 +340,265 @@ fn tree_collapse( } } +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use brittle_keymap::actions; + use leptos::prelude::*; + + fn ev(name: &str) -> ActionEvent { + ActionEvent { name: name.to_owned(), count: 1, seq: 0 } + } + + fn ev_count(name: &str, count: u32) -> ActionEvent { + ActionEvent { name: name.to_owned(), count, seq: 0 } + } + + fn make_tree_rows(n: usize) -> Vec { + (0..n) + .map(|i| TreeRow { + id: format!("row-{i}"), + name: format!("Row {i}"), + depth: 0, + expanded: false, + may_have_children: false, + }) + .collect() + } + + fn make_ref_summaries(n: usize) -> Vec { + use brittle_model::{EntryType, ReferenceId}; + (0..n) + .map(|_| ReferenceSummary { + id: ReferenceId::new(), + cite_key: "x".into(), + entry_type: EntryType::Misc, + title: None, + authors: vec![], + year: None, + }) + .collect() + } + + // Set up signals and call handle_action in a Leptos reactive owner. + // Returns the signal values after the action. + fn run(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F) + where + F: FnOnce(RwSignal, RwSignal, RwSignal, RwSignal>), + { + let owner = Owner::new(); + owner.with(|| { + let focused = RwSignal::new(focused_init); + let tree_cursor = RwSignal::new(0usize); + let list_cursor = RwSignal::new(0usize); + let expanded = RwSignal::new(HashSet::::new()); + let rows_data = make_tree_rows(tree_len); + let rows = Memo::new(move |_| rows_data.clone()); + let list_items = RwSignal::new(make_ref_summaries(list_len)); + + handle_action( + ev, + focused, + TreeState { cursor: tree_cursor, rows, expanded }, + ListState { cursor: list_cursor, items: list_items }, + ); + + check(focused, tree_cursor, list_cursor, expanded); + }); + } + + // ── Focus switching ──────────────────────────────────────────────────────── + + #[test] + fn focus_left_sets_tree() { + run(Pane::List, 0, 0, &ev(actions::FOCUS_LEFT), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::Tree); + }); + } + + #[test] + fn focus_center_sets_list() { + run(Pane::Tree, 0, 0, &ev(actions::FOCUS_CENTER), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::List); + }); + } + + #[test] + fn focus_right_sets_detail() { + run(Pane::Tree, 0, 0, &ev(actions::FOCUS_RIGHT), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::Detail); + }); + } + + #[test] + fn focus_next_cycles_forward() { + run(Pane::Tree, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::List); + }); + run(Pane::List, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::Detail); + }); + run(Pane::Detail, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::Tree); + }); + } + + #[test] + fn focus_prev_cycles_backward() { + run(Pane::Tree, 0, 0, &ev(actions::FOCUS_PREV), |f, _, _, _| { + assert_eq!(f.get_untracked(), Pane::Detail); + }); + } + + // ── Tree navigation ──────────────────────────────────────────────────────── + + #[test] + fn nav_down_advances_tree_cursor() { + run(Pane::Tree, 5, 0, &ev(actions::NAV_DOWN), |_, tc, _, _| { + assert_eq!(tc.get_untracked(), 1); + }); + } + + #[test] + fn nav_down_clamps_at_tree_end() { + let owner = Owner::new(); + owner.with(|| { + let focused = RwSignal::new(Pane::Tree); + let tree_cursor = RwSignal::new(4usize); // already at last row + let expanded = RwSignal::new(HashSet::::new()); + let rows_data = make_tree_rows(5); + let rows = Memo::new(move |_| rows_data.clone()); + let list_cursor = RwSignal::new(0usize); + let list_items = RwSignal::new(vec![]); + + handle_action( + &ev(actions::NAV_DOWN), + focused, + TreeState { cursor: tree_cursor, rows, expanded }, + ListState { cursor: list_cursor, items: list_items }, + ); + + assert_eq!(tree_cursor.get_untracked(), 4); // stayed at 4 + }); + } + + #[test] + fn nav_up_decrements_tree_cursor() { + let owner = Owner::new(); + owner.with(|| { + let focused = RwSignal::new(Pane::Tree); + let tree_cursor = RwSignal::new(3usize); + let expanded = RwSignal::new(HashSet::::new()); + let rows_data = make_tree_rows(5); + let rows = Memo::new(move |_| rows_data.clone()); + handle_action( + &ev(actions::NAV_UP), + focused, + TreeState { cursor: tree_cursor, rows, expanded }, + ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, + ); + assert_eq!(tree_cursor.get_untracked(), 2); + }); + } + + #[test] + fn nav_up_clamps_at_zero() { + run(Pane::Tree, 5, 0, &ev(actions::NAV_UP), |_, tc, _, _| { + assert_eq!(tc.get_untracked(), 0); // already 0, stays 0 + }); + } + + #[test] + fn nav_top_jumps_tree_to_start() { + let owner = Owner::new(); + owner.with(|| { + let focused = RwSignal::new(Pane::Tree); + let tree_cursor = RwSignal::new(4usize); + let expanded = RwSignal::new(HashSet::::new()); + let rows_data = make_tree_rows(5); + let rows = Memo::new(move |_| rows_data.clone()); + handle_action( + &ev(actions::NAV_TOP), + focused, + TreeState { cursor: tree_cursor, rows, expanded }, + ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, + ); + assert_eq!(tree_cursor.get_untracked(), 0); + }); + } + + #[test] + fn nav_bottom_jumps_tree_to_end() { + run(Pane::Tree, 5, 0, &ev(actions::NAV_BOTTOM), |_, tc, _, _| { + assert_eq!(tc.get_untracked(), 4); + }); + } + + #[test] + fn nav_down_with_count_advances_multiple() { + run(Pane::Tree, 10, 0, &ev_count(actions::NAV_DOWN, 3), |_, tc, _, _| { + assert_eq!(tc.get_untracked(), 3); + }); + } + + // ── List navigation ──────────────────────────────────────────────────────── + + #[test] + fn nav_down_advances_list_cursor_when_focused() { + run(Pane::List, 0, 5, &ev(actions::NAV_DOWN), |_, _, lc, _| { + assert_eq!(lc.get_untracked(), 1); + }); + } + + #[test] + fn nav_down_on_tree_does_not_advance_list_cursor() { + run(Pane::Tree, 3, 3, &ev(actions::NAV_DOWN), |_, tc, lc, _| { + assert_eq!(tc.get_untracked(), 1); // tree moved + assert_eq!(lc.get_untracked(), 0); // list unchanged + }); + } + + #[test] + fn nav_bottom_jumps_list_to_last_item() { + run(Pane::List, 0, 5, &ev(actions::NAV_BOTTOM), |_, _, lc, _| { + assert_eq!(lc.get_untracked(), 4); + }); + } + + // ── Tree expand/collapse ─────────────────────────────────────────────────── + + #[test] + fn tree_expand_action_inserts_id_into_expanded() { + run(Pane::Tree, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| { + assert!(exp.with_untracked(|e| e.contains("row-0"))); + }); + } + + #[test] + fn tree_collapse_action_removes_id_from_expanded() { + let owner = Owner::new(); + owner.with(|| { + let focused = RwSignal::new(Pane::Tree); + let tree_cursor = RwSignal::new(0usize); + let expanded = RwSignal::new(HashSet::from_iter(["row-0".to_string()])); + let rows_data = make_tree_rows(3); + let rows = Memo::new(move |_| rows_data.clone()); + handle_action( + &ev(actions::TREE_COLLAPSE), + focused, + TreeState { cursor: tree_cursor, rows, expanded }, + ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, + ); + assert!(!expanded.with_untracked(|e| e.contains("row-0"))); + }); + } + + #[test] + fn tree_actions_are_no_ops_when_list_focused() { + run(Pane::List, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| { + assert!(exp.with_untracked(|e| e.is_empty())); + }); + } +} diff --git a/src/src/lib_tree.rs b/src/src/lib_tree.rs index b332ece..4e03368 100644 --- a/src/src/lib_tree.rs +++ b/src/src/lib_tree.rs @@ -6,7 +6,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use web_sys::DragEvent; -use crate::models::{Library, LibraryId, ReferenceId}; +use brittle_model::{Library, LibraryId, ReferenceId}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -15,7 +15,10 @@ pub fn load_children_if_needed(id: String, children_cache: RwSignal LibraryId(u), + Err(_) => return, + }; match crate::tauri::list_child_libraries(&lib_id).await { Ok(children) => children_cache.update(|c| { c.insert(id, children); }), Err(e) => leptos::logging::error!("load children ({id}): {e}"), @@ -54,7 +57,7 @@ pub fn flatten_tree( ) -> Vec { let mut rows = Vec::new(); for lib in libs { - let id = lib.id.0.clone(); + let id = lib.id.to_string(); let is_expanded = expanded.contains(&id); let children = children_cache.get(&id); let may_have_children = children.is_none_or(|c| !c.is_empty()); @@ -175,8 +178,10 @@ pub fn LibraryTree( let Some(dt) = ev.data_transfer() else { return }; let Ok(ref_id_str) = dt.get_data("application/brittle-ref-id") else { return }; if ref_id_str.is_empty() { return } - let lib_id = LibraryId(row_id_drop.clone()); - let ref_id = ReferenceId(ref_id_str); + let Ok(lib_uuid) = uuid::Uuid::parse_str(&row_id_drop) else { return }; + let Ok(ref_uuid) = uuid::Uuid::parse_str(&ref_id_str) else { return }; + let lib_id = LibraryId(lib_uuid); + let ref_id = ReferenceId(ref_uuid); spawn_local(async move { if let Err(e) = crate::tauri::add_to_library(&lib_id, &ref_id).await { leptos::logging::warn!("add_to_library: {e}"); @@ -202,14 +207,17 @@ pub fn LibraryTree( #[cfg(test)] mod tests { use super::*; - use crate::models::LibraryId; - fn lib(id: &str, name: &str, parent: Option<&str>) -> Library { - Library { - id: LibraryId(id.into()), - name: name.into(), - parent_id: parent.map(|p| LibraryId(p.into())), - } + /// Create a Library with a deterministic UUID from a small integer. + /// `n = 1` → `00000000-0000-0000-0000-000000000001`, etc. + fn test_lib(n: u128, name: &str, parent: Option) -> Library { + let mut lib = Library::new(name, parent.map(|p| LibraryId(uuid::Uuid::from_u128(p)))); + lib.id = LibraryId(uuid::Uuid::from_u128(n)); + lib + } + + fn id_str(n: u128) -> String { + uuid::Uuid::from_u128(n).to_string() } #[test] @@ -220,10 +228,9 @@ mod tests { #[test] fn single_root_unloaded_children() { - let root = lib("r", "Root", None); + let root = test_lib(1, "Root", None); let rows = flatten_tree(&[root], &Default::default(), &Default::default(), 0); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].id, "r"); assert_eq!(rows[0].name, "Root"); assert_eq!(rows[0].depth, 0); assert!(!rows[0].expanded); @@ -233,10 +240,10 @@ mod tests { #[test] fn collapsed_node_hides_children() { - let parent = lib("p", "Parent", None); - let child = lib("c", "Child", Some("p")); + let parent = test_lib(1, "Parent", None); + let child = test_lib(2, "Child", Some(1)); let mut cache = HashMap::new(); - cache.insert("p".into(), vec![child]); + cache.insert(id_str(1), vec![child]); // Not in expanded set → children hidden let rows = flatten_tree(&[parent], &cache, &Default::default(), 0); assert_eq!(rows.len(), 1); @@ -245,12 +252,12 @@ mod tests { #[test] fn expanded_node_shows_children() { - let parent = lib("p", "Parent", None); - let child = lib("c", "Child", Some("p")); + let parent = test_lib(1, "Parent", None); + let child = test_lib(2, "Child", Some(1)); let mut cache = HashMap::new(); - cache.insert("p".into(), vec![child]); + cache.insert(id_str(1), vec![child]); let mut expanded = HashSet::new(); - expanded.insert("p".into()); + expanded.insert(id_str(1)); let rows = flatten_tree(&[parent], &cache, &expanded, 0); assert_eq!(rows.len(), 2); @@ -262,9 +269,9 @@ mod tests { #[test] fn loaded_empty_children_marks_leaf() { - let node = lib("n", "Node", None); + let node = test_lib(1, "Node", None); let mut cache = HashMap::new(); - cache.insert("n".into(), vec![]); // explicitly empty + cache.insert(id_str(1), vec![]); // explicitly empty let rows = flatten_tree(&[node], &cache, &Default::default(), 0); assert_eq!(rows.len(), 1); assert!(!rows[0].may_have_children); @@ -272,17 +279,17 @@ mod tests { #[test] fn multi_level_nesting() { - let root = lib("r", "Root", None); - let mid = lib("m", "Mid", Some("r")); - let leaf = lib("l", "Leaf", Some("m")); + let root = test_lib(1, "Root", None); + let mid = test_lib(2, "Mid", Some(1)); + let leaf = test_lib(3, "Leaf", Some(2)); let mut cache = HashMap::new(); - cache.insert("r".into(), vec![mid]); - cache.insert("m".into(), vec![leaf]); + cache.insert(id_str(1), vec![mid]); + cache.insert(id_str(2), vec![leaf]); let mut expanded = HashSet::new(); - expanded.insert("r".into()); - expanded.insert("m".into()); + expanded.insert(id_str(1)); + expanded.insert(id_str(2)); let rows = flatten_tree(&[root], &cache, &expanded, 0); assert_eq!(rows.len(), 3); @@ -294,8 +301,8 @@ mod tests { #[test] fn multiple_roots_ordered() { - let a = lib("a", "Alpha", None); - let b = lib("b", "Beta", None); + let a = test_lib(1, "Alpha", None); + let b = test_lib(2, "Beta", None); let rows = flatten_tree(&[a, b], &Default::default(), &Default::default(), 0); assert_eq!(rows.len(), 2); assert_eq!(rows[0].name, "Alpha"); @@ -305,15 +312,16 @@ mod tests { #[test] fn only_expanded_subtrees_included() { // Parent expanded, but sibling collapsed - let p = lib("p", "P", None); - let s = lib("s", "S", None); // sibling root, also collapsed - let c = lib("c", "C", Some("p")); + let p = test_lib(1, "P", None); + let s = test_lib(2, "S", None); // sibling root, also collapsed + let c = test_lib(3, "C", Some(1)); + let sc = test_lib(4, "SC", Some(2)); let mut cache = HashMap::new(); - cache.insert("p".into(), vec![c]); - cache.insert("s".into(), vec![lib("sc", "SC", Some("s"))]); + cache.insert(id_str(1), vec![c]); + cache.insert(id_str(2), vec![sc]); let mut expanded = HashSet::new(); - expanded.insert("p".into()); // expand only P + expanded.insert(id_str(1)); // expand only P let rows = flatten_tree(&[p, s], &cache, &expanded, 0); assert_eq!(rows.len(), 3); // P, C, S (SC hidden) diff --git a/src/src/main.rs b/src/src/main.rs index cdea23c..8e09d94 100644 --- a/src/src/main.rs +++ b/src/src/main.rs @@ -2,7 +2,6 @@ mod command_bar; mod commands; mod lib_tab; mod lib_tree; -mod models; mod mode; mod pdf_viewer; mod pub_detail; @@ -287,16 +286,14 @@ fn key_from_parts(key_str: &str, ctrl: bool, shift: bool, alt: bool, meta: bool) Some(Key { code, ctrl, shift, alt, meta }) } -// ── Root component ───────────────────────────────────────────────────────────── +// ── Tab provider ─────────────────────────────────────────────────────────────── -#[component] -fn App() -> impl IntoView { - provide_keymap(); - provide_theme(); - let mode = provide_mode(); - provide_search_query(); - - // ── Tab state ───────────────────────────────────────────────────────────── +/// Set up tab state and related contexts, following the same pattern as +/// `provide_keymap()` and `provide_theme()`. +/// +/// Provides: [`OpenPdfContext`], [`ReloadTrigger`]. +/// Returns: `(tabs, active_tab)` signals for the view layer. +fn provide_tabs() -> (RwSignal>, RwSignal) { // The Library tab is always present at index 0 and cannot be closed. let tabs = RwSignal::new(vec![AppTab::Library]); let active_tab = RwSignal::new(0usize); @@ -318,17 +315,11 @@ fn App() -> impl IntoView { } }); - // ── Keymap wiring ───────────────────────────────────────────────────────── + // Tab keymap effects (cycling and closing). let keymap_action = use_context::().unwrap().0; Effect::new(move |_| { let Some(ev) = keymap_action.get() else { return }; match ev.name.as_str() { - // Mode switching - actions::MODE_COMMAND => mode.set(AppMode::Command), - actions::MODE_SEARCH => mode.set(AppMode::Search), - actions::MODE_NORMAL => mode.set(AppMode::Normal), - - // Tab cycling actions::TAB_NEXT => { let len = tabs.with_untracked(Vec::len); if len > 1 { @@ -341,8 +332,7 @@ fn App() -> impl IntoView { active_tab.update(|i| *i = if *i == 0 { len - 1 } else { *i - 1 }); } } - - // Close the active tab (Library tab is immortal) + // Close the active tab (Library tab is immortal). actions::TAB_CLOSE => { let idx = active_tab.get_untracked(); if idx > 0 { @@ -350,13 +340,12 @@ fn App() -> impl IntoView { active_tab.update(|i| *i = i.saturating_sub(1)); } } - _ => {} } }); - // ── Open-PDF request handler ─────────────────────────────────────────────── - // Watches for requests from LibTab and opens or activates the PDF tab. + // Open-PDF request handler: watches for requests from LibTab and opens or + // activates the appropriate PDF tab. Effect::new(move |_| { let Some(req) = open_pdf_req.get() else { return }; @@ -381,6 +370,31 @@ fn App() -> impl IntoView { open_pdf_req.set(None); }); + (tabs, active_tab) +} + +// ── Root component ───────────────────────────────────────────────────────────── + +#[component] +fn App() -> impl IntoView { + provide_keymap(); + provide_theme(); + let mode = provide_mode(); + provide_search_query(); + let (tabs, active_tab) = provide_tabs(); + + // Mode-switching keymap effect (tab actions are handled inside provide_tabs). + let keymap_action = use_context::().unwrap().0; + Effect::new(move |_| { + let Some(ev) = keymap_action.get() else { return }; + match ev.name.as_str() { + actions::MODE_COMMAND => mode.set(AppMode::Command), + actions::MODE_SEARCH => mode.set(AppMode::Search), + actions::MODE_NORMAL => mode.set(AppMode::Normal), + _ => {} + } + }); + // ── View ────────────────────────────────────────────────────────────────── view! {
@@ -442,7 +456,8 @@ fn App() -> impl IntoView { #[cfg(test)] mod tests { - use super::action_key_to_name; + use super::{action_key_to_name, key_from_parts}; + use brittle_keymap::{Key, KeyCode}; #[test] fn single_segment_unchanged() { @@ -474,4 +489,80 @@ mod tests { assert_eq!(action_key_to_name("mode_command"), "mode.command"); assert_eq!(action_key_to_name("mode_normal"), "mode.normal"); } + + // ── key_from_parts ─────────────────────────────────────────────────────── + + #[test] + fn char_key_basic() { + let k = key_from_parts("g", false, false, false, false).unwrap(); + assert_eq!(k, Key { code: KeyCode::Char('g'), ctrl: false, shift: false, alt: false, meta: false }); + } + + #[test] + fn char_key_shift_suppressed() { + // 'G' already encodes the shift; shift flag must be suppressed to avoid + let k = key_from_parts("G", false, true, false, false).unwrap(); + assert_eq!(k.code, KeyCode::Char('G')); + assert!(!k.shift, "shift must be false for Char keys"); + } + + #[test] + fn char_key_colon_shift_suppressed() { + // ':' is the shifted ';' on many keyboards + let k = key_from_parts(":", false, true, false, false).unwrap(); + assert_eq!(k.code, KeyCode::Char(':')); + assert!(!k.shift); + } + + #[test] + fn char_key_ctrl_preserved() { + let k = key_from_parts("a", true, false, false, false).unwrap(); + assert_eq!(k.code, KeyCode::Char('a')); + assert!(k.ctrl); + assert!(!k.shift); + } + + #[test] + fn special_key_shift_preserved() { + let k = key_from_parts("Tab", false, true, false, false).unwrap(); + assert_eq!(k.code, KeyCode::Tab); + assert!(k.shift, "shift must be preserved for special keys like Tab"); + } + + #[test] + fn arrow_keys_shift_preserved() { + let k = key_from_parts("ArrowUp", false, true, false, false).unwrap(); + assert_eq!(k.code, KeyCode::ArrowUp); + assert!(k.shift); + + let k = key_from_parts("ArrowDown", false, true, false, false).unwrap(); + assert_eq!(k.code, KeyCode::ArrowDown); + assert!(k.shift); + } + + #[test] + fn enter_escape_backspace() { + assert_eq!(key_from_parts("Enter", false, false, false, false).unwrap().code, KeyCode::Enter); + assert_eq!(key_from_parts("Escape", false, false, false, false).unwrap().code, KeyCode::Escape); + assert_eq!(key_from_parts("Backspace", false, false, false, false).unwrap().code, KeyCode::Backspace); + } + + #[test] + fn function_keys() { + assert_eq!(key_from_parts("F1", false, false, false, false).unwrap().code, KeyCode::F(1)); + assert_eq!(key_from_parts("F12", false, false, false, false).unwrap().code, KeyCode::F(12)); + } + + #[test] + fn unknown_key_returns_none() { + assert!(key_from_parts("Dead", false, false, false, false).is_none()); + assert!(key_from_parts("Unidentified", false, false, false, false).is_none()); + assert!(key_from_parts("AudioVolumeUp", false, false, false, false).is_none()); + } + + #[test] + fn space_key() { + let k = key_from_parts(" ", false, false, false, false).unwrap(); + assert_eq!(k.code, KeyCode::Space); + } } diff --git a/src/src/models.rs b/src/src/models.rs deleted file mode 100644 index 0a0f6b5..0000000 --- a/src/src/models.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Frontend mirror of `brittle-core` data types. -//! -//! These types are intentionally minimal — they carry only the fields the -//! frontend needs. Unknown fields coming from the Tauri IPC are silently -//! ignored by serde's default behaviour. - -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -// ── IDs ─────────────────────────────────────────────────────────────────────── - -/// A library identifier (UUID string). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] -#[serde(transparent)] -pub struct LibraryId(pub String); - -/// A reference identifier (UUID string). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] -#[serde(transparent)] -pub struct ReferenceId(pub String); - -// ── Supporting types ────────────────────────────────────────────────────────── - -/// A person (author, editor, etc.). -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct Person { - pub family: String, - pub given: Option, - pub prefix: Option, - pub suffix: Option, -} - -impl Person { - /// "Given Family" or just "Family" when given is absent. - pub fn display_name(&self) -> String { - match &self.given { - Some(g) => format!("{} {}", g, self.family), - None => self.family.clone(), - } - } -} - -/// BibTeX entry type. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum EntryType { - Article, - Book, - Booklet, - InBook, - InCollection, - InProceedings, - Manual, - MastersThesis, - Misc, - PhdThesis, - Proceedings, - TechReport, - Unpublished, - Online, -} - -impl std::fmt::Display for EntryType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - EntryType::Article => "article", - EntryType::Book => "book", - EntryType::Booklet => "booklet", - EntryType::InBook => "inbook", - EntryType::InCollection => "incollection", - EntryType::InProceedings => "inproceedings", - EntryType::Manual => "manual", - EntryType::MastersThesis => "mastersthesis", - EntryType::Misc => "misc", - EntryType::PhdThesis => "phdthesis", - EntryType::Proceedings => "proceedings", - EntryType::TechReport => "techreport", - EntryType::Unpublished => "unpublished", - EntryType::Online => "online", - }; - write!(f, "{s}") - } -} - -// ── Library ─────────────────────────────────────────────────────────────────── - -/// A library node in the tree (subset of `brittle_core::Library`). -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct Library { - pub id: LibraryId, - pub name: String, - pub parent_id: Option, -} - -// ── References ──────────────────────────────────────────────────────────────── - -/// A lightweight reference record used in the publication list. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct ReferenceSummary { - pub id: ReferenceId, - pub cite_key: String, - pub entry_type: EntryType, - pub title: Option, - pub authors: Vec, - pub year: Option, -} - -impl ReferenceSummary { - /// Title or "[no title]" fallback. - pub fn title_display(&self) -> &str { - self.title.as_deref().unwrap_or("[no title]") - } - - /// Compact author string: "Family", "A & B", or "A et al." - pub fn author_display(&self) -> String { - match self.authors.len() { - 0 => "—".into(), - 1 => self.authors[0].family.clone(), - 2 => format!("{} & {}", self.authors[0].family, self.authors[1].family), - _ => format!("{} et al.", self.authors[0].family), - } - } -} - -/// Full reference record used in the detail pane. -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Reference { - pub id: ReferenceId, - pub cite_key: String, - pub entry_type: EntryType, - pub authors: Vec, - pub editors: Vec, - /// BibTeX fields: title, year, journal, doi, abstract, … - pub fields: BTreeMap, - pub created_at: String, - pub modified_at: String, -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::from_str; - - #[test] - fn library_id_transparent() { - let id: LibraryId = from_str(r#""abc-123""#).unwrap(); - assert_eq!(id.0, "abc-123"); - - // Also verify round-trips back to a plain string. - let back = serde_json::to_string(&id).unwrap(); - assert_eq!(back, r#""abc-123""#); - } - - #[test] - fn library_ignores_extra_fields() { - // brittle-core sends members, timestamps, etc. — we ignore them. - let json = r#"{ - "id": "lib-1", - "name": "Physics", - "parent_id": null, - "members": ["ref-x"], - "created_at": "2024-01-01T00:00:00Z", - "modified_at": "2024-01-01T00:00:00Z" - }"#; - let lib: Library = from_str(json).unwrap(); - assert_eq!(lib.id.0, "lib-1"); - assert_eq!(lib.name, "Physics"); - assert_eq!(lib.parent_id, None); - } - - #[test] - fn library_with_parent() { - let json = r#"{"id": "child-1", "name": "QM", "parent_id": "lib-1"}"#; - let lib: Library = from_str(json).unwrap(); - assert_eq!(lib.parent_id, Some(LibraryId("lib-1".into()))); - } - - #[test] - fn reference_summary_fields() { - let json = r#"{ - "id": "ref-1", - "cite_key": "einstein1905", - "entry_type": "article", - "title": "On the Electrodynamics of Moving Bodies", - "authors": [{"family": "Einstein", "given": "Albert", "prefix": null, "suffix": null}], - "year": "1905" - }"#; - let rs: ReferenceSummary = from_str(json).unwrap(); - assert_eq!(rs.cite_key, "einstein1905"); - assert_eq!(rs.title_display(), "On the Electrodynamics of Moving Bodies"); - assert_eq!(rs.author_display(), "Einstein"); - assert_eq!(rs.year.as_deref(), Some("1905")); - } - - #[test] - fn reference_summary_no_title() { - let rs = ReferenceSummary { - id: ReferenceId("x".into()), - cite_key: "x".into(), - entry_type: EntryType::Misc, - title: None, - authors: vec![], - year: None, - }; - assert_eq!(rs.title_display(), "[no title]"); - assert_eq!(rs.author_display(), "—"); - } - - #[test] - fn author_display_variants() { - fn p(family: &str) -> Person { - Person { family: family.into(), given: None, prefix: None, suffix: None } - } - let base = ReferenceSummary { - id: ReferenceId("x".into()), - cite_key: "x".into(), - entry_type: EntryType::Article, - title: None, - authors: vec![], - year: None, - }; - let one = ReferenceSummary { authors: vec![p("Smith")], ..base.clone() }; - assert_eq!(one.author_display(), "Smith"); - - let two = ReferenceSummary { authors: vec![p("Smith"), p("Jones")], ..base.clone() }; - assert_eq!(two.author_display(), "Smith & Jones"); - - let many = ReferenceSummary { - authors: vec![p("Smith"), p("Jones"), p("Brown")], - ..base - }; - assert_eq!(many.author_display(), "Smith et al."); - } - - #[test] - fn person_display_name() { - let p = Person { - family: "Turing".into(), - given: Some("Alan".into()), - prefix: None, - suffix: None, - }; - assert_eq!(p.display_name(), "Alan Turing"); - - let p2 = Person { given: None, ..p }; - assert_eq!(p2.display_name(), "Turing"); - } - - #[test] - fn entry_type_display() { - assert_eq!(EntryType::Article.to_string(), "article"); - assert_eq!(EntryType::InProceedings.to_string(), "inproceedings"); - } -} diff --git a/src/src/pub_detail.rs b/src/src/pub_detail.rs index aacc706..ebc0a54 100644 --- a/src/src/pub_detail.rs +++ b/src/src/pub_detail.rs @@ -2,7 +2,7 @@ use leptos::prelude::*; -use crate::models::Reference; +use brittle_model::Reference; /// Right pane: displays the fields of the currently selected reference. /// @@ -78,7 +78,7 @@ pub fn PubDetail(reference: RwSignal>) -> impl IntoView { }

- "Modified: "{r.modified_at.clone()} + "Modified: "{r.modified_at.format("%Y-%m-%d %H:%M UTC").to_string()}

}), diff --git a/src/src/pub_list.rs b/src/src/pub_list.rs index 7c68e47..0e2b471 100644 --- a/src/src/pub_list.rs +++ b/src/src/pub_list.rs @@ -2,7 +2,9 @@ use leptos::prelude::*; -use crate::{lib_tab::Pane, models::ReferenceSummary}; +use brittle_model::ReferenceSummary; + +use crate::lib_tab::Pane; /// Centre pane: a scrollable list of publication summaries. /// @@ -37,7 +39,7 @@ pub fn PubList( let authors = item.author_display(); let year = item.year.clone().unwrap_or_default(); let kind = item.entry_type.to_string(); - let ref_id = item.id.0.clone(); + let ref_id = item.id.to_string(); let cite_key = item.cite_key.clone(); let ref_id_for_dblclick = ref_id.clone(); view! { diff --git a/src/src/tauri.rs b/src/src/tauri.rs index ea810a3..cade31c 100644 --- a/src/src/tauri.rs +++ b/src/src/tauri.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use serde::Serialize; -use crate::models::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary}; +use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary}; // ── Low-level invoke ─────────────────────────────────────────────────────────── diff --git a/tools/brittle-seed/Cargo.toml b/tools/brittle-seed/Cargo.toml new file mode 100644 index 0000000..0f8d377 --- /dev/null +++ b/tools/brittle-seed/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "brittle-seed" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "brittle-seed" +path = "src/main.rs" + +[dependencies] +brittle-core = { path = "../../brittle-core" } +ureq = "2" diff --git a/brittle-core/src/bin/seed.rs b/tools/brittle-seed/src/main.rs similarity index 100% rename from brittle-core/src/bin/seed.rs rename to tools/brittle-seed/src/main.rs