1use anyhow::Result;
75use serde::{Deserialize, Serialize};
76use std::fmt;
77use uuid::Uuid;
78
79use crate::model_tier::Tier;
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
83pub enum AgentDocFormat {
84 #[clap(alias = "inline")]
86 Append,
87 Template,
89}
90
91impl fmt::Display for AgentDocFormat {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 match self {
94 Self::Append => write!(f, "inline"),
95 Self::Template => write!(f, "template"),
96 }
97 }
98}
99
100impl Serialize for AgentDocFormat {
101 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
102 where
103 S: serde::Serializer,
104 {
105 match self {
106 Self::Append => serializer.serialize_str("inline"),
107 Self::Template => serializer.serialize_str("template"),
108 }
109 }
110}
111
112impl<'de> Deserialize<'de> for AgentDocFormat {
113 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
114 where
115 D: serde::Deserializer<'de>,
116 {
117 let s = String::deserialize(deserializer)?;
118 match s.as_str() {
119 "append" | "inline" => Ok(Self::Append),
120 "template" => Ok(Self::Template),
121 other => Err(serde::de::Error::unknown_variant(other, &["inline", "append", "template"])),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
128#[serde(rename_all = "lowercase")]
129pub enum AgentDocWrite {
130 Merge,
132 Crdt,
134}
135
136impl fmt::Display for AgentDocWrite {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 Self::Merge => write!(f, "merge"),
140 Self::Crdt => write!(f, "crdt"),
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ResolvedMode {
148 pub format: AgentDocFormat,
149 pub write: AgentDocWrite,
150}
151
152impl ResolvedMode {
153 pub fn is_template(&self) -> bool {
154 self.format == AgentDocFormat::Template
155 }
156
157 pub fn is_append(&self) -> bool {
158 self.format == AgentDocFormat::Append
159 }
160
161 pub fn is_crdt(&self) -> bool {
162 self.write == AgentDocWrite::Crdt
163 }
164}
165
166#[derive(Debug, Default, Clone, Serialize, Deserialize)]
168pub struct StreamConfig {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub interval: Option<u64>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub strip_ansi: Option<bool>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub target: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub thinking: Option<bool>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub thinking_target: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub max_lines: Option<usize>,
189}
190
191#[derive(Debug, Default, Serialize, Deserialize)]
192pub struct Frontmatter {
193 #[serde(
196 default,
197 skip_serializing_if = "Option::is_none",
198 rename = "agent_doc_session",
199 alias = "session"
200 )]
201 pub session: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub resume: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub agent: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub model: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub branch: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub tmux_session: Option<String>,
219 #[serde(
223 default,
224 skip_serializing_if = "Option::is_none",
225 rename = "agent_doc_mode",
226 alias = "mode",
227 alias = "response_mode"
228 )]
229 pub mode: Option<String>,
230 #[serde(
232 default,
233 skip_serializing_if = "Option::is_none",
234 rename = "agent_doc_format"
235 )]
236 pub format: Option<AgentDocFormat>,
237 #[serde(
239 default,
240 skip_serializing_if = "Option::is_none",
241 rename = "agent_doc_write"
242 )]
243 pub write_mode: Option<AgentDocWrite>,
244 #[serde(
246 default,
247 skip_serializing_if = "Option::is_none",
248 rename = "agent_doc_stream"
249 )]
250 pub stream_config: Option<StreamConfig>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub claude_args: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub no_mcp: Option<bool>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub enable_tool_search: Option<bool>,
261 #[serde(
264 default,
265 skip_serializing_if = "Option::is_none",
266 rename = "agent_doc_debounce"
267 )]
268 pub debounce_ms: Option<u64>,
269 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
272 pub links: Vec<String>,
273 #[serde(
277 default,
278 skip_serializing_if = "Option::is_none",
279 rename = "agent_doc_auto_compact"
280 )]
281 pub auto_compact: Option<usize>,
282 #[serde(
287 default,
288 skip_serializing_if = "Option::is_none",
289 rename = "agent_doc_model_tier"
290 )]
291 pub model_tier: Option<Tier>,
292 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
314 pub hooks: std::collections::HashMap<String, Vec<String>>,
315 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
322 pub env: indexmap::IndexMap<String, Option<String>>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub agent_doc_env_inherit: Option<bool>,
331 #[serde(
337 default,
338 skip_serializing_if = "Option::is_none",
339 rename = "agent_doc_cwd"
340 )]
341 pub cwd: Option<String>,
342}
343
344impl Frontmatter {
345 pub fn resolve_mode(&self) -> ResolvedMode {
352 let mut format = AgentDocFormat::Template;
354 let mut write = AgentDocWrite::Crdt;
355
356 if let Some(ref mode_str) = self.mode {
358 match mode_str.as_str() {
359 "append" => {
360 format = AgentDocFormat::Append;
361 }
363 "template" => {
364 format = AgentDocFormat::Template;
365 }
367 "stream" => {
368 format = AgentDocFormat::Template;
369 write = AgentDocWrite::Crdt;
370 }
371 _ => {} }
373 }
374
375 if let Some(f) = self.format {
377 format = f;
378 }
379 if let Some(w) = self.write_mode {
380 write = w;
381 }
382
383 ResolvedMode { format, write }
384 }
385}
386
387pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
390 if !content.starts_with("---\n") {
391 return Ok((Frontmatter::default(), content));
392 }
393 let rest = &content[4..]; let end = rest
395 .find("\n---\n")
396 .or_else(|| rest.find("\n---"))
397 .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
398 let yaml = &rest[..end];
399 let fm: Frontmatter = serde_yaml::from_str(yaml)?;
400 let body_start = 4 + end + 4; let body = if body_start <= content.len() {
402 &content[body_start..]
403 } else {
404 ""
405 };
406 Ok((fm, body))
407}
408
409pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
411 let yaml = serde_yaml::to_string(fm)?;
412 Ok(format!("---\n{}---\n{}", yaml, body))
413}
414
415pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
417 let (mut fm, body) = parse(content)?;
418 fm.session = Some(session_id.to_string());
419 write(&fm, body)
420}
421
422pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
424 let (mut fm, body) = parse(content)?;
425 fm.resume = Some(resume_id.to_string());
426 write(&fm, body)
427}
428
429pub fn set_format_and_write(
431 content: &str,
432 format: AgentDocFormat,
433 write_mode: AgentDocWrite,
434) -> Result<String> {
435 let (mut fm, body) = parse(content)?;
436 fm.format = Some(format);
437 fm.write_mode = Some(write_mode);
438 fm.mode = None;
439 write(&fm, body)
440}
441
442pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
448 let (mut fm, body) = parse(content)?;
449 let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
450 .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
451 let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
452
453 for (key, value) in &mapping {
454 let key_str = key.as_str().unwrap_or("");
455 let val_str = || value.as_str().map(|s| s.to_string());
456 match key_str {
457 "agent_doc_session" | "session" => fm.session = val_str(),
458 "resume" => fm.resume = val_str(),
459 "agent" => fm.agent = val_str(),
460 "model" => fm.model = val_str(),
461 "branch" => fm.branch = val_str(),
462 "tmux_session" => fm.tmux_session = val_str(),
463 "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
464 "agent_doc_format" => {
465 if let Some(s) = value.as_str()
466 && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
467 {
468 fm.format = Some(f);
469 }
470 }
471 "agent_doc_write" => {
472 if let Some(s) = value.as_str()
473 && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
474 {
475 fm.write_mode = Some(w);
476 }
477 }
478 "claude_args" => fm.claude_args = val_str(),
479 _ => {
480 eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
481 }
482 }
483 }
484
485 write(&fm, body)
486}
487
488pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
494 let (mut fm, body) = parse(content)?;
495 fm.tmux_session = Some(session_name.to_string());
496 write(&fm, body)
497}
498
499pub fn ensure_session(content: &str) -> Result<(String, String)> {
504 let (fm, _body) = parse(content)?;
505 if let Some(ref session_id) = fm.session {
506 return Ok((content.to_string(), session_id.clone()));
508 }
509 let session_id = Uuid::new_v4().to_string();
510 let updated = set_session_id(content, &session_id)?;
511 Ok((updated, session_id))
512}
513
514pub fn read_session_id(file: &std::path::Path) -> Option<String> {
516 let content = std::fs::read_to_string(file).ok()?;
517 let (fm, _) = parse(&content).ok()?;
518 fm.session
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn parse_no_frontmatter() {
527 let content = "# Hello\n\nBody text.\n";
528 let (fm, body) = parse(content).unwrap();
529 assert!(fm.session.is_none());
530 assert!(fm.agent.is_none());
531 assert!(fm.model.is_none());
532 assert!(fm.branch.is_none());
533 assert_eq!(body, content);
534 }
535
536 #[test]
537 fn parse_all_fields() {
538 let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
539 let (fm, body) = parse(content).unwrap();
540 assert_eq!(fm.session.as_deref(), Some("abc-123"));
541 assert_eq!(fm.agent.as_deref(), Some("claude"));
542 assert_eq!(fm.model.as_deref(), Some("opus"));
543 assert_eq!(fm.branch.as_deref(), Some("main"));
544 assert!(body.contains("Body"));
545 }
546
547 #[test]
548 fn parse_partial_fields() {
549 let content = "---\nsession: xyz\n---\n# Doc\n";
550 let (fm, body) = parse(content).unwrap();
551 assert_eq!(fm.session.as_deref(), Some("xyz"));
552 assert!(fm.agent.is_none());
553 assert!(body.contains("# Doc"));
554 }
555
556 #[test]
557 fn parse_model_tier_high() {
558 let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
559 let (fm, _) = parse(content).unwrap();
560 assert_eq!(fm.model_tier, Some(Tier::High));
561 }
562
563 #[test]
564 fn parse_model_tier_low() {
565 let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
566 let (fm, _) = parse(content).unwrap();
567 assert_eq!(fm.model_tier, Some(Tier::Low));
568 }
569
570 #[test]
571 fn parse_model_tier_med() {
572 let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
573 let (fm, _) = parse(content).unwrap();
574 assert_eq!(fm.model_tier, Some(Tier::Med));
575 }
576
577 #[test]
578 fn parse_model_tier_auto() {
579 let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
580 let (fm, _) = parse(content).unwrap();
581 assert_eq!(fm.model_tier, Some(Tier::Auto));
582 }
583
584 #[test]
585 fn parse_model_tier_absent() {
586 let content = "---\nagent: claude\n---\nBody\n";
587 let (fm, _) = parse(content).unwrap();
588 assert_eq!(fm.model_tier, None);
589 }
590
591 #[test]
592 fn parse_model_tier_invalid_rejected() {
593 let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
594 let result = parse(content);
595 assert!(result.is_err(), "invalid tier value should fail to parse");
596 }
597
598 #[test]
599 fn write_model_tier_roundtrip() {
600 let fm = Frontmatter {
601 model_tier: Some(Tier::High),
602 ..Default::default()
603 };
604 let doc = write(&fm, "Body\n").unwrap();
605 let (parsed, _) = parse(&doc).unwrap();
606 assert_eq!(parsed.model_tier, Some(Tier::High));
607 assert!(doc.contains("agent_doc_model_tier: high"));
608 }
609
610 #[test]
611 fn parse_null_fields() {
612 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
613 let (fm, body) = parse(content).unwrap();
614 assert!(fm.session.is_none());
615 assert!(fm.agent.is_none());
616 assert!(fm.model.is_none());
617 assert!(fm.branch.is_none());
618 assert!(body.contains("Body"));
619 }
620
621 #[test]
622 fn parse_unterminated_frontmatter() {
623 let content = "---\nsession: abc\nno closing block";
624 let err = parse(content).unwrap_err();
625 assert!(err.to_string().contains("Unterminated frontmatter"));
626 }
627
628 #[test]
629 fn parse_closing_at_eof() {
630 let content = "---\nsession: abc\n---";
631 let (fm, body) = parse(content).unwrap();
632 assert_eq!(fm.session.as_deref(), Some("abc"));
633 assert_eq!(body, "");
634 }
635
636 #[test]
637 fn parse_empty_body() {
638 let content = "---\nsession: abc\n---\n";
639 let (fm, _body) = parse(content).unwrap();
640 assert_eq!(fm.session.as_deref(), Some("abc"));
641 }
642
643 #[test]
644 fn write_roundtrip() {
645 let fm = Frontmatter {
647 session: Some("test-id".to_string()),
648 resume: Some("resume-id".to_string()),
649 agent: Some("claude".to_string()),
650 model: Some("opus".to_string()),
651 branch: Some("dev".to_string()),
652 tmux_session: None,
653 mode: None,
654 format: None,
655 write_mode: None,
656 stream_config: None,
657 claude_args: None,
658 no_mcp: None,
659 enable_tool_search: None,
660 debounce_ms: None,
661 links: vec![],
662 auto_compact: None,
663 model_tier: None,
664 hooks: std::collections::HashMap::new(),
665 env: indexmap::IndexMap::new(),
666 agent_doc_env_inherit: None,
667 cwd: None,
668 };
669 let body = "# Hello\n\nBody text.\n";
670 let written = write(&fm, body).unwrap();
671 let (fm2, body2) = parse(&written).unwrap();
672 assert_eq!(fm2.session, fm.session);
673 assert_eq!(fm2.agent, fm.agent);
674 assert_eq!(fm2.model, fm.model);
675 assert_eq!(fm2.branch, fm.branch);
676 assert!(body2.contains("# Hello"));
678 assert!(body2.contains("Body text."));
679 }
680
681 #[test]
682 fn write_default_frontmatter() {
683 let fm = Frontmatter::default();
684 let result = write(&fm, "body\n").unwrap();
685 assert!(result.starts_with("---\n"));
686 assert!(result.ends_with("---\nbody\n"));
687 }
688
689 #[test]
690 fn write_preserves_body_content() {
691 let fm = Frontmatter::default();
692 let body = "# Title\n\nSome **markdown** with `code`.\n";
693 let result = write(&fm, body).unwrap();
694 assert!(result.contains("# Title"));
695 assert!(result.contains("Some **markdown** with `code`."));
696 }
697
698 #[test]
699 fn set_session_id_creates_frontmatter() {
700 let content = "# No frontmatter\n\nJust body.\n";
701 let result = set_session_id(content, "new-session").unwrap();
702 let (fm, body) = parse(&result).unwrap();
703 assert_eq!(fm.session.as_deref(), Some("new-session"));
704 assert!(body.contains("# No frontmatter"));
705 }
706
707 #[test]
708 fn set_session_id_updates_existing() {
709 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
710 let result = set_session_id(content, "new-id").unwrap();
711 let (fm, body) = parse(&result).unwrap();
712 assert_eq!(fm.session.as_deref(), Some("new-id"));
713 assert_eq!(fm.agent.as_deref(), Some("claude"));
714 assert!(body.contains("Body"));
715 }
716
717 #[test]
718 fn set_session_id_preserves_other_fields() {
719 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
720 let result = set_session_id(content, "new").unwrap();
721 let (fm, _) = parse(&result).unwrap();
722 assert_eq!(fm.session.as_deref(), Some("new"));
723 assert_eq!(fm.agent.as_deref(), Some("claude"));
724 assert_eq!(fm.model.as_deref(), Some("opus"));
725 assert_eq!(fm.branch.as_deref(), Some("dev"));
726 }
727
728 #[test]
729 fn ensure_session_no_frontmatter() {
730 let content = "# Hello\n\nBody.\n";
731 let (updated, sid) = ensure_session(content).unwrap();
732 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
735 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
736 assert!(body.contains("# Hello"));
737 }
738
739 #[test]
740 fn ensure_session_null_session() {
741 let content = "---\nsession:\nagent: claude\n---\nBody\n";
742 let (updated, sid) = ensure_session(content).unwrap();
743 assert_eq!(sid.len(), 36);
744 let (fm, body) = parse(&updated).unwrap();
745 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
746 assert_eq!(fm.agent.as_deref(), Some("claude"));
747 assert!(body.contains("Body"));
748 }
749
750 #[test]
751 fn ensure_session_existing_session() {
752 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
753 let (updated, sid) = ensure_session(content).unwrap();
754 assert_eq!(sid, "existing-id");
755 assert_eq!(updated, content);
757 }
758
759 #[test]
760 fn parse_legacy_session_field() {
761 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
763 let (fm, body) = parse(content).unwrap();
764 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
765 assert_eq!(fm.agent.as_deref(), Some("claude"));
766 assert!(body.contains("Body"));
767 }
768
769 #[test]
770 fn parse_agent_doc_mode_canonical() {
771 let content = "---\nagent_doc_mode: template\n---\nBody\n";
772 let (fm, _) = parse(content).unwrap();
773 assert_eq!(fm.mode.as_deref(), Some("template"));
774 }
775
776 #[test]
777 fn parse_mode_shorthand_alias() {
778 let content = "---\nmode: template\n---\nBody\n";
779 let (fm, _) = parse(content).unwrap();
780 assert_eq!(fm.mode.as_deref(), Some("template"));
781 }
782
783 #[test]
784 fn parse_response_mode_legacy_alias() {
785 let content = "---\nresponse_mode: template\n---\nBody\n";
786 let (fm, _) = parse(content).unwrap();
787 assert_eq!(fm.mode.as_deref(), Some("template"));
788 }
789
790 #[test]
791 fn write_uses_agent_doc_mode_field() {
792 #[allow(deprecated)]
793 let fm = Frontmatter {
794 mode: Some("template".to_string()),
795 ..Default::default()
796 };
797 let result = write(&fm, "body\n").unwrap();
798 assert!(result.contains("agent_doc_mode:"));
799 assert!(!result.contains("response_mode:"));
800 assert!(!result.contains("\nmode:"));
801 }
802
803 #[test]
804 fn write_uses_new_field_name() {
805 let fm = Frontmatter {
806 session: Some("test-id".to_string()),
807 ..Default::default()
808 };
809 let result = write(&fm, "body\n").unwrap();
810 assert!(result.contains("agent_doc_session:"));
811 assert!(!result.contains("\nsession:"));
812 }
813
814 #[test]
817 fn resolve_mode_defaults() {
818 let fm = Frontmatter::default();
819 let resolved = fm.resolve_mode();
820 assert_eq!(resolved.format, AgentDocFormat::Template);
821 assert_eq!(resolved.write, AgentDocWrite::Crdt);
822 }
823
824 #[test]
825 fn resolve_mode_from_deprecated_append() {
826 let content = "---\nagent_doc_mode: append\n---\nBody\n";
827 let (fm, _) = parse(content).unwrap();
828 let resolved = fm.resolve_mode();
829 assert_eq!(resolved.format, AgentDocFormat::Append);
830 assert_eq!(resolved.write, AgentDocWrite::Crdt);
831 }
832
833 #[test]
834 fn resolve_mode_from_deprecated_template() {
835 let content = "---\nagent_doc_mode: template\n---\nBody\n";
836 let (fm, _) = parse(content).unwrap();
837 let resolved = fm.resolve_mode();
838 assert_eq!(resolved.format, AgentDocFormat::Template);
839 assert_eq!(resolved.write, AgentDocWrite::Crdt);
840 }
841
842 #[test]
843 fn resolve_mode_from_deprecated_stream() {
844 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
845 let (fm, _) = parse(content).unwrap();
846 let resolved = fm.resolve_mode();
847 assert_eq!(resolved.format, AgentDocFormat::Template);
848 assert_eq!(resolved.write, AgentDocWrite::Crdt);
849 }
850
851 #[test]
852 fn resolve_mode_new_fields_override_deprecated() {
853 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
854 let (fm, _) = parse(content).unwrap();
855 let resolved = fm.resolve_mode();
856 assert_eq!(resolved.format, AgentDocFormat::Template);
857 assert_eq!(resolved.write, AgentDocWrite::Merge);
858 }
859
860 #[test]
861 fn resolve_mode_explicit_new_fields_only() {
862 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
863 let (fm, _) = parse(content).unwrap();
864 let resolved = fm.resolve_mode();
865 assert_eq!(resolved.format, AgentDocFormat::Append);
866 assert_eq!(resolved.write, AgentDocWrite::Crdt);
867 }
868
869 #[test]
870 fn resolve_mode_partial_new_field_format_only() {
871 let content = "---\nagent_doc_format: append\n---\nBody\n";
872 let (fm, _) = parse(content).unwrap();
873 let resolved = fm.resolve_mode();
874 assert_eq!(resolved.format, AgentDocFormat::Append);
875 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
877
878 #[test]
879 fn resolve_mode_partial_new_field_write_only() {
880 let content = "---\nagent_doc_write: merge\n---\nBody\n";
881 let (fm, _) = parse(content).unwrap();
882 let resolved = fm.resolve_mode();
883 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
885 }
886
887 #[test]
888 fn resolve_mode_helper_methods() {
889 let fm = Frontmatter::default();
890 let resolved = fm.resolve_mode();
891 assert!(resolved.is_template());
892 assert!(!resolved.is_append());
893 assert!(resolved.is_crdt());
894 }
895
896 #[test]
897 fn parse_new_format_field() {
898 let content = "---\nagent_doc_format: template\n---\nBody\n";
899 let (fm, _) = parse(content).unwrap();
900 assert_eq!(fm.format, Some(AgentDocFormat::Template));
901 }
902
903 #[test]
904 fn parse_new_write_field() {
905 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
906 let (fm, _) = parse(content).unwrap();
907 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
908 }
909
910 #[test]
911 fn write_uses_new_format_write_fields() {
912 let fm = Frontmatter {
913 format: Some(AgentDocFormat::Template),
914 write_mode: Some(AgentDocWrite::Crdt),
915 ..Default::default()
916 };
917 let result = write(&fm, "body\n").unwrap();
918 assert!(result.contains("agent_doc_format:"));
919 assert!(result.contains("agent_doc_write:"));
920 assert!(!result.contains("agent_doc_mode:"));
921 }
922
923 #[test]
924 fn set_format_and_write_clears_deprecated_mode() {
925 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
926 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
927 let (fm, _) = parse(&result).unwrap();
928 assert!(fm.mode.is_none());
929 assert_eq!(fm.format, Some(AgentDocFormat::Template));
930 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
931 }
932
933 #[test]
936 fn merge_fields_adds_new_field() {
937 let content = "---\nagent_doc_session: abc\n---\nBody\n";
938 let result = merge_fields(content, "model: opus").unwrap();
939 let (fm, body) = parse(&result).unwrap();
940 assert_eq!(fm.session.as_deref(), Some("abc"));
941 assert_eq!(fm.model.as_deref(), Some("opus"));
942 assert!(body.contains("Body"));
943 }
944
945 #[test]
946 fn merge_fields_updates_existing_field() {
947 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
948 let result = merge_fields(content, "model: opus").unwrap();
949 let (fm, _) = parse(&result).unwrap();
950 assert_eq!(fm.model.as_deref(), Some("opus"));
951 assert_eq!(fm.session.as_deref(), Some("abc"));
952 }
953
954 #[test]
955 fn merge_fields_multiple_fields() {
956 let content = "---\nagent_doc_session: abc\n---\nBody\n";
957 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
958 let (fm, _) = parse(&result).unwrap();
959 assert_eq!(fm.model.as_deref(), Some("opus"));
960 assert_eq!(fm.agent.as_deref(), Some("claude"));
961 assert_eq!(fm.branch.as_deref(), Some("main"));
962 }
963
964 #[test]
965 fn merge_fields_format_enum() {
966 let content = "---\nagent_doc_session: abc\n---\nBody\n";
967 let result = merge_fields(content, "agent_doc_format: append").unwrap();
968 let (fm, _) = parse(&result).unwrap();
969 assert_eq!(fm.format, Some(AgentDocFormat::Append));
970 }
971
972 #[test]
973 fn merge_fields_write_enum() {
974 let content = "---\nagent_doc_session: abc\n---\nBody\n";
975 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
976 let (fm, _) = parse(&result).unwrap();
977 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
978 }
979
980 #[test]
981 fn merge_fields_ignores_unknown() {
982 let content = "---\nagent_doc_session: abc\n---\nBody\n";
983 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
984 let (fm, _) = parse(&result).unwrap();
985 assert_eq!(fm.model.as_deref(), Some("opus"));
986 }
987
988 #[test]
989 fn merge_fields_preserves_body() {
990 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
991 let result = merge_fields(content, "model: opus").unwrap();
992 assert!(result.contains("# Title"));
993 assert!(result.contains("Some **markdown** content."));
994 }
995
996 #[test]
997 fn set_format_and_write_clears_deprecated() {
998 let content = "---\nagent_doc_mode: append\n---\nBody\n";
999 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1000 let (fm, _) = parse(&result).unwrap();
1001 assert!(fm.mode.is_none());
1002 assert_eq!(fm.format, Some(AgentDocFormat::Template));
1003 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1004 }
1005
1006 #[test]
1007 fn hooks_roundtrip() {
1008 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
1009 let (fm, _) = parse(content).unwrap();
1010 assert_eq!(fm.hooks.get("session_start"), Some(&vec!["echo start {{session_id}}".to_string()]));
1011 assert_eq!(fm.hooks.get("post_write"), Some(&vec!["notify {{file}}".to_string()]));
1012 }
1013
1014 #[test]
1015 fn hooks_omitted_when_empty() {
1016 let fm = Frontmatter::default();
1017 let result = write(&fm, "body\n").unwrap();
1018 assert!(!result.contains("hooks"));
1019 }
1020
1021 #[test]
1022 fn hooks_absent_parses_as_empty() {
1023 let content = "---\nsession: abc\n---\nBody\n";
1024 let (fm, _) = parse(content).unwrap();
1025 assert!(fm.hooks.is_empty());
1026 }
1027
1028 #[test]
1029 fn parse_no_mcp_field() {
1030 let content = "---\nno_mcp: true\n---\nBody\n";
1031 let (fm, _) = parse(content).unwrap();
1032 assert_eq!(fm.no_mcp, Some(true));
1033 }
1034
1035 #[test]
1036 fn parse_enable_tool_search_field() {
1037 let content = "---\nenable_tool_search: true\n---\nBody\n";
1038 let (fm, _) = parse(content).unwrap();
1039 assert_eq!(fm.enable_tool_search, Some(true));
1040 }
1041
1042 #[test]
1043 fn parse_missing_flags_default_none() {
1044 let content = "---\nsession: abc\n---\nBody\n";
1045 let (fm, _) = parse(content).unwrap();
1046 assert!(fm.no_mcp.is_none());
1047 assert!(fm.enable_tool_search.is_none());
1048 }
1049
1050 #[test]
1051 fn parse_env_map() {
1052 let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
1053 let (fm, _) = parse(content).unwrap();
1054 assert_eq!(fm.env.len(), 2);
1055 assert_eq!(fm.env["FOO"], Some("bar".to_string()));
1056 assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
1057 let keys: Vec<&String> = fm.env.keys().collect();
1059 assert_eq!(keys, vec!["FOO", "BAZ"]);
1060 }
1061
1062 #[test]
1063 fn parse_env_empty_default() {
1064 let content = "---\nsession: abc\n---\nBody\n";
1065 let (fm, _) = parse(content).unwrap();
1066 assert!(fm.env.is_empty());
1067 }
1068
1069 #[test]
1070 fn parse_env_unset_via_null() {
1071 let content = "---\nenv:\n SET_ME: value\n UNSET_ME: null\n---\nBody\n";
1072 let (fm, _) = parse(content).unwrap();
1073 assert_eq!(fm.env.len(), 2);
1074 assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
1075 assert_eq!(fm.env["UNSET_ME"], None);
1076 }
1077
1078 #[test]
1079 fn write_roundtrip_with_env() {
1080 let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
1081 env.insert("KEY1".to_string(), Some("value1".to_string()));
1082 env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
1083 env.insert("KEY3".to_string(), None);
1084 let fm = Frontmatter {
1085 env,
1086 ..Default::default()
1087 };
1088 let written = write(&fm, "body\n").unwrap();
1089 let (fm2, _) = parse(&written).unwrap();
1090 assert_eq!(fm2.env.len(), 3);
1091 assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
1092 assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
1093 assert_eq!(fm2.env["KEY3"], None);
1094 }
1095}