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 | |
| 17 | use crate::llm::{extract_json_number, extract_json_string, json_escape}; |
| 18 | |
| 19 | use oxedyne_fe2o3_core::prelude::*; |
| 20 | |
| 21 | |
| 22 | /// The most characters a relation may carry; the excess is truncated. |
| 23 | const MAX_REL_LEN: usize = 32; |
| 24 | |
| 25 | /// The most characters a note may carry; the excess is truncated. |
| 26 | const 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. |
| 34 | pub 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)] |
| 39 | pub 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 | |
| 46 | impl 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)] |
| 91 | pub 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 | |
| 108 | impl 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. |
| 162 | fn 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. |
| 180 | pub 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. |
| 198 | pub 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. |
| 228 | pub 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. |
| 254 | pub 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. |
| 263 | pub 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. |
| 279 | pub 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. |
| 292 | fn 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. |
| 314 | pub 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)] |
| 338 | mod 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 | } |