Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/diamond_link.rs

28.5 KiB, 1 run

created by r2519314175:941, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The target-agnostic core of a link: the record shape, its parse/serialise
2//! pair, and node-reference normalisation.
3//!
4//! A **link** joins one thing to another and says, in a word and a sentence,
5//! how they are related. The things are not only Diamonds: a link may name a
6//! file, a page, a chat or a Diamond, because the substrate is meant to outlive
7//! the first use anyone puts it to, and a link space that can only join Diamonds
8//! to Diamonds would need replacing the first time a Diamond had to point at a
9//! file.
10//!
11//! The OPFS edge that reads and writes the sidecar lives in
12//! [`crate::wasm::diamond`], which is compiled only for wasm32 and so cannot be
13//! reached by the native test suite. What is pure sits here instead, where it
14//! is tested -- the parse in particular, because a link written by a hand or by
15//! an older build must still open.
16
17use crate::llm::{extract_json_number, extract_json_string, json_escape};
18
19use oxedyne_fe2o3_core::prelude::*;
20
21
22/// The most characters a relation may carry; the excess is truncated.
23const MAX_REL_LEN: usize = 32;
24
25/// The most characters a note may carry; the excess is truncated.
26const MAX_NOTE_LEN: usize = 2_000;
27
28/// The node-reference kinds this build knows how to name.
29///
30/// The list is open on purpose: [`Node::parse`] keeps a kind it does not know
31/// rather than rejecting it, so a link written by a later build -- or by a hand
32/// naming something not yet modelled -- survives a round trip through this one
33/// instead of being silently dropped.
34pub const KNOWN_KINDS: [&str; 4] = ["diamond", "file", "url", "chat"];
35
36
37/// One end of a link: a kind and the thing it names, spelled `kind:rest`.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct Node {
40 /// The kind, lowercased. // e.g. `diamond`
41 pub kind: String,
42 /// Whatever the kind uses to name one of its own. // an id, a path, a URL
43 pub rest: String,
44}
45
46impl Node {
47
48 /// Parse a `kind:rest` reference, or nothing if it is not one.
49 ///
50 /// Only the FIRST colon separates, because a `url:` reference carries
51 /// colons of its own and splitting on the last would truncate every link to
52 /// a page. An empty kind or an empty rest is not a reference.
53 pub fn parse(s: &str) -> Option<Self> {
54 let t = s.trim();
55 let (kind, rest) = match t.find(':') {
56 Some(i) => (&t[..i], &t[i + 1..]),
57 None => return None,
58 };
59 if kind.is_empty() || rest.is_empty() {
60 return None;
61 }
62 // A kind is a bare word, so a stray colon in prose cannot read as one.
63 if !kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
64 return None;
65 }
66 Some(Self { kind: kind.to_lowercase(), rest: rest.to_string() })
67 }
68
69 /// Whether this is a kind the current build models.
70 ///
71 /// An unknown kind is not an error -- it is stored, listed and returned
72 /// untouched -- but a caller that must act on a reference can ask first.
73 pub fn is_known(&self) -> bool {
74 KNOWN_KINDS.contains(&self.kind.as_str())
75 }
76
77 /// The canonical `kind:rest` spelling.
78 pub fn to_ref(&self) -> String {
79 fmt!("{}:{}", self.kind, self.rest)
80 }
81}
82
83
84/// One link, as held on a line of a Diamond's `links.jsonl`.
85///
86/// Direction is recorded -- `from` and `to` are not interchangeable -- but
87/// nothing here implies that anything flows along it. A reader that wants
88/// what points AT something scans for it in `to`; that is what makes the
89/// links two-way without a second copy of each one.
90#[derive(Clone, Debug)]
91pub struct Link {
92 /// Stable link id, so a link can be named to delete it.
93 pub id: String,
94 /// Creation time, wall-clock milliseconds.
95 pub ts: u64,
96 /// The end this link is asserted from.
97 pub from: Node,
98 /// The end it points at.
99 pub to: Node,
100 /// What kind of relation this is, as [`normalise_rel`] leaves it.
101 pub rel: String,
102 /// A free sentence about the link, for whatever the relation does not say.
103 pub note: String,
104 /// Who asserted it: `user`, or `agent:<name>`.
105 pub by: String,
106}
107
108impl Link {
109
110 /// Serialise to a compact single-line JSON object.
111 pub fn to_json(&self) -> String {
112 fmt!(
113 "{{\"id\":\"{}\",\"ts\":{},\"from\":\"{}\",\"to\":\"{}\",\
114 \"rel\":\"{}\",\"note\":\"{}\",\"by\":\"{}\"}}",
115 json_escape(&self.id), self.ts,
116 json_escape(&self.from.to_ref()), json_escape(&self.to.to_ref()),
117 json_escape(&self.rel), json_escape(&self.note), json_escape(&self.by),
118 )
119 }
120
121 /// Parse one stored line, or nothing if it does not describe a link.
122 ///
123 /// A line missing either end is not a link and is dropped; everything else
124 /// is tolerated and defaulted, so a record written before a field existed
125 /// still opens rather than taking the whole sidecar down with it.
126 pub fn from_json(s: &str) -> Option<Self> {
127 let from = res_opt(extract_json_string(s, "from"))?;
128 let to = res_opt(extract_json_string(s, "to"))?;
129 Some(Self {
130 id: extract_json_string(s, "id").unwrap_or_default(),
131 ts: extract_json_number(s, "ts").unwrap_or(0),
132 from: Node::parse(&from)?,
133 to: Node::parse(&to)?,
134 rel: normalise_rel(&extract_json_string(s, "rel").unwrap_or_default()),
135 note: extract_json_string(s, "note").unwrap_or_default(),
136 by: extract_json_string(s, "by").unwrap_or_default(),
137 })
138 }
139
140 /// Whether either end of this link names `node`.
141 ///
142 /// This is the whole of what makes the graph two-way: one stored record,
143 /// found from both of its ends.
144 pub fn touches(&self, node: &Node) -> bool {
145 &self.from == node || &self.to == node
146 }
147
148 /// The end that is not `node`, or nothing if `node` is neither end.
149 pub fn other(&self, node: &Node) -> Option<&Node> {
150 if &self.from == node {
151 Some(&self.to)
152 } else if &self.to == node {
153 Some(&self.from)
154 } else {
155 None
156 }
157 }
158}
159
160/// `Option` in the shape the `?` above needs, without introducing `?` on a
161/// `Result`. Kept tiny and local rather than pulled from anywhere.
162fn res_opt(o: Option<String>) -> Option<String> {
163 match o {
164 Some(s) if !s.trim().is_empty() => Some(s),
165 _ => None,
166 }
167}
168
169
170/// Normalise a relation into the form the store holds.
171///
172/// A relation is typed by hand, so nothing about it can be assumed: it is
173/// trimmed, its internal whitespace collapsed to single spaces, lowercased so
174/// `Supersedes` and `supersedes` are one relation rather than two, and capped
175/// at [`MAX_REL_LEN`] characters. An empty relation is allowed and means only
176/// that the link exists.
177///
178/// Nothing here knows any relation by name. Which ones to suggest is the
179/// interface's business alone, exactly as it is for a Diamond's tags.
180pub fn normalise_rel(rel: &str) -> String {
181 let mut out = String::new();
182 for (i, word) in rel.split_whitespace().enumerate() {
183 if i > 0 {
184 out.push(' ');
185 }
186 out.push_str(&word.to_lowercase());
187 }
188 // Cap by characters, not bytes, so a multi-byte relation is never cut
189 // mid-character.
190 out.chars().take(MAX_REL_LEN).collect()
191}
192
193/// Cap a note at [`MAX_NOTE_LEN`] characters, leaving it otherwise as written.
194///
195/// A note is prose and belongs to whoever wrote it, so unlike a relation it is
196/// neither lowercased nor collapsed -- only bounded, so one link cannot grow
197/// until it crowds out the sidecar it lives in.
198pub fn normalise_note(note: &str) -> String {
199 note.trim().chars().take(MAX_NOTE_LEN).collect()
200}
201
202/// Change the relation and note of the link with `id`, keeping everything that
203/// identifies it. True when one was found and something about it moved.
204///
205/// **This is what a delete plus a fresh assertion is not.** Re-asserting an
206/// edited link mints a new [`Link::id`] and a new [`Link::ts`], so the record of
207/// when the relationship was FIRST claimed is lost and every reader that named
208/// the old id is left naming nothing. A relation is a judgement about two
209/// things that were already joined, and refining the judgement does not make it
210/// a different joining, so the id and the timestamp are held and only what was
211/// actually revised is written.
212///
213/// The ends are not editable here, and deliberately: changing `from` or `to`
214/// makes it a link between two other things, which is a new claim and should
215/// cost a new record.
216///
217/// Nothing false is returned as a success. An unchanged relation and note write
218/// nothing, which is what stops the surface stamping the Diamond -- and the
219/// stamp is what the merge reads, so a no-op that stamped would make one device
220/// fresher than the other for having done nothing (see
221/// [`crate::wasm::diamond::update_link`]).
222///
223/// # Arguments
224/// * `links` - The owner's sidecar, edited in place.
225/// * `id` - The link to revise.
226/// * `rel` - The new relation, normalised as [`normalise_rel`] leaves it.
227/// * `note` - The new note, bounded as [`normalise_note`] leaves it.
228pub fn update_link_in(links: &mut [Link], id: &str, rel: &str, note: &str) -> bool {
229 // A blank id names no link. Matching one would revise every hand-written
230 // record in the sidecar at once, since that is exactly what those carry.
231 if id.trim().is_empty() {
232 return false;
233 }
234 let (rel, note) = (normalise_rel(rel), normalise_note(note));
235 for l in links.iter_mut() {
236 if l.id != id {
237 continue;
238 }
239 if l.rel == rel && l.note == note {
240 return false;
241 }
242 l.rel = rel;
243 l.note = note;
244 return true;
245 }
246 false
247}
248
249/// Parse a whole sidecar, skipping blank and unreadable lines.
250///
251/// A line that will not parse is dropped rather than failing the read: a
252/// sidecar is hand-editable by design, and one fat-fingered line must not make
253/// every other link in the file disappear.
254pub fn parse_links(text: &str) -> Vec<Link> {
255 text.lines()
256 .map(|l| l.trim())
257 .filter(|l| !l.is_empty())
258 .filter_map(Link::from_json)
259 .collect()
260}
261
262/// Serialise links back to sidecar text, one per line.
263pub fn write_links(links: &[Link]) -> String {
264 let mut s = String::new();
265 for l in links {
266 s.push_str(&l.to_json());
267 s.push('\n');
268 }
269 s
270}
271
272/// Rewrite the node kind an earlier build wrote.
273///
274/// A sidecar written before the rename names its ends `facet:<id>` -- what a
275/// Diamond was called when the link substrate shipped. An unknown kind is kept
276/// rather than refused, so those links survive, but they survive pointing at a
277/// kind nothing resolves any more. The substitution is on the quoted prefix,
278/// so a note that merely mentions the word is left alone.
279pub fn fix_legacy_kinds(text: &str) -> String {
280 text.replace("\"facet:", "\"diamond:")
281}
282
283/// What identifies a link when two copies of one sidecar are reconciled.
284///
285/// The id, which is assigned once and travels with the record. Two devices
286/// that each asserted "the same" link asserted two links, with two ids, and
287/// both are kept -- deciding that they meant one is a judgement about content,
288/// and nothing here is entitled to make it. A record written by a hand or by a
289/// build that assigned no id has only what it says, so that is what it is keyed
290/// by; the two forms are prefixed apart so neither can be mistaken for the
291/// other.
292fn identity(l: &Link) -> String {
293 let id = l.id.trim();
294 if id.is_empty() {
295 fmt!("k:{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
296 l.from.to_ref(), l.to.to_ref(), l.rel, l.note, l.by)
297 } else {
298 fmt!("i:{}", id)
299 }
300}
301
302/// Add to `mine` every link of `theirs` it does not already hold, or nothing at
303/// all when it holds them all.
304///
305/// This is what lets two stores whose sidecars disagree come back together. It
306/// is deliberately not a merge of link CONTENT: a link is not edited in place,
307/// so two records sharing an id are the same record, and the copy already here
308/// is kept rather than compared field by field.
309///
310/// Returning nothing when there is nothing to add is the whole of why it
311/// settles. The caller writes only when it is handed a list, and writing is
312/// what stamps the Diamond; a union that wrote unconditionally would stamp each
313/// side fresher than the other for ever.
314pub fn union_links(mine: &[Link], theirs: &[Link]) -> Option<Vec<Link>> {
315 // A linear scan, because a sidecar holds a handful of links and a set for a
316 // dozen short strings costs more to read than it saves.
317 let mut keys: Vec<String> = mine.iter().map(identity).collect();
318 let mut out = mine.to_vec();
319 for l in theirs {
320 let key = identity(l);
321 // What is taken includes what was just added, so a sidecar carrying the
322 // same link twice does not land it twice.
323 if keys.iter().any(|k| k == &key) {
324 continue;
325 }
326 keys.push(key);
327 out.push(l.clone());
328 }
329 if out.len() == mine.len() {
330 None
331 } else {
332 Some(out)
333 }
334}
335
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn node(s: &str) -> Node {
342 Node::parse(s).expect("a test reference must parse")
343 }
344
345 fn link(from: &str, to: &str, rel: &str) -> Link {
346 Link {
347 id: fmt!("l1"),
348 ts: 1_700_000_000_000,
349 from: node(from),
350 to: node(to),
351 rel: normalise_rel(rel),
352 note: fmt!(""),
353 by: fmt!("user"),
354 }
355 }
356
357 // ── Node references: what keeps the substrate general ────────────
358
359 #[test]
360 fn test_a_reference_splits_on_the_first_colon_only() {
361 // A URL carries colons of its own. Splitting on the last would leave
362 // every link to a page pointing at a truncated address.
363 let n = node("url:https://example.com:8443/a?b=1");
364 assert_eq!("url", n.kind);
365 assert_eq!("https://example.com:8443/a?b=1", n.rest);
366 assert_eq!("url:https://example.com:8443/a?b=1", n.to_ref());
367 }
368
369 #[test]
370 fn test_every_modelled_kind_parses() {
371 for kind in KNOWN_KINDS {
372 let n = node(&fmt!("{}:something", kind));
373 assert_eq!(kind, n.kind);
374 assert!(n.is_known(), "{} should be known", kind);
375 }
376 }
377
378 #[test]
379 fn test_an_unknown_kind_survives_rather_than_being_dropped() {
380 // The substrate has to outlive this build's idea of what can be linked.
381 // A kind written by a later version round-trips untouched; it simply
382 // answers false when asked whether this build models it.
383 let n = node("email:msg-1234@example.com");
384 assert_eq!("email", n.kind);
385 assert!(!n.is_known());
386 assert_eq!("email:msg-1234@example.com", n.to_ref());
387 }
388
389 #[test]
390 fn test_a_kind_is_case_folded_but_what_it_names_is_not() {
391 // `File:` and `file:` are one kind. A path is not ours to fold: on a
392 // case-sensitive filesystem `Notes.md` and `notes.md` are two files.
393 let n = node("FILE:Notes/Report.md");
394 assert_eq!("file", n.kind);
395 assert_eq!("Notes/Report.md", n.rest);
396 }
397
398 #[test]
399 fn test_what_is_not_a_reference_is_refused() {
400 assert!(Node::parse("no colon here").is_none());
401 assert!(Node::parse(":nokind").is_none(), "an empty kind is not a kind");
402 assert!(Node::parse("norest:").is_none(), "an empty rest names nothing");
403 assert!(Node::parse("").is_none());
404 // A colon inside prose must not read as a kind, or a sentence becomes a link.
405 assert!(Node::parse("as follows: the thing").is_none());
406 }
407
408 // ── The record: what an existing link depends on ─────────────────
409
410 #[test]
411 fn test_a_link_round_trips() {
412 let mut l = link("diamond:abc", "file:notes/report.md", "produced");
413 l.note = fmt!("The figures came out of this run.");
414 let back = Link::from_json(&l.to_json()).expect("must parse back");
415 assert_eq!("diamond:abc", back.from.to_ref());
416 assert_eq!("file:notes/report.md", back.to.to_ref());
417 assert_eq!("produced", back.rel);
418 assert_eq!("The figures came out of this run.", back.note);
419 assert_eq!("user", back.by);
420 assert_eq!(1_700_000_000_000, back.ts);
421 }
422
423 #[test]
424 fn test_a_record_missing_the_newer_fields_still_opens() {
425 // The two ends are the link. Everything else postdates something, and a
426 // strict parse would shut every link written before whatever came last.
427 let old = r#"{"from":"diamond:a","to":"diamond:b"}"#;
428 let l = Link::from_json(old).expect("a two-ended record is a link");
429 assert_eq!("diamond:a", l.from.to_ref());
430 assert_eq!("diamond:b", l.to.to_ref());
431 assert!(l.rel.is_empty() && l.note.is_empty() && l.by.is_empty());
432 assert_eq!(0, l.ts);
433 }
434
435 #[test]
436 fn test_a_record_missing_an_end_is_not_a_link() {
437 assert!(Link::from_json(r#"{"from":"diamond:a"}"#).is_none());
438 assert!(Link::from_json(r#"{"to":"diamond:b"}"#).is_none());
439 assert!(Link::from_json(r#"{"from":"diamond:a","to":"not a ref"}"#).is_none());
440 }
441
442 #[test]
443 fn test_notes_needing_escapes_survive_the_round_trip() {
444 // A note is arbitrary prose, so it can hold the characters the JSON it
445 // is written into uses. Written naively it closes the string early and
446 // the whole sidecar stops parsing.
447 let mut l = link("diamond:a", "diamond:b", "relates to");
448 l.note = fmt!("he said \"use this\"\nand a back\\slash, caf\u{e9} \u{65e5}\u{672c}");
449 let back = Link::from_json(&l.to_json()).expect("must parse back");
450 assert_eq!(l.note, back.note);
451 }
452
453 #[test]
454 fn test_a_note_holding_a_field_key_does_not_become_that_field() {
455 // The parse is a scan, not a grammar, so a value that reads like a
456 // field is worth pinning.
457 let mut l = link("diamond:a", "diamond:b", "");
458 l.note = fmt!("\"rel\":\"fake\"");
459 let back = Link::from_json(&l.to_json()).expect("must parse back");
460 assert_eq!("", back.rel, "the real field is the one that follows a comma");
461 }
462
463 // ── Two-way: one record, found from both ends ────────────────────
464
465 #[test]
466 fn test_a_link_is_found_from_either_end() {
467 let l = link("diamond:a", "diamond:b", "informs");
468 assert!(l.touches(&node("diamond:a")));
469 assert!(l.touches(&node("diamond:b")));
470 assert!(!l.touches(&node("diamond:c")));
471 }
472
473 #[test]
474 fn test_the_other_end_is_whichever_one_you_did_not_ask_from() {
475 let l = link("diamond:a", "file:x.md", "produced");
476 assert_eq!(Some(&node("file:x.md")), l.other(&node("diamond:a")));
477 assert_eq!(Some(&node("diamond:a")), l.other(&node("file:x.md")));
478 assert_eq!(None, l.other(&node("diamond:zzz")));
479 }
480
481 #[test]
482 fn test_direction_is_kept_even_though_both_ends_find_it() {
483 // Two-way means traversable from either end, NOT that the ends are
484 // interchangeable. A later reader may care which way `supersedes` ran,
485 // and it cannot recover a direction that was never stored.
486 let l = link("diamond:new", "diamond:old", "supersedes");
487 let back = Link::from_json(&l.to_json()).expect("must parse back");
488 assert_eq!("diamond:new", back.from.to_ref());
489 assert_eq!("diamond:old", back.to.to_ref());
490 }
491
492 // ── Normalisation: what the store is spared ──────────────────────
493
494 #[test]
495 fn test_case_and_whitespace_fold_into_one_relation() {
496 assert_eq!(fmt!("derives from"), normalise_rel(" Derives\t\tFrom "));
497 assert_eq!(normalise_rel("DERIVES FROM"), normalise_rel("derives from"));
498 }
499
500 #[test]
501 fn test_an_empty_relation_is_allowed() {
502 // A link with no relation still says the two things are connected,
503 // which is the least a link can usefully mean.
504 assert_eq!("", normalise_rel(" \t\n "));
505 }
506
507 #[test]
508 fn test_a_long_relation_is_capped_by_characters_not_bytes() {
509 let got = normalise_rel(&"\u{65e5}".repeat(40));
510 assert_eq!(MAX_REL_LEN, got.chars().count());
511 }
512
513 #[test]
514 fn test_a_note_is_bounded_but_otherwise_left_as_written() {
515 // Unlike a relation, a note is prose and keeps its case and its shape.
516 assert_eq!("Keep This Case, and the spacing.",
517 normalise_note(" Keep This Case, and the spacing. "));
518 assert_eq!(MAX_NOTE_LEN, normalise_note(&"x".repeat(MAX_NOTE_LEN + 500)).chars().count());
519 }
520
521 #[test]
522 fn test_the_note_cap_is_two_thousand_characters_and_that_is_the_promise() {
523 // THE ONE PLACE THE FIGURE IS WRITTEN DOWN, and it is written down on purpose.
524 // The line above says `MAX_NOTE_LEN` on both sides, which is a sentence about
525 // itself: move the constant to 200 and it stays green while every note a user
526 // writes is cut to a tenth. That is not hypothetical -- `dev/mutate.mjs` did
527 // exactly that on 2026-08-28 and all 857 tests passed.
528 //
529 // So the symbolic form stays, because it is the right way to say "capped by
530 // characters, not bytes", and this stands beside it holding the figure the
531 // product actually promises. A cap that changes must change here too, which is
532 // the point: the edit becomes visible instead of silent.
533 assert_eq!(2_000, MAX_NOTE_LEN, "the note cap is part of what the app promises");
534 assert_eq!(2_000, normalise_note(&"x".repeat(3_000)).chars().count());
535 }
536
537 // ── Revising a link: the same claim, said better ─────────────────
538
539 #[test]
540 fn test_a_revision_keeps_the_id_and_the_first_assertion_time() {
541 // The whole reason this exists. Delete-plus-re-add mints a new id and a
542 // new `ts`, so the record of when the two things were first said to be
543 // related is destroyed by an edit to the WORDING of the relation.
544 let mut links = vec![link("diamond:a", "diamond:b", "relates to")];
545 links[0].id = fmt!("east");
546 assert!(update_link_in(&mut links, "east", "Supersedes", "the newer figures"));
547 assert_eq!("east", links[0].id, "the id must survive the revision");
548 assert_eq!(1_700_000_000_000, links[0].ts, "and so must the first assertion");
549 assert_eq!("supersedes", links[0].rel, "a revised relation is normalised like any other");
550 assert_eq!("the newer figures", links[0].note);
551 // And the ends are untouched: this revises a claim, it does not move it.
552 assert_eq!("diamond:a", links[0].from.to_ref());
553 assert_eq!("diamond:b", links[0].to.to_ref());
554 }
555
556 #[test]
557 fn test_a_revision_that_changes_nothing_reports_nothing() {
558 // The caller writes only when it is told something moved, and writing is
559 // what stamps the Diamond. A no-op that reported a change would make this
560 // device fresher than the other for having done nothing, and the merge
561 // would then carry this copy over a genuine edit made elsewhere.
562 let mut links = vec![link("diamond:a", "diamond:b", "informs")];
563 links[0].id = fmt!("east");
564 links[0].note = fmt!("as written");
565 assert!(!update_link_in(&mut links, "east", "informs", "as written"));
566 // Normalisation is applied BEFORE the comparison, so a relation that
567 // differs only in case or spacing is the same relation.
568 assert!(!update_link_in(&mut links, "east", " INFORMS ", "as written"));
569 }
570
571 #[test]
572 fn test_a_revision_touches_only_the_link_it_names() {
573 let mut links = vec![
574 link("diamond:a", "diamond:b", "informs"),
575 link("diamond:a", "diamond:c", "informs"),
576 ];
577 links[0].id = fmt!("east");
578 links[1].id = fmt!("west");
579 assert!(update_link_in(&mut links, "west", "supersedes", ""));
580 assert_eq!("informs", links[0].rel, "the link that was not named must not move");
581 assert_eq!("supersedes", links[1].rel);
582 }
583
584 #[test]
585 fn test_an_id_that_names_no_link_revises_nothing() {
586 let mut links = vec![link("diamond:a", "diamond:b", "informs")];
587 links[0].id = fmt!("east");
588 assert!(!update_link_in(&mut links, "nosuch", "supersedes", ""));
589 assert_eq!("informs", links[0].rel);
590 }
591
592 #[test]
593 fn test_a_blank_id_does_not_revise_every_hand_written_link_at_once() {
594 // A link written by hand carries no id, so an empty id matches all of
595 // them -- and one careless call would rewrite the relation on every
596 // hand-written record in the sidecar.
597 let mut links = parse_links(concat!(
598 "{\"from\":\"diamond:a\",\"to\":\"diamond:b\",\"rel\":\"informs\"}\n",
599 "{\"from\":\"diamond:a\",\"to\":\"diamond:c\",\"rel\":\"informs\"}\n",
600 ));
601 assert!(!update_link_in(&mut links, "", "supersedes", "wholesale"));
602 assert!(!update_link_in(&mut links, " ", "supersedes", "wholesale"));
603 assert!(links.iter().all(|l| l.rel == "informs"), "nothing may have moved");
604 }
605
606 #[test]
607 fn test_a_revision_does_not_travel_by_union_and_the_union_does_not_undo_it() {
608 // The union keys on the id and keeps the copy already here, so a revision
609 // is NOT how an edit reaches the other device -- the stamp is, and the
610 // merge carries the Diamond wholesale on it. What matters here is the
611 // other half: the stale copy coming back must not silently un-revise the
612 // edit, and it does not, because a held id is never taken twice.
613 let mut mine = vec![with_id("east", "diamond:b")];
614 let stale = mine.clone();
615 assert!(update_link_in(&mut mine, "east", "supersedes", "revised here"));
616 assert!(union_links(&mine, &stale).is_none(), "the stale copy is the same record");
617 assert_eq!("supersedes", mine[0].rel);
618 assert_eq!("revised here", mine[0].note);
619 }
620
621 // ── The sidecar: hand-editable, so forgiving ─────────────────────
622
623 #[test]
624 fn test_a_sidecar_round_trips() {
625 let links = vec![
626 link("diamond:a", "diamond:b", "informs"),
627 link("diamond:a", "file:notes/x.md", "produced"),
628 ];
629 let back = parse_links(&write_links(&links));
630 assert_eq!(2, back.len());
631 assert_eq!("diamond:b", back[0].to.to_ref());
632 assert_eq!("file:notes/x.md", back[1].to.to_ref());
633 }
634
635 #[test]
636 fn test_one_bad_line_does_not_take_the_others_with_it() {
637 // The sidecar is meant to be hand-edited, so a fat-fingered line is a
638 // normal event. Failing the whole read would make every other link in
639 // the file vanish -- the worst possible answer to a typo.
640 let text = concat!(
641 "{\"from\":\"diamond:a\",\"to\":\"diamond:b\"}\n",
642 "this line is not JSON at all\n",
643 "\n",
644 "{\"from\":\"diamond:a\",\"to\":\"diamond:c\"}\n",
645 );
646 let links = parse_links(text);
647 assert_eq!(2, links.len(), "the readable lines still read");
648 assert_eq!("diamond:b", links[0].to.to_ref());
649 assert_eq!("diamond:c", links[1].to.to_ref());
650 }
651
652 #[test]
653 fn test_an_empty_sidecar_is_no_links_rather_than_a_failure() {
654 assert!(parse_links("").is_empty());
655 assert!(parse_links("\n\n \n").is_empty());
656 }
657
658 #[test]
659 fn test_a_sidecar_from_before_the_rename_reads_as_diamonds() {
660 let old = concat!(
661 "{\"from\":\"facet:a\",\"to\":\"facet:b\",\"note\":\"a facet: is what it was\"}\n",
662 );
663 let links = parse_links(&fix_legacy_kinds(old));
664 assert_eq!(1, links.len());
665 assert_eq!("diamond:a", links[0].from.to_ref());
666 assert_eq!("diamond:b", links[0].to.to_ref());
667 // Only the quoted prefix is a reference; the same word in prose is prose.
668 assert_eq!("a facet: is what it was", links[0].note);
669 }
670
671 // ── The union: two copies of one sidecar coming back together ────
672
673 fn with_id(id: &str, to: &str) -> Link {
674 let mut l = link("diamond:a", to, "relates to");
675 l.id = fmt!("{}", id);
676 l
677 }
678
679 fn ids(links: &[Link]) -> String {
680 links.iter().map(|l| l.id.clone()).collect::<Vec<_>>().join(",")
681 }
682
683 #[test]
684 fn test_the_union_takes_what_the_other_copy_has_and_keeps_what_this_one_had() {
685 let mine = vec![with_id("east", "diamond:b")];
686 let there = vec![with_id("west", "diamond:c")];
687 let out = union_links(&mine, &there).expect("one side had a link the other lacked");
688 assert_eq!("east,west", ids(&out), "this copy's links come first, in order");
689 }
690
691 #[test]
692 fn test_a_union_with_nothing_to_add_writes_nothing() {
693 // The whole of why it settles: no addition, no write, no stamp, and the
694 // round after a convergence is quiet.
695 let mine = vec![with_id("east", "diamond:b"), with_id("west", "diamond:c")];
696 let there = vec![with_id("west", "diamond:c")];
697 assert!(union_links(&mine, &there).is_none());
698 assert!(union_links(&mine, &mine).is_none());
699 assert!(union_links(&mine, &[]).is_none());
700 }
701
702 #[test]
703 fn test_an_id_already_held_is_not_taken_a_second_time() {
704 // The id is the identity, so the copy already here is kept rather than
705 // compared: a link is never edited in place, and re-adding it every
706 // round is how a sidecar would grow without bound.
707 let mut theirs = with_id("east", "diamond:b");
708 theirs.note = fmt!("the other device's wording");
709 let mine = vec![with_id("east", "diamond:b")];
710 assert!(union_links(&mine, &[theirs]).is_none());
711 }
712
713 #[test]
714 fn test_two_devices_asserting_the_same_link_keep_both() {
715 // Two ids are two assertions. Deciding they meant one is a judgement
716 // about content, which the wholesale merge does not make either.
717 let mine = vec![with_id("here", "diamond:b")];
718 let there = vec![with_id("there", "diamond:b")];
719 let out = union_links(&mine, &there).expect("two ids are two links");
720 assert_eq!("here,there", ids(&out));
721 }
722
723 #[test]
724 fn test_a_hand_written_link_with_no_id_is_keyed_by_what_it_says() {
725 // Nothing else can tell two of them apart, and keying them all as ""
726 // would let one unrelated link hide every other.
727 let text = concat!(
728 "{\"from\":\"diamond:a\",\"to\":\"diamond:b\",\"rel\":\"informs\"}\n",
729 "{\"from\":\"diamond:a\",\"to\":\"diamond:c\",\"rel\":\"informs\"}\n",
730 );
731 let mine = parse_links(&text);
732 let there = parse_links(&text);
733 assert_eq!(2, mine.len());
734 assert!(union_links(&mine, &there).is_none(), "the same two, not four");
735
736 let extra = parse_links("{\"from\":\"diamond:a\",\"to\":\"diamond:d\"}\n");
737 let out = union_links(&mine, &extra).expect("a third link is new");
738 assert_eq!(3, out.len());
739 }
740
741 #[test]
742 fn test_a_link_repeated_in_the_incoming_sidecar_lands_once() {
743 let there = vec![with_id("west", "diamond:c"), with_id("west", "diamond:c")];
744 let out = union_links(&[], &there).expect("this side held none");
745 assert_eq!("west", ids(&out));
746 }
747}