1use std::fmt::Write as _;
21
22pub const FUR_DOC_SCHEMA: u32 = 1;
26
27const MARKER_OPEN: &str = "<!-- fur:msg";
28const MARKER_CLOSE: &str = "-->";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FurMessage {
37 pub id: String,
38 pub avatar: String,
39 pub ts: String,
40 pub link: Option<String>,
42 pub sha256: Option<String>,
44 pub img: Option<String>,
46 pub body: String,
47}
48
49impl FurMessage {
50 pub fn new(id: impl Into<String>, avatar: impl Into<String>, ts: impl Into<String>) -> Self {
51 FurMessage {
52 id: id.into(),
53 avatar: avatar.into(),
54 ts: ts.into(),
55 link: None,
56 sha256: None,
57 img: None,
58 body: String::new(),
59 }
60 }
61
62 pub fn with_body(mut self, body: impl Into<String>) -> Self {
63 self.body = body.into();
64 self
65 }
66
67 pub fn with_link(mut self, link: impl Into<String>, sha256: Option<String>) -> Self {
68 self.link = Some(link.into());
69 self.sha256 = sha256;
70 self
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FurDocument {
81 pub schema: u32,
82 pub conversation_id: String,
83 pub title: String,
84 pub created_at: String,
85 pub updated_at: Option<String>,
86 pub tags: Vec<String>,
87 pub messages: Vec<FurMessage>,
88}
89
90impl FurDocument {
91 pub fn new(
92 conversation_id: impl Into<String>,
93 title: impl Into<String>,
94 created_at: impl Into<String>,
95 ) -> Self {
96 FurDocument {
97 schema: FUR_DOC_SCHEMA,
98 conversation_id: conversation_id.into(),
99 title: title.into(),
100 created_at: created_at.into(),
101 updated_at: None,
102 tags: Vec::new(),
103 messages: Vec::new(),
104 }
105 }
106}
107
108pub fn serialize(doc: &FurDocument) -> String {
118 let mut out = String::new();
119
120 out.push_str("---\n");
121 let _ = writeln!(out, "fur_schema: {}", doc.schema);
122 let _ = writeln!(out, "conversation_id: {}", yaml_scalar(&doc.conversation_id));
123 let _ = writeln!(out, "title: {}", yaml_scalar(&doc.title));
124 let _ = writeln!(out, "created_at: {}", yaml_scalar(&doc.created_at));
125 if let Some(updated) = &doc.updated_at {
126 let _ = writeln!(out, "updated_at: {}", yaml_scalar(updated));
127 }
128 if doc.tags.is_empty() {
129 out.push_str("tags: []\n");
130 } else {
131 out.push_str("tags:\n");
132 for tag in &doc.tags {
133 let _ = writeln!(out, " - {}", yaml_scalar(tag));
134 }
135 }
136 out.push_str("---\n");
137
138 for msg in &doc.messages {
139 out.push('\n');
140 out.push_str(&render_marker(msg));
141 out.push('\n');
142
143 let body = msg.body.trim_matches('\n');
144 if !body.is_empty() {
145 out.push('\n');
146 out.push_str(&escape_body(body));
147 out.push('\n');
148 }
149 }
150
151 out
152}
153
154fn render_marker(msg: &FurMessage) -> String {
155 let mut s = String::from(MARKER_OPEN);
156 let _ = write!(s, " id={}", attr_value(&msg.id));
157 let _ = write!(s, " avatar={}", attr_value(&msg.avatar));
158 let _ = write!(s, " ts={}", attr_value(&msg.ts));
159 if let Some(link) = &msg.link {
160 let _ = write!(s, " link={}", attr_value(link));
161 }
162 if let Some(hash) = &msg.sha256 {
163 let _ = write!(s, " sha256={}", attr_value(hash));
164 }
165 if let Some(img) = &msg.img {
166 let _ = write!(s, " img={}", attr_value(img));
167 }
168 s.push(' ');
169 s.push_str(MARKER_CLOSE);
170 s
171}
172
173fn attr_value(raw: &str) -> String {
176 let needs_quotes = raw.is_empty()
177 || raw
178 .chars()
179 .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c == '=');
180
181 if !needs_quotes {
182 return raw.to_string();
183 }
184
185 let mut out = String::with_capacity(raw.len() + 2);
186 out.push('"');
187 for c in raw.chars() {
188 if c == '"' || c == '\\' {
189 out.push('\\');
190 }
191 out.push(c);
192 }
193 out.push('"');
194 out
195}
196
197fn yaml_scalar(raw: &str) -> String {
200 let safe = !raw.is_empty()
201 && !raw.starts_with(' ')
202 && !raw.ends_with(' ')
203 && raw
204 .chars()
205 .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '+' | ':' | '/'))
206 && !raw.contains(": ")
207 && !raw.starts_with('-');
208
209 if safe {
210 return raw.to_string();
211 }
212
213 let mut out = String::with_capacity(raw.len() + 2);
214 out.push('"');
215 for c in raw.chars() {
216 if c == '"' || c == '\\' {
217 out.push('\\');
218 }
219 out.push(c);
220 }
221 out.push('"');
222 out
223}
224
225fn escape_body(body: &str) -> String {
229 body.lines()
230 .map(|line| {
231 if is_escapable_marker_line(line) {
232 format!("\\{}", line)
233 } else {
234 line.to_string()
235 }
236 })
237 .collect::<Vec<_>>()
238 .join("\n")
239}
240
241fn unescape_body(body: &str) -> String {
242 body.lines()
243 .map(|line| {
244 if let Some(rest) = line.strip_prefix('\\') {
245 if is_escapable_marker_line(rest) {
246 return rest.to_string();
247 }
248 }
249 line.to_string()
250 })
251 .collect::<Vec<_>>()
252 .join("\n")
253}
254
255fn is_escapable_marker_line(line: &str) -> bool {
258 let stripped = line.trim_start_matches('\\');
259 stripped.starts_with(MARKER_OPEN)
260}
261
262pub fn parse(text: &str) -> Result<FurDocument, String> {
269 let lines: Vec<&str> = text.lines().collect();
270
271 let (front_matter, body_start) = split_front_matter(&lines)?;
272 let mut doc = parse_front_matter(&front_matter)?;
273 doc.messages = parse_messages(&lines[body_start..])?;
274
275 let mut seen: Vec<&str> = Vec::with_capacity(doc.messages.len());
276 for msg in &doc.messages {
277 if msg.id.is_empty() {
278 return Err("message marker has an empty id".to_string());
279 }
280 if seen.contains(&msg.id.as_str()) {
281 return Err(format!("duplicate message id '{}'", msg.id));
282 }
283 seen.push(&msg.id);
284 }
285
286 Ok(doc)
287}
288
289fn split_front_matter<'a>(lines: &[&'a str]) -> Result<(Vec<&'a str>, usize), String> {
291 if lines.first().map(|l| l.trim_end()) != Some("---") {
292 return Err("missing front matter: document must start with '---'".to_string());
293 }
294
295 for (i, line) in lines.iter().enumerate().skip(1) {
296 if line.trim_end() == "---" {
297 return Ok((lines[1..i].to_vec(), i + 1));
298 }
299 }
300
301 Err("unterminated front matter: no closing '---'".to_string())
302}
303
304fn parse_front_matter(lines: &[&str]) -> Result<FurDocument, String> {
305 let mut schema: Option<u32> = None;
306 let mut conversation_id: Option<String> = None;
307 let mut title: Option<String> = None;
308 let mut created_at: Option<String> = None;
309 let mut updated_at: Option<String> = None;
310 let mut tags: Vec<String> = Vec::new();
311 let mut in_tags = false;
312
313 for line in lines {
314 if line.trim().is_empty() {
315 continue;
316 }
317
318 if let Some(item) = line.trim_start().strip_prefix("- ") {
319 if !in_tags {
320 return Err(format!("unexpected list item in front matter: {}", line));
321 }
322 tags.push(unquote_scalar(item.trim()));
323 continue;
324 }
325
326 let Some((key, value)) = line.split_once(':') else {
327 return Err(format!("malformed front matter line: {}", line));
328 };
329
330 let key = key.trim();
331 let value = value.trim();
332 in_tags = false;
333
334 match key {
335 "fur_schema" => {
336 schema = Some(
337 value
338 .parse::<u32>()
339 .map_err(|_| format!("fur_schema must be an integer, got '{}'", value))?,
340 );
341 }
342 "conversation_id" => conversation_id = Some(unquote_scalar(value)),
343 "title" => title = Some(unquote_scalar(value)),
344 "created_at" => created_at = Some(unquote_scalar(value)),
345 "updated_at" => updated_at = Some(unquote_scalar(value)),
346 "tags" => {
347 if value == "[]" || value.is_empty() {
348 in_tags = value.is_empty();
349 } else {
350 return Err(format!("unsupported inline tags value: '{}'", value));
351 }
352 }
353 _ => return Err(format!("unknown front matter key: '{}'", key)),
354 }
355 }
356
357 let schema = schema.ok_or("front matter missing 'fur_schema'")?;
358 if schema > FUR_DOC_SCHEMA {
359 return Err(format!(
360 "document schema {} is newer than this build supports ({})",
361 schema, FUR_DOC_SCHEMA
362 ));
363 }
364
365 Ok(FurDocument {
366 schema,
367 conversation_id: conversation_id.ok_or("front matter missing 'conversation_id'")?,
368 title: title.ok_or("front matter missing 'title'")?,
369 created_at: created_at.ok_or("front matter missing 'created_at'")?,
370 updated_at,
371 tags,
372 messages: Vec::new(),
373 })
374}
375
376fn parse_messages(lines: &[&str]) -> Result<Vec<FurMessage>, String> {
377 let mut messages: Vec<FurMessage> = Vec::new();
378 let mut pending: Option<FurMessage> = None;
379 let mut body: Vec<&str> = Vec::new();
380
381 for line in lines {
382 if is_marker_line(line) {
383 if let Some(mut msg) = pending.take() {
384 msg.body = finish_body(&body);
385 messages.push(msg);
386 }
387 body.clear();
388 pending = Some(parse_marker(line)?);
389 continue;
390 }
391
392 if pending.is_none() {
393 if line.trim().is_empty() {
394 continue;
395 }
396 return Err(format!(
397 "content before the first message marker: {}",
398 line.trim()
399 ));
400 }
401
402 body.push(line);
403 }
404
405 if let Some(mut msg) = pending.take() {
406 msg.body = finish_body(&body);
407 messages.push(msg);
408 }
409
410 Ok(messages)
411}
412
413fn is_marker_line(line: &str) -> bool {
416 line.starts_with(MARKER_OPEN) && line.trim_end().ends_with(MARKER_CLOSE)
417}
418
419fn finish_body(lines: &[&str]) -> String {
420 let joined = lines.join("\n");
421 unescape_body(joined.trim_matches('\n'))
422}
423
424fn parse_marker(line: &str) -> Result<FurMessage, String> {
425 let trimmed = line.trim_end();
426 let inner = trimmed[MARKER_OPEN.len()..trimmed.len() - MARKER_CLOSE.len()].trim();
427
428 let mut msg = FurMessage::new(String::new(), String::new(), String::new());
429 let mut have_id = false;
430 let mut have_avatar = false;
431 let mut have_ts = false;
432
433 for (key, value) in parse_attributes(inner)? {
434 match key.as_str() {
435 "id" => {
436 msg.id = value;
437 have_id = true;
438 }
439 "avatar" => {
440 msg.avatar = value;
441 have_avatar = true;
442 }
443 "ts" => {
444 msg.ts = value;
445 have_ts = true;
446 }
447 "link" => msg.link = Some(value),
448 "sha256" => msg.sha256 = Some(value),
449 "img" => msg.img = Some(value),
450 other => return Err(format!("unknown marker attribute '{}'", other)),
451 }
452 }
453
454 if !have_id || !have_avatar || !have_ts {
455 return Err(format!(
456 "marker missing required attribute (id, avatar, ts): {}",
457 trimmed
458 ));
459 }
460
461 Ok(msg)
462}
463
464fn parse_attributes(input: &str) -> Result<Vec<(String, String)>, String> {
467 let chars: Vec<char> = input.chars().collect();
468 let mut out = Vec::new();
469 let mut i = 0;
470
471 while i < chars.len() {
472 while i < chars.len() && chars[i].is_whitespace() {
473 i += 1;
474 }
475 if i >= chars.len() {
476 break;
477 }
478
479 let key_start = i;
480 while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
481 i += 1;
482 }
483 let key: String = chars[key_start..i].iter().collect();
484
485 if i >= chars.len() || chars[i] != '=' {
486 return Err(format!("attribute '{}' is missing '='", key));
487 }
488 i += 1;
489
490 let value = if i < chars.len() && chars[i] == '"' {
491 i += 1;
492 let mut v = String::new();
493 let mut closed = false;
494 while i < chars.len() {
495 if chars[i] == '\\' && i + 1 < chars.len() {
496 v.push(chars[i + 1]);
497 i += 2;
498 continue;
499 }
500 if chars[i] == '"' {
501 closed = true;
502 i += 1;
503 break;
504 }
505 v.push(chars[i]);
506 i += 1;
507 }
508 if !closed {
509 return Err(format!("unterminated quoted value for '{}'", key));
510 }
511 v
512 } else {
513 let value_start = i;
514 while i < chars.len() && !chars[i].is_whitespace() {
515 i += 1;
516 }
517 chars[value_start..i].iter().collect()
518 };
519
520 out.push((key, value));
521 }
522
523 Ok(out)
524}
525
526fn unquote_scalar(raw: &str) -> String {
527 let trimmed = raw.trim();
528 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
529 let inner: Vec<char> = trimmed[1..trimmed.len() - 1].chars().collect();
530 let mut out = String::with_capacity(inner.len());
531 let mut i = 0;
532 while i < inner.len() {
533 if inner[i] == '\\' && i + 1 < inner.len() {
534 out.push(inner[i + 1]);
535 i += 2;
536 continue;
537 }
538 out.push(inner[i]);
539 i += 1;
540 }
541 return out;
542 }
543 trimmed.to_string()
544}
545
546#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn sample() -> FurDocument {
555 let mut doc = FurDocument::new(
556 "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
557 "Schema Proposal Changes FUR",
558 "2026-08-08T18:11:40-05:00",
559 );
560 doc.updated_at = Some("2026-08-08T19:02:15-05:00".to_string());
561 doc.tags = vec!["schema".to_string(), "architecture".to_string()];
562 doc.messages = vec![
563 FurMessage::new("3a7f9c21", "andrew", "2026-08-08T18:11:40-05:00")
564 .with_body("I think that I may have a better idea..."),
565 FurMessage::new("b2e14d80", "gpt5", "2026-08-08T18:11:57-05:00")
566 .with_link("CHAT-20260808-181158.md", Some("9f2c".to_string())),
567 FurMessage::new("c17aa903", "andrew", "2026-08-08T18:14:02-05:00").with_body("badass"),
568 ];
569 doc
570 }
571
572 #[test]
573 fn round_trip_is_byte_stable() {
574 let once = serialize(&sample());
575 let parsed = parse(&once).expect("parses");
576 assert_eq!(once, serialize(&parsed));
577 assert_eq!(sample(), parsed);
578 }
579
580 #[test]
581 fn body_containing_a_marker_survives() {
582 let mut doc = FurDocument::new("cid", "Escaping", "2026-08-08T00:00:00Z");
583 let evil = "Here is the format:\n<!-- fur:msg id=fake avatar=x ts=y -->\ndone";
584 doc.messages = vec![FurMessage::new("m1", "andrew", "2026-08-08T00:00:00Z").with_body(evil)];
585
586 let text = serialize(&doc);
587 let parsed = parse(&text).expect("parses");
588
589 assert_eq!(parsed.messages.len(), 1);
590 assert_eq!(parsed.messages[0].body, evil);
591 assert_eq!(text, serialize(&parsed));
592 }
593
594 #[test]
595 fn duplicate_ids_are_rejected() {
596 let mut doc = FurDocument::new("cid", "Dupes", "2026-08-08T00:00:00Z");
597 doc.messages = vec![
598 FurMessage::new("same", "andrew", "2026-08-08T00:00:00Z").with_body("one"),
599 FurMessage::new("same", "gpt5", "2026-08-08T00:00:01Z").with_body("two"),
600 ];
601
602 let err = parse(&serialize(&doc)).unwrap_err();
603 assert!(err.contains("duplicate message id"), "got: {}", err);
604 }
605
606 #[test]
607 fn values_with_spaces_round_trip() {
608 let mut doc = FurDocument::new("cid", "Title: with punctuation", "2026-08-08T00:00:00Z");
609 doc.tags = vec!["deep-learning".to_string()];
610 doc.messages = vec![FurMessage::new("m1", "karen from hr", "2026-08-08T00:00:00Z")
611 .with_link("CHAT with spaces.md", None)];
612
613 let text = serialize(&doc);
614 let parsed = parse(&text).expect("parses");
615
616 assert_eq!(parsed.title, "Title: with punctuation");
617 assert_eq!(parsed.messages[0].avatar, "karen from hr");
618 assert_eq!(parsed.messages[0].link.as_deref(), Some("CHAT with spaces.md"));
619 assert_eq!(text, serialize(&parsed));
620 }
621
622 #[test]
623 fn missing_front_matter_is_an_error() {
624 assert!(parse("just some markdown\n").is_err());
625 }
626}