1use std::fmt::Write as _;
45
46pub const FUR_DOC_SCHEMA: u32 = 2;
54
55pub const FUR_DOC_SCHEMA_MIN: u32 = 1;
57
58const MARKER_OPEN: &str = "<!-- fur:msg";
59const MARKER_CLOSE: &str = "-->";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FurMessage {
68 pub id: String,
69 pub avatar: String,
70 pub ts: String,
71 pub link: Option<String>,
73 pub sha256: Option<String>,
75 pub img: Option<String>,
77 pub body: String,
78}
79
80impl FurMessage {
81 pub fn new(id: impl Into<String>, avatar: impl Into<String>, ts: impl Into<String>) -> Self {
82 FurMessage {
83 id: id.into(),
84 avatar: avatar.into(),
85 ts: ts.into(),
86 link: None,
87 sha256: None,
88 img: None,
89 body: String::new(),
90 }
91 }
92
93 pub fn with_body(mut self, body: impl Into<String>) -> Self {
94 self.body = body.into();
95 self
96 }
97
98 pub fn with_link(mut self, link: impl Into<String>, sha256: Option<String>) -> Self {
99 self.link = Some(link.into());
100 self.sha256 = sha256;
101 self
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct FurDocument {
112 pub schema: u32,
113 pub conversation_id: String,
114 pub title: String,
115 pub created_at: String,
116 pub updated_at: Option<String>,
117 pub tags: Vec<String>,
118 pub parents: Vec<String>,
120 pub children: Vec<String>,
122 pub messages: Vec<FurMessage>,
123}
124
125impl FurDocument {
126 pub fn new(
127 conversation_id: impl Into<String>,
128 title: impl Into<String>,
129 created_at: impl Into<String>,
130 ) -> Self {
131 FurDocument {
132 schema: FUR_DOC_SCHEMA,
133 conversation_id: conversation_id.into(),
134 title: title.into(),
135 created_at: created_at.into(),
136 updated_at: None,
137 tags: Vec::new(),
138 parents: Vec::new(),
139 children: Vec::new(),
140 messages: Vec::new(),
141 }
142 }
143
144 pub fn effective_schema(&self) -> u32 {
151 if self.parents.is_empty() && self.children.is_empty() {
152 self.schema
153 } else {
154 self.schema.max(2)
155 }
156 }
157
158 pub fn validate(&self) -> Result<(), String> {
164 check_refs(&self.parents, &self.conversation_id, "parents")?;
165 check_refs(&self.children, &self.conversation_id, "children")?;
166
167 let mut seen: Vec<&str> = Vec::with_capacity(self.messages.len());
168 for msg in &self.messages {
169 if msg.id.is_empty() {
170 return Err("message has an empty id".to_string());
171 }
172 if seen.contains(&msg.id.as_str()) {
173 return Err(format!("duplicate message id '{}'", msg.id));
174 }
175 seen.push(&msg.id);
176 }
177
178 Ok(())
179 }
180}
181
182fn check_refs(refs: &[String], conversation_id: &str, field: &str) -> Result<(), String> {
190 for reference in refs {
191 if reference.trim().is_empty() {
192 return Err(format!("empty conversation reference in '{}'", field));
193 }
194 if reference == conversation_id {
195 return Err(format!(
196 "conversation {} lists itself in '{}'",
197 conversation_id, field
198 ));
199 }
200 }
201 Ok(())
202}
203
204fn canonical_refs(refs: &[String]) -> Vec<String> {
210 let mut out: Vec<String> = refs.iter().map(|r| r.trim().to_string()).collect();
211 out.sort();
212 out.dedup();
213 out
214}
215
216pub fn serialize(doc: &FurDocument) -> String {
226 let mut out = String::new();
227
228 out.push_str("---\n");
229 let _ = writeln!(out, "fur_schema: {}", doc.effective_schema());
230 let _ = writeln!(out, "conversation_id: {}", yaml_scalar(&doc.conversation_id));
231 let _ = writeln!(out, "title: {}", yaml_scalar(&doc.title));
232 let _ = writeln!(out, "created_at: {}", yaml_scalar(&doc.created_at));
233 if let Some(updated) = &doc.updated_at {
234 let _ = writeln!(out, "updated_at: {}", yaml_scalar(updated));
235 }
236 write_scalar_list(&mut out, "tags", &doc.tags);
237 write_scalar_list(&mut out, "parents", &canonical_refs(&doc.parents));
238 write_scalar_list(&mut out, "children", &canonical_refs(&doc.children));
239 out.push_str("---\n");
240
241 for msg in &doc.messages {
242 out.push('\n');
243 out.push_str(&render_marker(msg));
244 out.push('\n');
245
246 let body = msg.body.trim_matches('\n');
247 if !body.is_empty() {
248 out.push('\n');
249 out.push_str(&escape_body(body));
250 out.push('\n');
251 }
252 }
253
254 out
255}
256
257fn write_scalar_list(out: &mut String, key: &str, values: &[String]) {
263 if values.is_empty() {
264 let _ = writeln!(out, "{}: []", key);
265 return;
266 }
267
268 let _ = writeln!(out, "{}:", key);
269 for value in values {
270 let _ = writeln!(out, " - {}", yaml_scalar(value));
271 }
272}
273
274fn render_marker(msg: &FurMessage) -> String {
275 let mut s = String::from(MARKER_OPEN);
276 let _ = write!(s, " id={}", attr_value(&msg.id));
277 let _ = write!(s, " avatar={}", attr_value(&msg.avatar));
278 let _ = write!(s, " ts={}", attr_value(&msg.ts));
279 if let Some(link) = &msg.link {
280 let _ = write!(s, " link={}", attr_value(link));
281 }
282 if let Some(hash) = &msg.sha256 {
283 let _ = write!(s, " sha256={}", attr_value(hash));
284 }
285 if let Some(img) = &msg.img {
286 let _ = write!(s, " img={}", attr_value(img));
287 }
288 s.push(' ');
289 s.push_str(MARKER_CLOSE);
290 s
291}
292
293fn attr_value(raw: &str) -> String {
296 let needs_quotes = raw.is_empty()
297 || raw
298 .chars()
299 .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c == '=');
300
301 if !needs_quotes {
302 return raw.to_string();
303 }
304
305 let mut out = String::with_capacity(raw.len() + 2);
306 out.push('"');
307 for c in raw.chars() {
308 if c == '"' || c == '\\' {
309 out.push('\\');
310 }
311 out.push(c);
312 }
313 out.push('"');
314 out
315}
316
317fn yaml_scalar(raw: &str) -> String {
320 let safe = !raw.is_empty()
321 && !raw.starts_with(' ')
322 && !raw.ends_with(' ')
323 && raw
324 .chars()
325 .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '+' | ':' | '/'))
326 && !raw.contains(": ")
327 && !raw.starts_with('-')
328 && !raw.starts_with('[');
329
330 if safe {
331 return raw.to_string();
332 }
333
334 let mut out = String::with_capacity(raw.len() + 2);
335 out.push('"');
336 for c in raw.chars() {
337 if c == '"' || c == '\\' {
338 out.push('\\');
339 }
340 out.push(c);
341 }
342 out.push('"');
343 out
344}
345
346fn escape_body(body: &str) -> String {
350 body.lines()
351 .map(|line| {
352 if is_escapable_marker_line(line) {
353 format!("\\{}", line)
354 } else {
355 line.to_string()
356 }
357 })
358 .collect::<Vec<_>>()
359 .join("\n")
360}
361
362fn unescape_body(body: &str) -> String {
363 body.lines()
364 .map(|line| {
365 if let Some(rest) = line.strip_prefix('\\') {
366 if is_escapable_marker_line(rest) {
367 return rest.to_string();
368 }
369 }
370 line.to_string()
371 })
372 .collect::<Vec<_>>()
373 .join("\n")
374}
375
376fn is_escapable_marker_line(line: &str) -> bool {
379 let stripped = line.trim_start_matches('\\');
380 stripped.starts_with(MARKER_OPEN)
381}
382
383#[derive(Clone, Copy, PartialEq, Eq)]
392enum ListKey {
393 Tags,
394 Parents,
395 Children,
396}
397
398impl ListKey {
399 fn from_key(key: &str) -> Option<Self> {
400 match key {
401 "tags" => Some(ListKey::Tags),
402 "parents" => Some(ListKey::Parents),
403 "children" => Some(ListKey::Children),
404 _ => None,
405 }
406 }
407
408 fn name(self) -> &'static str {
409 match self {
410 ListKey::Tags => "tags",
411 ListKey::Parents => "parents",
412 ListKey::Children => "children",
413 }
414 }
415}
416
417pub fn parse(text: &str) -> Result<FurDocument, String> {
420 let lines: Vec<&str> = text.lines().collect();
421
422 let (front_matter, body_start) = split_front_matter(&lines)?;
423 let mut doc = parse_front_matter(&front_matter)?;
424 doc.messages = parse_messages(&lines[body_start..])?;
425
426 doc.parents = canonical_refs(&doc.parents);
429 doc.children = canonical_refs(&doc.children);
430
431 doc.validate()?;
432
433 Ok(doc)
434}
435
436fn split_front_matter<'a>(lines: &[&'a str]) -> Result<(Vec<&'a str>, usize), String> {
438 if lines.first().map(|l| l.trim_end()) != Some("---") {
439 return Err("missing front matter: document must start with '---'".to_string());
440 }
441
442 for (i, line) in lines.iter().enumerate().skip(1) {
443 if line.trim_end() == "---" {
444 return Ok((lines[1..i].to_vec(), i + 1));
445 }
446 }
447
448 Err("unterminated front matter: no closing '---'".to_string())
449}
450
451fn parse_front_matter(lines: &[&str]) -> Result<FurDocument, String> {
452 let mut schema: Option<u32> = None;
453 let mut conversation_id: Option<String> = None;
454 let mut title: Option<String> = None;
455 let mut created_at: Option<String> = None;
456 let mut updated_at: Option<String> = None;
457 let mut tags: Vec<String> = Vec::new();
458 let mut parents: Vec<String> = Vec::new();
459 let mut children: Vec<String> = Vec::new();
460 let mut current_list: Option<ListKey> = None;
461
462 for line in lines {
463 if line.trim().is_empty() {
464 continue;
465 }
466
467 if let Some(item) = line.trim_start().strip_prefix("- ") {
468 let Some(list) = current_list else {
469 return Err(format!("unexpected list item in front matter: {}", line));
470 };
471 let value = unquote_scalar(item.trim());
472 match list {
473 ListKey::Tags => tags.push(value),
474 ListKey::Parents => parents.push(value),
475 ListKey::Children => children.push(value),
476 }
477 continue;
478 }
479
480 let Some((key, value)) = line.split_once(':') else {
481 return Err(format!("malformed front matter line: {}", line));
482 };
483
484 let key = key.trim();
485 let value = value.trim();
486 current_list = None;
487
488 if let Some(list) = ListKey::from_key(key) {
489 let target = match list {
490 ListKey::Tags => &mut tags,
491 ListKey::Parents => &mut parents,
492 ListKey::Children => &mut children,
493 };
494
495 if value.is_empty() {
496 current_list = Some(list);
498 } else if value.starts_with('[') && value.ends_with(']') {
499 *target = parse_flow_sequence(&value[1..value.len() - 1], list.name())?;
500 } else {
501 return Err(format!("unsupported value for '{}': '{}'", list.name(), value));
502 }
503 continue;
504 }
505
506 match key {
507 "fur_schema" => {
508 schema = Some(
509 value
510 .parse::<u32>()
511 .map_err(|_| format!("fur_schema must be an integer, got '{}'", value))?,
512 );
513 }
514 "conversation_id" => conversation_id = Some(unquote_scalar(value)),
515 "title" => title = Some(unquote_scalar(value)),
516 "created_at" => created_at = Some(unquote_scalar(value)),
517 "updated_at" => updated_at = Some(unquote_scalar(value)),
518 _ => return Err(format!("unknown front matter key: '{}'", key)),
519 }
520 }
521
522 let schema = schema.ok_or("front matter missing 'fur_schema'")?;
523 if schema > FUR_DOC_SCHEMA {
524 return Err(format!(
525 "document schema {} is newer than this build supports ({})",
526 schema, FUR_DOC_SCHEMA
527 ));
528 }
529 if schema < FUR_DOC_SCHEMA_MIN {
530 return Err(format!(
531 "document schema {} is older than this build supports ({})",
532 schema, FUR_DOC_SCHEMA_MIN
533 ));
534 }
535
536 Ok(FurDocument {
537 schema,
538 conversation_id: conversation_id.ok_or("front matter missing 'conversation_id'")?,
539 title: title.ok_or("front matter missing 'title'")?,
540 created_at: created_at.ok_or("front matter missing 'created_at'")?,
541 updated_at,
542 tags,
543 parents,
544 children,
545 messages: Vec::new(),
546 })
547}
548
549fn parse_flow_sequence(inner: &str, field: &str) -> Result<Vec<String>, String> {
556 let trimmed = inner.trim();
557 if trimmed.is_empty() {
558 return Ok(Vec::new());
559 }
560
561 let chars: Vec<char> = trimmed.chars().collect();
562 let mut out = Vec::new();
563 let mut current = String::new();
564 let mut quoted = false;
565 let mut closed_quote = false;
566 let mut i = 0;
567
568 while i < chars.len() {
569 let c = chars[i];
570
571 if quoted {
572 if c == '\\' && i + 1 < chars.len() {
573 current.push(chars[i + 1]);
574 i += 2;
575 continue;
576 }
577 if c == '"' {
578 quoted = false;
579 closed_quote = true;
580 i += 1;
581 continue;
582 }
583 current.push(c);
584 i += 1;
585 continue;
586 }
587
588 match c {
589 '"' if current.trim().is_empty() && !closed_quote => {
590 current.clear();
591 quoted = true;
592 i += 1;
593 }
594 ',' => {
595 out.push(std::mem::take(&mut current).trim().to_string());
596 closed_quote = false;
597 i += 1;
598 }
599 _ => {
600 current.push(c);
601 i += 1;
602 }
603 }
604 }
605
606 if quoted {
607 return Err(format!("unterminated quoted value in '{}'", field));
608 }
609
610 out.push(current.trim().to_string());
611
612 if out.iter().any(|v| v.is_empty()) {
613 return Err(format!("empty entry in '{}' list", field));
614 }
615
616 Ok(out)
617}
618
619fn parse_messages(lines: &[&str]) -> Result<Vec<FurMessage>, String> {
620 let mut messages: Vec<FurMessage> = Vec::new();
621 let mut pending: Option<FurMessage> = None;
622 let mut body: Vec<&str> = Vec::new();
623
624 for line in lines {
625 if is_marker_line(line) {
626 if let Some(mut msg) = pending.take() {
627 msg.body = finish_body(&body);
628 messages.push(msg);
629 }
630 body.clear();
631 pending = Some(parse_marker(line)?);
632 continue;
633 }
634
635 if pending.is_none() {
636 if line.trim().is_empty() {
637 continue;
638 }
639 return Err(format!(
640 "content before the first message marker: {}",
641 line.trim()
642 ));
643 }
644
645 body.push(line);
646 }
647
648 if let Some(mut msg) = pending.take() {
649 msg.body = finish_body(&body);
650 messages.push(msg);
651 }
652
653 Ok(messages)
654}
655
656fn is_marker_line(line: &str) -> bool {
659 line.starts_with(MARKER_OPEN) && line.trim_end().ends_with(MARKER_CLOSE)
660}
661
662fn finish_body(lines: &[&str]) -> String {
663 let joined = lines.join("\n");
664 unescape_body(joined.trim_matches('\n'))
665}
666
667fn parse_marker(line: &str) -> Result<FurMessage, String> {
668 let trimmed = line.trim_end();
669 let inner = trimmed[MARKER_OPEN.len()..trimmed.len() - MARKER_CLOSE.len()].trim();
670
671 let mut msg = FurMessage::new(String::new(), String::new(), String::new());
672 let mut have_id = false;
673 let mut have_avatar = false;
674 let mut have_ts = false;
675
676 for (key, value) in parse_attributes(inner)? {
677 match key.as_str() {
678 "id" => {
679 msg.id = value;
680 have_id = true;
681 }
682 "avatar" => {
683 msg.avatar = value;
684 have_avatar = true;
685 }
686 "ts" => {
687 msg.ts = value;
688 have_ts = true;
689 }
690 "link" => msg.link = Some(value),
691 "sha256" => msg.sha256 = Some(value),
692 "img" => msg.img = Some(value),
693 other => return Err(format!("unknown marker attribute '{}'", other)),
694 }
695 }
696
697 if !have_id || !have_avatar || !have_ts {
698 return Err(format!(
699 "marker missing required attribute (id, avatar, ts): {}",
700 trimmed
701 ));
702 }
703
704 Ok(msg)
705}
706
707fn parse_attributes(input: &str) -> Result<Vec<(String, String)>, String> {
710 let chars: Vec<char> = input.chars().collect();
711 let mut out = Vec::new();
712 let mut i = 0;
713
714 while i < chars.len() {
715 while i < chars.len() && chars[i].is_whitespace() {
716 i += 1;
717 }
718 if i >= chars.len() {
719 break;
720 }
721
722 let key_start = i;
723 while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
724 i += 1;
725 }
726 let key: String = chars[key_start..i].iter().collect();
727
728 if i >= chars.len() || chars[i] != '=' {
729 return Err(format!("attribute '{}' is missing '='", key));
730 }
731 i += 1;
732
733 let value = if i < chars.len() && chars[i] == '"' {
734 i += 1;
735 let mut v = String::new();
736 let mut closed = false;
737 while i < chars.len() {
738 if chars[i] == '\\' && i + 1 < chars.len() {
739 v.push(chars[i + 1]);
740 i += 2;
741 continue;
742 }
743 if chars[i] == '"' {
744 closed = true;
745 i += 1;
746 break;
747 }
748 v.push(chars[i]);
749 i += 1;
750 }
751 if !closed {
752 return Err(format!("unterminated quoted value for '{}'", key));
753 }
754 v
755 } else {
756 let value_start = i;
757 while i < chars.len() && !chars[i].is_whitespace() {
758 i += 1;
759 }
760 chars[value_start..i].iter().collect()
761 };
762
763 out.push((key, value));
764 }
765
766 Ok(out)
767}
768
769fn unquote_scalar(raw: &str) -> String {
770 let trimmed = raw.trim();
771 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
772 let inner: Vec<char> = trimmed[1..trimmed.len() - 1].chars().collect();
773 let mut out = String::with_capacity(inner.len());
774 let mut i = 0;
775 while i < inner.len() {
776 if inner[i] == '\\' && i + 1 < inner.len() {
777 out.push(inner[i + 1]);
778 i += 2;
779 continue;
780 }
781 out.push(inner[i]);
782 i += 1;
783 }
784 return out;
785 }
786 trimmed.to_string()
787}
788
789#[cfg(test)]
794mod tests {
795 use super::*;
796
797 const A: &str = "aaaaaaaa-1111-4111-8111-111111111111";
798 const B: &str = "bbbbbbbb-2222-4222-8222-222222222222";
799 const C: &str = "cccccccc-3333-4333-8333-333333333333";
800 const D: &str = "dddddddd-4444-4444-8444-444444444444";
801
802 fn sample() -> FurDocument {
803 let mut doc = FurDocument::new(
804 "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
805 "Schema Proposal Changes FUR",
806 "2026-08-08T18:11:40-05:00",
807 );
808 doc.updated_at = Some("2026-08-08T19:02:15-05:00".to_string());
809 doc.tags = vec!["schema".to_string(), "architecture".to_string()];
810 doc.messages = vec![
811 FurMessage::new("3a7f9c21", "andrew", "2026-08-08T18:11:40-05:00")
812 .with_body("I think that I may have a better idea..."),
813 FurMessage::new("b2e14d80", "gpt5", "2026-08-08T18:11:57-05:00")
814 .with_link("CHAT-20260808-181158.md", Some("9f2c".to_string())),
815 FurMessage::new("c17aa903", "andrew", "2026-08-08T18:14:02-05:00").with_body("badass"),
816 ];
817 doc
818 }
819
820 fn round_trips(doc: &FurDocument) -> FurDocument {
821 let once = serialize(doc);
822 let parsed = parse(&once).expect("parses");
823 assert_eq!(once, serialize(&parsed), "serialization is not idempotent");
824 parsed
825 }
826
827 #[test]
828 fn round_trip_is_byte_stable() {
829 let parsed = round_trips(&sample());
830 assert_eq!(sample(), parsed);
831 }
832
833 #[test]
834 fn body_containing_a_marker_survives() {
835 let mut doc = FurDocument::new("cid", "Escaping", "2026-08-08T00:00:00Z");
836 let evil = "Here is the format:\n<!-- fur:msg id=fake avatar=x ts=y -->\ndone";
837 doc.messages = vec![FurMessage::new("m1", "andrew", "2026-08-08T00:00:00Z").with_body(evil)];
838
839 let parsed = round_trips(&doc);
840 assert_eq!(parsed.messages.len(), 1);
841 assert_eq!(parsed.messages[0].body, evil);
842 }
843
844 #[test]
845 fn duplicate_ids_are_rejected() {
846 let mut doc = FurDocument::new("cid", "Dupes", "2026-08-08T00:00:00Z");
847 doc.messages = vec![
848 FurMessage::new("same", "andrew", "2026-08-08T00:00:00Z").with_body("one"),
849 FurMessage::new("same", "gpt5", "2026-08-08T00:00:01Z").with_body("two"),
850 ];
851
852 let err = parse(&serialize(&doc)).unwrap_err();
853 assert!(err.contains("duplicate message id"), "got: {}", err);
854 }
855
856 #[test]
857 fn values_with_spaces_round_trip() {
858 let mut doc = FurDocument::new("cid", "Title: with punctuation", "2026-08-08T00:00:00Z");
859 doc.tags = vec!["deep-learning".to_string()];
860 doc.messages = vec![FurMessage::new("m1", "karen from hr", "2026-08-08T00:00:00Z")
861 .with_link("CHAT with spaces.md", None)];
862
863 let parsed = round_trips(&doc);
864 assert_eq!(parsed.title, "Title: with punctuation");
865 assert_eq!(parsed.messages[0].avatar, "karen from hr");
866 assert_eq!(parsed.messages[0].link.as_deref(), Some("CHAT with spaces.md"));
867 }
868
869 #[test]
870 fn missing_front_matter_is_an_error() {
871 assert!(parse("just some markdown\n").is_err());
872 }
873
874 #[test]
877 fn lineage_round_trips() {
878 let mut doc = FurDocument::new(A, "Business plan", "2026-08-17T00:14:20Z");
879 doc.children = vec![B.to_string(), C.to_string()];
880
881 let parsed = round_trips(&doc);
882 assert_eq!(parsed.children, vec![B.to_string(), C.to_string()]);
883 assert!(parsed.parents.is_empty());
884 assert_eq!(parsed.schema, 2);
885 }
886
887 #[test]
888 fn lineage_is_sorted_and_deduplicated() {
889 let mut doc = FurDocument::new(A, "Unsorted", "2026-08-17T00:14:20Z");
890 doc.parents = vec![C.to_string(), B.to_string(), C.to_string()];
891
892 let parsed = round_trips(&doc);
893 assert_eq!(parsed.parents, vec![B.to_string(), C.to_string()]);
894 }
895
896 #[test]
897 fn self_reference_is_rejected() {
898 let mut doc = FurDocument::new(A, "Ouroboros", "2026-08-17T00:14:20Z");
899 doc.parents = vec![A.to_string()];
900
901 let err = parse(&serialize(&doc)).unwrap_err();
902 assert!(err.contains("lists itself"), "got: {}", err);
903 }
904
905 #[test]
906 fn dangling_references_are_legal() {
907 let mut doc = FurDocument::new(A, "Imported alone", "2026-08-17T00:14:20Z");
908 doc.parents = vec!["ffffffff-0000-4000-8000-000000000000".to_string()];
909
910 let parsed = round_trips(&doc);
911 assert_eq!(parsed.parents.len(), 1);
912 }
913
914 #[test]
915 fn a_cycle_between_two_documents_is_representable() {
916 let mut first = FurDocument::new(A, "First", "2026-08-17T00:14:20Z");
917 first.children = vec![B.to_string()];
918 let mut second = FurDocument::new(B, "Second", "2026-08-17T00:15:20Z");
919 second.children = vec![A.to_string()];
920
921 assert_eq!(round_trips(&first).children, vec![B.to_string()]);
924 assert_eq!(round_trips(&second).children, vec![A.to_string()]);
925 }
926
927 #[test]
928 fn a_diamond_is_representable() {
929 let mut top = FurDocument::new(A, "Top", "2026-08-17T00:14:20Z");
930 top.children = vec![B.to_string(), C.to_string()];
931
932 let mut bottom = FurDocument::new(D, "Bottom", "2026-08-17T00:17:20Z");
933 bottom.parents = vec![B.to_string(), C.to_string()];
934
935 assert_eq!(round_trips(&top).children.len(), 2);
936 assert_eq!(round_trips(&bottom).parents.len(), 2);
937 }
938
939 #[test]
940 fn inline_flow_sequences_parse() {
941 let text = format!(
942 "---\nfur_schema: 2\nconversation_id: {}\ntitle: Flow\ncreated_at: 2026-08-17T00:14:20Z\ntags: [research, \"deep learning\"]\nparents: [{}]\nchildren: []\n---\n",
943 A, B
944 );
945
946 let doc = parse(&text).expect("parses");
947 assert_eq!(doc.tags, vec!["research".to_string(), "deep learning".to_string()]);
948 assert_eq!(doc.parents, vec![B.to_string()]);
949 assert!(doc.children.is_empty());
950 }
951
952 #[test]
953 fn block_and_flow_forms_agree() {
954 let flow = format!(
955 "---\nfur_schema: 2\nconversation_id: {}\ntitle: Same\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents: [{}, {}]\nchildren: []\n---\n",
956 A, B, C
957 );
958 let block = format!(
959 "---\nfur_schema: 2\nconversation_id: {}\ntitle: Same\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents:\n - {}\n - {}\nchildren: []\n---\n",
960 A, B, C
961 );
962
963 assert_eq!(parse(&flow).unwrap(), parse(&block).unwrap());
964 assert_eq!(serialize(&parse(&flow).unwrap()), serialize(&parse(&block).unwrap()));
965 }
966
967 #[test]
968 fn schema_one_documents_still_parse() {
969 let text = format!(
970 "---\nfur_schema: 1\nconversation_id: {}\ntitle: Old\ncreated_at: 2026-08-08T18:11:40-05:00\ntags: []\n---\n\n<!-- fur:msg id=m1 avatar=andrew ts=2026-08-08T18:11:40-05:00 -->\n\nhello\n",
971 A
972 );
973
974 let doc = parse(&text).expect("parses");
975 assert_eq!(doc.schema, 1);
976 assert!(doc.parents.is_empty());
977 assert_eq!(doc.messages.len(), 1);
978 }
979
980 #[test]
981 fn a_schema_one_document_without_lineage_stays_schema_one() {
982 let mut doc = FurDocument::new(A, "Old", "2026-08-08T18:11:40-05:00");
983 doc.schema = 1;
984 assert!(serialize(&doc).contains("fur_schema: 1"));
985 }
986
987 #[test]
988 fn adding_lineage_upgrades_the_document() {
989 let mut doc = FurDocument::new(A, "Old", "2026-08-08T18:11:40-05:00");
990 doc.schema = 1;
991 doc.parents = vec![B.to_string()];
992 assert!(serialize(&doc).contains("fur_schema: 2"));
993 }
994
995 #[test]
996 fn a_future_schema_is_refused_by_version_not_by_key() {
997 let text = format!(
998 "---\nfur_schema: 3\nconversation_id: {}\ntitle: Future\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\n---\n",
999 A
1000 );
1001
1002 let err = parse(&text).unwrap_err();
1003 assert!(err.contains("newer than this build"), "got: {}", err);
1004 }
1005
1006 #[test]
1007 fn an_empty_reference_is_rejected() {
1008 let text = format!(
1009 "---\nfur_schema: 2\nconversation_id: {}\ntitle: Empty ref\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents: [{}, ]\nchildren: []\n---\n",
1010 A, B
1011 );
1012
1013 let err = parse(&text).unwrap_err();
1014 assert!(err.contains("empty entry"), "got: {}", err);
1015 }
1016}