Files
cumulus/src/model/document.rs
T
skkeyeandClaude Opus 5.5 69aa7eee58 Add the data model and the document file format
The model holds the rules: a document is a tree under one root, with
loose nodes beside it and cross-links between any two nodes. Every change
runs as a transaction that rolls back on error and returns an undo step
(the old and new values of whatever it touched), so undo and redo need no
code of their own per command. Text from outside (titles, tags, names) is
checked once, when it becomes a typed value. Folding (Open, Auto, Closed,
plus focus) is one function that every view will share.

storage::document_file reads and writes `.cum` files in RON, with
multi-line bodies kept readable, `\n` line endings on every OS, and errors
that name the node they're about. The tests cover the tree rules, 500
random edits undone and redone step by step, folding, and the file format
(the example from DESIGN.md is written back character for character).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 00:54:49 -04:00

493 lines
17 KiB
Rust

//! A document: a tree of nodes under one root, loose nodes beside it, and
//! cross-links between any of them.
use std::collections::BTreeMap;
use thiserror::Error;
use super::geometry::CanvasPos;
use super::ids::{DocUid, NodeId};
use super::link::{Link, LinkKey};
use super::look::LineLook;
use super::node::{Content, Node};
use super::portal::{Backlink, Portal};
use super::text::Title;
/// Where a node sits in its document's tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Place {
/// The document's main topic. There is exactly one, and its title is the
/// document's name.
Root,
/// A top-level idea beside the root's tree, not attached to anything yet.
Loose,
/// Attached under another node.
Child {
/// The node above this one.
parent: NodeId,
/// How the line from the parent is drawn.
branch: LineLook,
},
}
/// A node, with where it sits in the tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Entry {
pub(super) node: Node,
pub(super) place: Place,
/// Its children, in order.
pub(super) children: Vec<NodeId>,
}
/// One brainstorm: a tree of nodes under a single root, loose nodes beside
/// it, cross-links between any two nodes, and the backlinks of portals in
/// other documents that lead here.
///
/// The fields are private and every change goes through
/// [`Document::edit`](Document::edit), so these rules always hold:
/// - there is exactly one root, and it can't be removed or given a parent;
/// - a node has at most one parent, and there are no cycles;
/// - a link joins two different nodes that both exist, at most one link per
/// pair.
#[derive(Debug, Clone)]
pub struct Document {
uid: DocUid,
root: NodeId,
pub(super) entries: BTreeMap<NodeId, Entry>,
/// The loose nodes, in order.
pub(super) loose: Vec<NodeId>,
pub(super) links: BTreeMap<LinkKey, Link>,
backlinks: Vec<Backlink>,
/// The number the next new node gets. It only ever grows, so ids aren't
/// handed out twice, not even after an undo.
pub(super) next_id: u32,
}
impl PartialEq for Document {
/// Two documents are equal when they hold the same things. `next_id` is
/// left out: it only says which number comes next.
fn eq(&self, other: &Document) -> bool {
self.uid == other.uid
&& self.root == other.root
&& self.entries == other.entries
&& self.loose == other.loose
&& self.links == other.links
&& self.backlinks == other.backlinks
}
}
impl Eq for Document {}
/// A node as read from a file: the node, plus where the file says it goes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeRecord {
pub id: NodeId,
pub parent: Option<NodeId>,
/// How the line from the parent is drawn (ignored without a parent).
pub branch: LineLook,
pub node: Node,
}
/// Why the nodes and links read from a file don't make a valid document.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StructureError {
#[error("node {0} appears twice")]
DuplicateNode(NodeId),
#[error("the root (node {0}) isn't in the file")]
MissingRoot(NodeId),
#[error("the root (node {0}) can't have a parent")]
RootWithParent(NodeId),
#[error("node {child} has parent {parent}, which isn't in the file")]
UnknownParent { child: NodeId, parent: NodeId },
#[error("node {0} is inside its own branch")]
Cycle(NodeId),
#[error("node ids must be smaller than {}", u32::MAX)]
IdTooLarge,
#[error("a link points at node {0}, which isn't in the file")]
UnknownLinkEnd(NodeId),
#[error("nodes {0} and {1} are linked twice")]
DuplicateLink(NodeId, NodeId),
}
impl Document {
/// A new document with just its root, at the middle of the canvas.
pub fn new(title: Title) -> Document {
Document::with_uid(title, DocUid::random())
}
/// A new document with just its root, and a uid chosen by the caller.
pub fn with_uid(title: Title, uid: DocUid) -> Document {
let root = NodeId::new(1);
let entry =
Entry { node: Node::idea(title, CanvasPos::default()), place: Place::Root, children: Vec::new() };
Document {
uid,
root,
entries: BTreeMap::from([(root, entry)]),
loose: Vec::new(),
links: BTreeMap::new(),
backlinks: Vec::new(),
next_id: 2,
}
}
/// Builds a document from what a file holds, checking every rule.
/// Siblings keep the order they have in `records`.
pub fn from_records(
uid: DocUid,
root: NodeId,
records: Vec<NodeRecord>,
links: Vec<Link>,
backlinks: Vec<Backlink>,
) -> Result<Document, StructureError> {
let mut entries = BTreeMap::new();
let mut order = Vec::with_capacity(records.len());
for record in records {
if record.id.get() == u32::MAX {
return Err(StructureError::IdTooLarge);
}
let place = match record.parent {
Some(_) if record.id == root => return Err(StructureError::RootWithParent(root)),
Some(parent) => Place::Child { parent, branch: record.branch },
None if record.id == root => Place::Root,
None => Place::Loose,
};
order.push((record.id, record.parent));
let entry = Entry { node: record.node, place, children: Vec::new() };
if entries.insert(record.id, entry).is_some() {
return Err(StructureError::DuplicateNode(record.id));
}
}
if !entries.contains_key(&root) {
return Err(StructureError::MissingRoot(root));
}
let mut loose = Vec::new();
for (id, parent) in order {
match parent {
Some(parent) => match entries.get_mut(&parent) {
Some(entry) => entry.children.push(id),
None => return Err(StructureError::UnknownParent { child: id, parent }),
},
None if id != root => loose.push(id),
None => {}
}
}
// Climbing from any node must reach the top level. A node that keeps
// climbing for longer than there are nodes is going round in a cycle.
for &id in entries.keys() {
let mut current = id;
for steps in 0.. {
match entries.get(&current).map(|entry| &entry.place) {
Some(Place::Child { parent, .. }) => current = *parent,
_ => break,
}
if steps > entries.len() {
return Err(StructureError::Cycle(id));
}
}
}
let mut link_map = BTreeMap::new();
for link in links {
for end in [link.start(), link.end()] {
if !entries.contains_key(&end) {
return Err(StructureError::UnknownLinkEnd(end));
}
}
let key = link.key();
if link_map.insert(key, link).is_some() {
let (a, b) = key.nodes();
return Err(StructureError::DuplicateLink(a, b));
}
}
// Ids are below u32::MAX (checked above), so this can't overflow.
let next_id = entries.keys().next_back().map_or(1, |id| id.get() + 1);
Ok(Document { uid, root, entries, loose, links: link_map, backlinks, next_id })
}
/// The document's uid, which stays the same when its file is renamed.
pub fn uid(&self) -> DocUid {
self.uid
}
/// The root: the document's main topic.
pub fn root(&self) -> NodeId {
self.root
}
/// The document's name: its root's title.
pub fn title(&self) -> &Title {
// The root can't be removed (see `Edit::remove_branch`), so it's
// always there.
self.entries[&self.root].node.title()
}
/// How many nodes the document has, the root included.
pub fn node_count(&self) -> usize {
self.entries.len()
}
/// Whether the document has a node with this id.
pub fn contains(&self, id: NodeId) -> bool {
self.entries.contains_key(&id)
}
/// A node, if it exists.
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.entries.get(&id).map(|entry| &entry.node)
}
/// Where a node sits in the tree.
pub fn place(&self, id: NodeId) -> Option<&Place> {
self.entries.get(&id).map(|entry| &entry.place)
}
/// A node's parent (none for the root and loose nodes).
pub fn parent(&self, id: NodeId) -> Option<NodeId> {
match self.place(id)? {
Place::Child { parent, .. } => Some(*parent),
Place::Root | Place::Loose => None,
}
}
/// A node's children, in order (empty for an unknown id).
pub fn children(&self, id: NodeId) -> &[NodeId] {
self.entries.get(&id).map_or(&[], |entry| &entry.children)
}
/// The loose nodes, in order.
pub fn loose(&self) -> &[NodeId] {
&self.loose
}
/// The top level: the root, then the loose nodes.
pub fn top_level(&self) -> impl Iterator<Item = NodeId> + '_ {
std::iter::once(self.root).chain(self.loose.iter().copied())
}
/// The node after `id` among its siblings. For the root, that's the first
/// loose node: the top level counts as one row of siblings.
pub fn next_sibling(&self, id: NodeId) -> Option<NodeId> {
let (siblings, index) = self.siblings(id)?;
siblings.get(index + 1).copied()
}
/// The node before `id` among its siblings.
pub fn previous_sibling(&self, id: NodeId) -> Option<NodeId> {
let (siblings, index) = self.siblings(id)?;
siblings.get(index.checked_sub(1)?).copied()
}
/// The row of siblings `id` is in, and its index there.
fn siblings(&self, id: NodeId) -> Option<(Vec<NodeId>, usize)> {
let siblings: Vec<NodeId> = match self.place(id)? {
Place::Child { parent, .. } => self.children(*parent).to_vec(),
Place::Root | Place::Loose => self.top_level().collect(),
};
let index = siblings.iter().position(|&sibling| sibling == id)?;
Some((siblings, index))
}
/// The nodes above `id`: its parent, grandparent and so on up to the top.
pub fn ancestors(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
std::iter::successors(self.parent(id), |&node| self.parent(node))
}
/// Whether `id` is somewhere below `branch` (not `branch` itself).
pub fn is_inside(&self, id: NodeId, branch: NodeId) -> bool {
self.ancestors(id).any(|ancestor| ancestor == branch)
}
/// How deep a node is: 0 at the top level, 1 for their children…
pub fn depth(&self, id: NodeId) -> usize {
self.ancestors(id).count()
}
/// Every node below `id`, in reading order (each node before its
/// children).
pub fn descendants(&self, id: NodeId) -> Vec<NodeId> {
let mut found = Vec::new();
let mut to_visit: Vec<NodeId> = self.children(id).iter().rev().copied().collect();
while let Some(node) = to_visit.pop() {
found.push(node);
to_visit.extend(self.children(node).iter().rev());
}
found
}
/// `id` and every node below it, in reading order (empty for an unknown
/// id).
pub fn branch(&self, id: NodeId) -> Vec<NodeId> {
if !self.contains(id) {
return Vec::new();
}
let mut branch = vec![id];
branch.extend(self.descendants(id));
branch
}
/// Every node in reading order: the root's branch, then each loose node's
/// branch. Files list their nodes in this order.
pub fn outline(&self) -> Vec<NodeId> {
self.top_level().flat_map(|top| self.branch(top)).collect()
}
/// Every cross-link, in the order of their keys.
pub fn links(&self) -> impl Iterator<Item = &Link> {
self.links.values()
}
/// The link between two nodes, if there is one.
pub fn link(&self, key: LinkKey) -> Option<&Link> {
self.links.get(&key)
}
/// The cross-links touching `id`, whichever way they point.
pub fn links_of(&self, id: NodeId) -> impl Iterator<Item = &Link> + '_ {
self.links.values().filter(move |link| link.key().touches(id))
}
/// The portals in other documents that lead here.
pub fn backlinks(&self) -> &[Backlink] {
&self.backlinks
}
/// Replaces the backlinks. They're bookkeeping, kept in step with other
/// documents when they're saved, so this isn't an undoable edit.
pub fn set_backlinks(&mut self, backlinks: Vec<Backlink>) {
self.backlinks = backlinks;
}
/// The portals in this document, with the nodes they sit on.
pub fn portals(&self) -> impl Iterator<Item = (NodeId, &Portal)> + '_ {
self.entries.iter().filter_map(|(id, entry)| match &entry.node.content {
Content::Portal(portal) => Some((*id, portal)),
Content::Idea(_) => None,
})
}
}
#[cfg(test)]
impl Document {
/// Panics if one of the document's rules is broken. Tests call this
/// after edits.
pub(crate) fn assert_rules(&self) {
let roots: Vec<_> =
self.entries.iter().filter(|(_, e)| e.place == Place::Root).map(|(id, _)| *id).collect();
assert_eq!(roots, vec![self.root], "exactly one root");
let loose: Vec<_> =
self.entries.iter().filter(|(_, e)| e.place == Place::Loose).map(|(id, _)| *id).collect();
let mut listed = self.loose.clone();
listed.sort();
assert_eq!(listed, loose, "the loose list matches the loose nodes");
for (id, entry) in &self.entries {
if let Place::Child { parent, .. } = &entry.place {
let siblings = self.children(*parent);
assert_eq!(
siblings.iter().filter(|&&s| s == *id).count(),
1,
"{id} is listed once by its parent"
);
}
for child in &entry.children {
assert_eq!(self.parent(*child), Some(*id), "{child} knows its parent {id}");
}
assert!(id.get() < self.next_id, "{id} is below next_id");
}
let mut outline = self.outline();
assert_eq!(outline.len(), self.entries.len(), "every node is reachable once (no cycles)");
outline.sort();
outline.dedup();
assert_eq!(outline.len(), self.entries.len(), "no node is reached twice");
for (key, link) in &self.links {
assert_eq!(*key, link.key());
assert!(self.contains(link.start()) && self.contains(link.end()), "link ends exist");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::{garden, idea, title};
fn record(id: u32, parent: Option<u32>, name: &str) -> NodeRecord {
NodeRecord {
id: NodeId::new(id),
parent: parent.map(NodeId::new),
branch: LineLook::default(),
node: idea(name),
}
}
fn build(records: Vec<NodeRecord>) -> Result<Document, StructureError> {
Document::from_records(
DocUid::parse("0000000000000001").unwrap(),
NodeId::new(1),
records,
vec![],
vec![],
)
}
#[test]
fn records_become_a_tree_in_file_order() {
let doc = build(vec![
record(1, None, "root"),
record(3, Some(1), "b"),
record(2, Some(1), "a"),
record(9, None, "loose"),
record(4, Some(3), "b1"),
])
.unwrap();
doc.assert_rules();
let ids = |raw: &[u32]| raw.iter().map(|&n| NodeId::new(n)).collect::<Vec<_>>();
assert_eq!(doc.children(NodeId::new(1)), ids(&[3, 2]).as_slice());
assert_eq!(doc.loose(), ids(&[9]).as_slice());
assert_eq!(doc.outline(), ids(&[1, 3, 4, 2, 9]));
assert_eq!(doc.title().as_str(), "root");
}
#[test]
fn broken_records_are_refused() {
use StructureError as E;
let n = NodeId::new;
assert_eq!(build(vec![record(2, None, "x")]), Err(E::MissingRoot(n(1))));
assert_eq!(build(vec![record(1, Some(2), "r"), record(2, None, "x")]), Err(E::RootWithParent(n(1))));
assert_eq!(build(vec![record(1, None, "r"), record(1, None, "again")]), Err(E::DuplicateNode(n(1))));
assert_eq!(
build(vec![record(1, None, "r"), record(2, Some(5), "x")]),
Err(E::UnknownParent { child: n(2), parent: n(5) })
);
assert!(matches!(
build(vec![record(1, None, "r"), record(2, Some(3), "x"), record(3, Some(2), "y")]),
Err(E::Cycle(_))
));
}
#[test]
fn siblings_ancestors_and_branches() {
let (doc, g) = garden();
assert_eq!(doc.next_sibling(g.soil), Some(g.beds));
assert_eq!(doc.previous_sibling(g.soil), None);
assert_eq!(
doc.next_sibling(g.root),
Some(g.compost),
"the root's next sibling is the first loose node"
);
assert_eq!(doc.ancestors(g.cedar).collect::<Vec<_>>(), vec![g.beds, g.root]);
assert!(doc.is_inside(g.cedar, g.root) && !doc.is_inside(g.root, g.cedar));
assert_eq!(doc.depth(g.cedar), 2);
assert_eq!(doc.branch(g.beds), vec![g.beds, g.cedar]);
assert_eq!(doc.outline(), vec![g.root, g.soil, g.beds, g.cedar, g.watering, g.compost]);
assert_eq!(Document::new(title("x")).node_count(), 1);
}
}