1use anyhow::Result;
75use serde::{Deserialize, Serialize};
76use std::fmt;
77use uuid::Uuid;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
81pub enum AgentDocFormat {
82 #[clap(alias = "inline")]
84 Append,
85 Template,
87}
88
89impl fmt::Display for AgentDocFormat {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 Self::Append => write!(f, "inline"),
93 Self::Template => write!(f, "template"),
94 }
95 }
96}
97
98impl Serialize for AgentDocFormat {
99 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
100 where
101 S: serde::Serializer,
102 {
103 match self {
104 Self::Append => serializer.serialize_str("inline"),
105 Self::Template => serializer.serialize_str("template"),
106 }
107 }
108}
109
110impl<'de> Deserialize<'de> for AgentDocFormat {
111 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
112 where
113 D: serde::Deserializer<'de>,
114 {
115 let s = String::deserialize(deserializer)?;
116 match s.as_str() {
117 "append" | "inline" => Ok(Self::Append),
118 "template" => Ok(Self::Template),
119 other => Err(serde::de::Error::unknown_variant(other, &["inline", "append", "template"])),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
126#[serde(rename_all = "lowercase")]
127pub enum AgentDocWrite {
128 Merge,
130 Crdt,
132}
133
134impl fmt::Display for AgentDocWrite {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 match self {
137 Self::Merge => write!(f, "merge"),
138 Self::Crdt => write!(f, "crdt"),
139 }
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ResolvedMode {
146 pub format: AgentDocFormat,
147 pub write: AgentDocWrite,
148}
149
150impl ResolvedMode {
151 pub fn is_template(&self) -> bool {
152 self.format == AgentDocFormat::Template
153 }
154
155 pub fn is_append(&self) -> bool {
156 self.format == AgentDocFormat::Append
157 }
158
159 pub fn is_crdt(&self) -> bool {
160 self.write == AgentDocWrite::Crdt
161 }
162}
163
164#[derive(Debug, Default, Clone, Serialize, Deserialize)]
166pub struct StreamConfig {
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub interval: Option<u64>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub strip_ansi: Option<bool>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub target: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub thinking: Option<bool>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub thinking_target: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub max_lines: Option<usize>,
187}
188
189#[derive(Debug, Default, Serialize, Deserialize)]
190pub struct Frontmatter {
191 #[serde(
194 default,
195 skip_serializing_if = "Option::is_none",
196 rename = "agent_doc_session",
197 alias = "session"
198 )]
199 pub session: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub resume: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub agent: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub model: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub branch: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub tmux_session: Option<String>,
217 #[serde(
221 default,
222 skip_serializing_if = "Option::is_none",
223 rename = "agent_doc_mode",
224 alias = "mode",
225 alias = "response_mode"
226 )]
227 pub mode: Option<String>,
228 #[serde(
230 default,
231 skip_serializing_if = "Option::is_none",
232 rename = "agent_doc_format"
233 )]
234 pub format: Option<AgentDocFormat>,
235 #[serde(
237 default,
238 skip_serializing_if = "Option::is_none",
239 rename = "agent_doc_write"
240 )]
241 pub write_mode: Option<AgentDocWrite>,
242 #[serde(
244 default,
245 skip_serializing_if = "Option::is_none",
246 rename = "agent_doc_stream"
247 )]
248 pub stream_config: Option<StreamConfig>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub claude_args: Option<String>,
253 #[serde(
256 default,
257 skip_serializing_if = "Option::is_none",
258 rename = "agent_doc_debounce"
259 )]
260 pub debounce_ms: Option<u64>,
261 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
264 pub links: Vec<String>,
265 #[serde(
269 default,
270 skip_serializing_if = "Option::is_none",
271 rename = "agent_doc_auto_compact"
272 )]
273 pub auto_compact: Option<usize>,
274 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
296 pub hooks: std::collections::HashMap<String, Vec<String>>,
297}
298
299impl Frontmatter {
300 pub fn resolve_mode(&self) -> ResolvedMode {
307 let mut format = AgentDocFormat::Template;
309 let mut write = AgentDocWrite::Crdt;
310
311 if let Some(ref mode_str) = self.mode {
313 match mode_str.as_str() {
314 "append" => {
315 format = AgentDocFormat::Append;
316 }
318 "template" => {
319 format = AgentDocFormat::Template;
320 }
322 "stream" => {
323 format = AgentDocFormat::Template;
324 write = AgentDocWrite::Crdt;
325 }
326 _ => {} }
328 }
329
330 if let Some(f) = self.format {
332 format = f;
333 }
334 if let Some(w) = self.write_mode {
335 write = w;
336 }
337
338 ResolvedMode { format, write }
339 }
340}
341
342pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
345 if !content.starts_with("---\n") {
346 return Ok((Frontmatter::default(), content));
347 }
348 let rest = &content[4..]; let end = rest
350 .find("\n---\n")
351 .or_else(|| rest.find("\n---"))
352 .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
353 let yaml = &rest[..end];
354 let fm: Frontmatter = serde_yaml::from_str(yaml)?;
355 let body_start = 4 + end + 4; let body = if body_start <= content.len() {
357 &content[body_start..]
358 } else {
359 ""
360 };
361 Ok((fm, body))
362}
363
364pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
366 let yaml = serde_yaml::to_string(fm)?;
367 Ok(format!("---\n{}---\n{}", yaml, body))
368}
369
370pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
372 let (mut fm, body) = parse(content)?;
373 fm.session = Some(session_id.to_string());
374 write(&fm, body)
375}
376
377pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
379 let (mut fm, body) = parse(content)?;
380 fm.resume = Some(resume_id.to_string());
381 write(&fm, body)
382}
383
384pub fn set_format_and_write(
386 content: &str,
387 format: AgentDocFormat,
388 write_mode: AgentDocWrite,
389) -> Result<String> {
390 let (mut fm, body) = parse(content)?;
391 fm.format = Some(format);
392 fm.write_mode = Some(write_mode);
393 fm.mode = None;
394 write(&fm, body)
395}
396
397pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
403 let (mut fm, body) = parse(content)?;
404 let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
405 .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
406 let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
407
408 for (key, value) in &mapping {
409 let key_str = key.as_str().unwrap_or("");
410 let val_str = || value.as_str().map(|s| s.to_string());
411 match key_str {
412 "agent_doc_session" | "session" => fm.session = val_str(),
413 "resume" => fm.resume = val_str(),
414 "agent" => fm.agent = val_str(),
415 "model" => fm.model = val_str(),
416 "branch" => fm.branch = val_str(),
417 "tmux_session" => fm.tmux_session = val_str(),
418 "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
419 "agent_doc_format" => {
420 if let Some(s) = value.as_str()
421 && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
422 {
423 fm.format = Some(f);
424 }
425 }
426 "agent_doc_write" => {
427 if let Some(s) = value.as_str()
428 && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
429 {
430 fm.write_mode = Some(w);
431 }
432 }
433 "claude_args" => fm.claude_args = val_str(),
434 _ => {
435 eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
436 }
437 }
438 }
439
440 write(&fm, body)
441}
442
443pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
449 let (mut fm, body) = parse(content)?;
450 fm.tmux_session = Some(session_name.to_string());
451 write(&fm, body)
452}
453
454pub fn ensure_session(content: &str) -> Result<(String, String)> {
459 let (fm, _body) = parse(content)?;
460 if let Some(ref session_id) = fm.session {
461 return Ok((content.to_string(), session_id.clone()));
463 }
464 let session_id = Uuid::new_v4().to_string();
465 let updated = set_session_id(content, &session_id)?;
466 Ok((updated, session_id))
467}
468
469pub fn read_session_id(file: &std::path::Path) -> Option<String> {
471 let content = std::fs::read_to_string(file).ok()?;
472 let (fm, _) = parse(&content).ok()?;
473 fm.session
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn parse_no_frontmatter() {
482 let content = "# Hello\n\nBody text.\n";
483 let (fm, body) = parse(content).unwrap();
484 assert!(fm.session.is_none());
485 assert!(fm.agent.is_none());
486 assert!(fm.model.is_none());
487 assert!(fm.branch.is_none());
488 assert_eq!(body, content);
489 }
490
491 #[test]
492 fn parse_all_fields() {
493 let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
494 let (fm, body) = parse(content).unwrap();
495 assert_eq!(fm.session.as_deref(), Some("abc-123"));
496 assert_eq!(fm.agent.as_deref(), Some("claude"));
497 assert_eq!(fm.model.as_deref(), Some("opus"));
498 assert_eq!(fm.branch.as_deref(), Some("main"));
499 assert!(body.contains("Body"));
500 }
501
502 #[test]
503 fn parse_partial_fields() {
504 let content = "---\nsession: xyz\n---\n# Doc\n";
505 let (fm, body) = parse(content).unwrap();
506 assert_eq!(fm.session.as_deref(), Some("xyz"));
507 assert!(fm.agent.is_none());
508 assert!(body.contains("# Doc"));
509 }
510
511 #[test]
512 fn parse_null_fields() {
513 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
514 let (fm, body) = parse(content).unwrap();
515 assert!(fm.session.is_none());
516 assert!(fm.agent.is_none());
517 assert!(fm.model.is_none());
518 assert!(fm.branch.is_none());
519 assert!(body.contains("Body"));
520 }
521
522 #[test]
523 fn parse_unterminated_frontmatter() {
524 let content = "---\nsession: abc\nno closing block";
525 let err = parse(content).unwrap_err();
526 assert!(err.to_string().contains("Unterminated frontmatter"));
527 }
528
529 #[test]
530 fn parse_closing_at_eof() {
531 let content = "---\nsession: abc\n---";
532 let (fm, body) = parse(content).unwrap();
533 assert_eq!(fm.session.as_deref(), Some("abc"));
534 assert_eq!(body, "");
535 }
536
537 #[test]
538 fn parse_empty_body() {
539 let content = "---\nsession: abc\n---\n";
540 let (fm, _body) = parse(content).unwrap();
541 assert_eq!(fm.session.as_deref(), Some("abc"));
542 }
543
544 #[test]
545 fn write_roundtrip() {
546 let fm = Frontmatter {
548 session: Some("test-id".to_string()),
549 resume: Some("resume-id".to_string()),
550 agent: Some("claude".to_string()),
551 model: Some("opus".to_string()),
552 branch: Some("dev".to_string()),
553 tmux_session: None,
554 mode: None,
555 format: None,
556 write_mode: None,
557 stream_config: None,
558 claude_args: None,
559 debounce_ms: None,
560 links: vec![],
561 auto_compact: None,
562 hooks: std::collections::HashMap::new(),
563 };
564 let body = "# Hello\n\nBody text.\n";
565 let written = write(&fm, body).unwrap();
566 let (fm2, body2) = parse(&written).unwrap();
567 assert_eq!(fm2.session, fm.session);
568 assert_eq!(fm2.agent, fm.agent);
569 assert_eq!(fm2.model, fm.model);
570 assert_eq!(fm2.branch, fm.branch);
571 assert!(body2.contains("# Hello"));
573 assert!(body2.contains("Body text."));
574 }
575
576 #[test]
577 fn write_default_frontmatter() {
578 let fm = Frontmatter::default();
579 let result = write(&fm, "body\n").unwrap();
580 assert!(result.starts_with("---\n"));
581 assert!(result.ends_with("---\nbody\n"));
582 }
583
584 #[test]
585 fn write_preserves_body_content() {
586 let fm = Frontmatter::default();
587 let body = "# Title\n\nSome **markdown** with `code`.\n";
588 let result = write(&fm, body).unwrap();
589 assert!(result.contains("# Title"));
590 assert!(result.contains("Some **markdown** with `code`."));
591 }
592
593 #[test]
594 fn set_session_id_creates_frontmatter() {
595 let content = "# No frontmatter\n\nJust body.\n";
596 let result = set_session_id(content, "new-session").unwrap();
597 let (fm, body) = parse(&result).unwrap();
598 assert_eq!(fm.session.as_deref(), Some("new-session"));
599 assert!(body.contains("# No frontmatter"));
600 }
601
602 #[test]
603 fn set_session_id_updates_existing() {
604 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
605 let result = set_session_id(content, "new-id").unwrap();
606 let (fm, body) = parse(&result).unwrap();
607 assert_eq!(fm.session.as_deref(), Some("new-id"));
608 assert_eq!(fm.agent.as_deref(), Some("claude"));
609 assert!(body.contains("Body"));
610 }
611
612 #[test]
613 fn set_session_id_preserves_other_fields() {
614 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
615 let result = set_session_id(content, "new").unwrap();
616 let (fm, _) = parse(&result).unwrap();
617 assert_eq!(fm.session.as_deref(), Some("new"));
618 assert_eq!(fm.agent.as_deref(), Some("claude"));
619 assert_eq!(fm.model.as_deref(), Some("opus"));
620 assert_eq!(fm.branch.as_deref(), Some("dev"));
621 }
622
623 #[test]
624 fn ensure_session_no_frontmatter() {
625 let content = "# Hello\n\nBody.\n";
626 let (updated, sid) = ensure_session(content).unwrap();
627 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
630 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
631 assert!(body.contains("# Hello"));
632 }
633
634 #[test]
635 fn ensure_session_null_session() {
636 let content = "---\nsession:\nagent: claude\n---\nBody\n";
637 let (updated, sid) = ensure_session(content).unwrap();
638 assert_eq!(sid.len(), 36);
639 let (fm, body) = parse(&updated).unwrap();
640 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
641 assert_eq!(fm.agent.as_deref(), Some("claude"));
642 assert!(body.contains("Body"));
643 }
644
645 #[test]
646 fn ensure_session_existing_session() {
647 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
648 let (updated, sid) = ensure_session(content).unwrap();
649 assert_eq!(sid, "existing-id");
650 assert_eq!(updated, content);
652 }
653
654 #[test]
655 fn parse_legacy_session_field() {
656 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
658 let (fm, body) = parse(content).unwrap();
659 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
660 assert_eq!(fm.agent.as_deref(), Some("claude"));
661 assert!(body.contains("Body"));
662 }
663
664 #[test]
665 fn parse_agent_doc_mode_canonical() {
666 let content = "---\nagent_doc_mode: template\n---\nBody\n";
667 let (fm, _) = parse(content).unwrap();
668 assert_eq!(fm.mode.as_deref(), Some("template"));
669 }
670
671 #[test]
672 fn parse_mode_shorthand_alias() {
673 let content = "---\nmode: template\n---\nBody\n";
674 let (fm, _) = parse(content).unwrap();
675 assert_eq!(fm.mode.as_deref(), Some("template"));
676 }
677
678 #[test]
679 fn parse_response_mode_legacy_alias() {
680 let content = "---\nresponse_mode: template\n---\nBody\n";
681 let (fm, _) = parse(content).unwrap();
682 assert_eq!(fm.mode.as_deref(), Some("template"));
683 }
684
685 #[test]
686 fn write_uses_agent_doc_mode_field() {
687 #[allow(deprecated)]
688 let fm = Frontmatter {
689 mode: Some("template".to_string()),
690 ..Default::default()
691 };
692 let result = write(&fm, "body\n").unwrap();
693 assert!(result.contains("agent_doc_mode:"));
694 assert!(!result.contains("response_mode:"));
695 assert!(!result.contains("\nmode:"));
696 }
697
698 #[test]
699 fn write_uses_new_field_name() {
700 let fm = Frontmatter {
701 session: Some("test-id".to_string()),
702 ..Default::default()
703 };
704 let result = write(&fm, "body\n").unwrap();
705 assert!(result.contains("agent_doc_session:"));
706 assert!(!result.contains("\nsession:"));
707 }
708
709 #[test]
712 fn resolve_mode_defaults() {
713 let fm = Frontmatter::default();
714 let resolved = fm.resolve_mode();
715 assert_eq!(resolved.format, AgentDocFormat::Template);
716 assert_eq!(resolved.write, AgentDocWrite::Crdt);
717 }
718
719 #[test]
720 fn resolve_mode_from_deprecated_append() {
721 let content = "---\nagent_doc_mode: append\n---\nBody\n";
722 let (fm, _) = parse(content).unwrap();
723 let resolved = fm.resolve_mode();
724 assert_eq!(resolved.format, AgentDocFormat::Append);
725 assert_eq!(resolved.write, AgentDocWrite::Crdt);
726 }
727
728 #[test]
729 fn resolve_mode_from_deprecated_template() {
730 let content = "---\nagent_doc_mode: template\n---\nBody\n";
731 let (fm, _) = parse(content).unwrap();
732 let resolved = fm.resolve_mode();
733 assert_eq!(resolved.format, AgentDocFormat::Template);
734 assert_eq!(resolved.write, AgentDocWrite::Crdt);
735 }
736
737 #[test]
738 fn resolve_mode_from_deprecated_stream() {
739 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
740 let (fm, _) = parse(content).unwrap();
741 let resolved = fm.resolve_mode();
742 assert_eq!(resolved.format, AgentDocFormat::Template);
743 assert_eq!(resolved.write, AgentDocWrite::Crdt);
744 }
745
746 #[test]
747 fn resolve_mode_new_fields_override_deprecated() {
748 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
749 let (fm, _) = parse(content).unwrap();
750 let resolved = fm.resolve_mode();
751 assert_eq!(resolved.format, AgentDocFormat::Template);
752 assert_eq!(resolved.write, AgentDocWrite::Merge);
753 }
754
755 #[test]
756 fn resolve_mode_explicit_new_fields_only() {
757 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
758 let (fm, _) = parse(content).unwrap();
759 let resolved = fm.resolve_mode();
760 assert_eq!(resolved.format, AgentDocFormat::Append);
761 assert_eq!(resolved.write, AgentDocWrite::Crdt);
762 }
763
764 #[test]
765 fn resolve_mode_partial_new_field_format_only() {
766 let content = "---\nagent_doc_format: append\n---\nBody\n";
767 let (fm, _) = parse(content).unwrap();
768 let resolved = fm.resolve_mode();
769 assert_eq!(resolved.format, AgentDocFormat::Append);
770 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
772
773 #[test]
774 fn resolve_mode_partial_new_field_write_only() {
775 let content = "---\nagent_doc_write: merge\n---\nBody\n";
776 let (fm, _) = parse(content).unwrap();
777 let resolved = fm.resolve_mode();
778 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
780 }
781
782 #[test]
783 fn resolve_mode_helper_methods() {
784 let fm = Frontmatter::default();
785 let resolved = fm.resolve_mode();
786 assert!(resolved.is_template());
787 assert!(!resolved.is_append());
788 assert!(resolved.is_crdt());
789 }
790
791 #[test]
792 fn parse_new_format_field() {
793 let content = "---\nagent_doc_format: template\n---\nBody\n";
794 let (fm, _) = parse(content).unwrap();
795 assert_eq!(fm.format, Some(AgentDocFormat::Template));
796 }
797
798 #[test]
799 fn parse_new_write_field() {
800 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
801 let (fm, _) = parse(content).unwrap();
802 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
803 }
804
805 #[test]
806 fn write_uses_new_format_write_fields() {
807 let fm = Frontmatter {
808 format: Some(AgentDocFormat::Template),
809 write_mode: Some(AgentDocWrite::Crdt),
810 ..Default::default()
811 };
812 let result = write(&fm, "body\n").unwrap();
813 assert!(result.contains("agent_doc_format:"));
814 assert!(result.contains("agent_doc_write:"));
815 assert!(!result.contains("agent_doc_mode:"));
816 }
817
818 #[test]
819 fn set_format_and_write_clears_deprecated_mode() {
820 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
821 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
822 let (fm, _) = parse(&result).unwrap();
823 assert!(fm.mode.is_none());
824 assert_eq!(fm.format, Some(AgentDocFormat::Template));
825 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
826 }
827
828 #[test]
831 fn merge_fields_adds_new_field() {
832 let content = "---\nagent_doc_session: abc\n---\nBody\n";
833 let result = merge_fields(content, "model: opus").unwrap();
834 let (fm, body) = parse(&result).unwrap();
835 assert_eq!(fm.session.as_deref(), Some("abc"));
836 assert_eq!(fm.model.as_deref(), Some("opus"));
837 assert!(body.contains("Body"));
838 }
839
840 #[test]
841 fn merge_fields_updates_existing_field() {
842 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
843 let result = merge_fields(content, "model: opus").unwrap();
844 let (fm, _) = parse(&result).unwrap();
845 assert_eq!(fm.model.as_deref(), Some("opus"));
846 assert_eq!(fm.session.as_deref(), Some("abc"));
847 }
848
849 #[test]
850 fn merge_fields_multiple_fields() {
851 let content = "---\nagent_doc_session: abc\n---\nBody\n";
852 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
853 let (fm, _) = parse(&result).unwrap();
854 assert_eq!(fm.model.as_deref(), Some("opus"));
855 assert_eq!(fm.agent.as_deref(), Some("claude"));
856 assert_eq!(fm.branch.as_deref(), Some("main"));
857 }
858
859 #[test]
860 fn merge_fields_format_enum() {
861 let content = "---\nagent_doc_session: abc\n---\nBody\n";
862 let result = merge_fields(content, "agent_doc_format: append").unwrap();
863 let (fm, _) = parse(&result).unwrap();
864 assert_eq!(fm.format, Some(AgentDocFormat::Append));
865 }
866
867 #[test]
868 fn merge_fields_write_enum() {
869 let content = "---\nagent_doc_session: abc\n---\nBody\n";
870 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
871 let (fm, _) = parse(&result).unwrap();
872 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
873 }
874
875 #[test]
876 fn merge_fields_ignores_unknown() {
877 let content = "---\nagent_doc_session: abc\n---\nBody\n";
878 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
879 let (fm, _) = parse(&result).unwrap();
880 assert_eq!(fm.model.as_deref(), Some("opus"));
881 }
882
883 #[test]
884 fn merge_fields_preserves_body() {
885 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
886 let result = merge_fields(content, "model: opus").unwrap();
887 assert!(result.contains("# Title"));
888 assert!(result.contains("Some **markdown** content."));
889 }
890
891 #[test]
892 fn set_format_and_write_clears_deprecated() {
893 let content = "---\nagent_doc_mode: append\n---\nBody\n";
894 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
895 let (fm, _) = parse(&result).unwrap();
896 assert!(fm.mode.is_none());
897 assert_eq!(fm.format, Some(AgentDocFormat::Template));
898 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
899 }
900
901 #[test]
902 fn hooks_roundtrip() {
903 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
904 let (fm, _) = parse(content).unwrap();
905 assert_eq!(fm.hooks.get("session_start"), Some(&vec!["echo start {{session_id}}".to_string()]));
906 assert_eq!(fm.hooks.get("post_write"), Some(&vec!["notify {{file}}".to_string()]));
907 }
908
909 #[test]
910 fn hooks_omitted_when_empty() {
911 let fm = Frontmatter::default();
912 let result = write(&fm, "body\n").unwrap();
913 assert!(!result.contains("hooks"));
914 }
915
916 #[test]
917 fn hooks_absent_parses_as_empty() {
918 let content = "---\nsession: abc\n---\nBody\n";
919 let (fm, _) = parse(content).unwrap();
920 assert!(fm.hooks.is_empty());
921 }
922}