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")]
321 pub env: indexmap::IndexMap<String, String>,
322}
323
324impl Frontmatter {
325 pub fn resolve_mode(&self) -> ResolvedMode {
332 let mut format = AgentDocFormat::Template;
334 let mut write = AgentDocWrite::Crdt;
335
336 if let Some(ref mode_str) = self.mode {
338 match mode_str.as_str() {
339 "append" => {
340 format = AgentDocFormat::Append;
341 }
343 "template" => {
344 format = AgentDocFormat::Template;
345 }
347 "stream" => {
348 format = AgentDocFormat::Template;
349 write = AgentDocWrite::Crdt;
350 }
351 _ => {} }
353 }
354
355 if let Some(f) = self.format {
357 format = f;
358 }
359 if let Some(w) = self.write_mode {
360 write = w;
361 }
362
363 ResolvedMode { format, write }
364 }
365}
366
367pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
370 if !content.starts_with("---\n") {
371 return Ok((Frontmatter::default(), content));
372 }
373 let rest = &content[4..]; let end = rest
375 .find("\n---\n")
376 .or_else(|| rest.find("\n---"))
377 .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
378 let yaml = &rest[..end];
379 let fm: Frontmatter = serde_yaml::from_str(yaml)?;
380 let body_start = 4 + end + 4; let body = if body_start <= content.len() {
382 &content[body_start..]
383 } else {
384 ""
385 };
386 Ok((fm, body))
387}
388
389pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
391 let yaml = serde_yaml::to_string(fm)?;
392 Ok(format!("---\n{}---\n{}", yaml, body))
393}
394
395pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
397 let (mut fm, body) = parse(content)?;
398 fm.session = Some(session_id.to_string());
399 write(&fm, body)
400}
401
402pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
404 let (mut fm, body) = parse(content)?;
405 fm.resume = Some(resume_id.to_string());
406 write(&fm, body)
407}
408
409pub fn set_format_and_write(
411 content: &str,
412 format: AgentDocFormat,
413 write_mode: AgentDocWrite,
414) -> Result<String> {
415 let (mut fm, body) = parse(content)?;
416 fm.format = Some(format);
417 fm.write_mode = Some(write_mode);
418 fm.mode = None;
419 write(&fm, body)
420}
421
422pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
428 let (mut fm, body) = parse(content)?;
429 let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
430 .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
431 let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
432
433 for (key, value) in &mapping {
434 let key_str = key.as_str().unwrap_or("");
435 let val_str = || value.as_str().map(|s| s.to_string());
436 match key_str {
437 "agent_doc_session" | "session" => fm.session = val_str(),
438 "resume" => fm.resume = val_str(),
439 "agent" => fm.agent = val_str(),
440 "model" => fm.model = val_str(),
441 "branch" => fm.branch = val_str(),
442 "tmux_session" => fm.tmux_session = val_str(),
443 "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
444 "agent_doc_format" => {
445 if let Some(s) = value.as_str()
446 && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
447 {
448 fm.format = Some(f);
449 }
450 }
451 "agent_doc_write" => {
452 if let Some(s) = value.as_str()
453 && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
454 {
455 fm.write_mode = Some(w);
456 }
457 }
458 "claude_args" => fm.claude_args = val_str(),
459 _ => {
460 eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
461 }
462 }
463 }
464
465 write(&fm, body)
466}
467
468pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
474 let (mut fm, body) = parse(content)?;
475 fm.tmux_session = Some(session_name.to_string());
476 write(&fm, body)
477}
478
479pub fn ensure_session(content: &str) -> Result<(String, String)> {
484 let (fm, _body) = parse(content)?;
485 if let Some(ref session_id) = fm.session {
486 return Ok((content.to_string(), session_id.clone()));
488 }
489 let session_id = Uuid::new_v4().to_string();
490 let updated = set_session_id(content, &session_id)?;
491 Ok((updated, session_id))
492}
493
494pub fn read_session_id(file: &std::path::Path) -> Option<String> {
496 let content = std::fs::read_to_string(file).ok()?;
497 let (fm, _) = parse(&content).ok()?;
498 fm.session
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn parse_no_frontmatter() {
507 let content = "# Hello\n\nBody text.\n";
508 let (fm, body) = parse(content).unwrap();
509 assert!(fm.session.is_none());
510 assert!(fm.agent.is_none());
511 assert!(fm.model.is_none());
512 assert!(fm.branch.is_none());
513 assert_eq!(body, content);
514 }
515
516 #[test]
517 fn parse_all_fields() {
518 let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
519 let (fm, body) = parse(content).unwrap();
520 assert_eq!(fm.session.as_deref(), Some("abc-123"));
521 assert_eq!(fm.agent.as_deref(), Some("claude"));
522 assert_eq!(fm.model.as_deref(), Some("opus"));
523 assert_eq!(fm.branch.as_deref(), Some("main"));
524 assert!(body.contains("Body"));
525 }
526
527 #[test]
528 fn parse_partial_fields() {
529 let content = "---\nsession: xyz\n---\n# Doc\n";
530 let (fm, body) = parse(content).unwrap();
531 assert_eq!(fm.session.as_deref(), Some("xyz"));
532 assert!(fm.agent.is_none());
533 assert!(body.contains("# Doc"));
534 }
535
536 #[test]
537 fn parse_model_tier_high() {
538 let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
539 let (fm, _) = parse(content).unwrap();
540 assert_eq!(fm.model_tier, Some(Tier::High));
541 }
542
543 #[test]
544 fn parse_model_tier_low() {
545 let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
546 let (fm, _) = parse(content).unwrap();
547 assert_eq!(fm.model_tier, Some(Tier::Low));
548 }
549
550 #[test]
551 fn parse_model_tier_med() {
552 let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
553 let (fm, _) = parse(content).unwrap();
554 assert_eq!(fm.model_tier, Some(Tier::Med));
555 }
556
557 #[test]
558 fn parse_model_tier_auto() {
559 let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
560 let (fm, _) = parse(content).unwrap();
561 assert_eq!(fm.model_tier, Some(Tier::Auto));
562 }
563
564 #[test]
565 fn parse_model_tier_absent() {
566 let content = "---\nagent: claude\n---\nBody\n";
567 let (fm, _) = parse(content).unwrap();
568 assert_eq!(fm.model_tier, None);
569 }
570
571 #[test]
572 fn parse_model_tier_invalid_rejected() {
573 let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
574 let result = parse(content);
575 assert!(result.is_err(), "invalid tier value should fail to parse");
576 }
577
578 #[test]
579 fn write_model_tier_roundtrip() {
580 let fm = Frontmatter {
581 model_tier: Some(Tier::High),
582 ..Default::default()
583 };
584 let doc = write(&fm, "Body\n").unwrap();
585 let (parsed, _) = parse(&doc).unwrap();
586 assert_eq!(parsed.model_tier, Some(Tier::High));
587 assert!(doc.contains("agent_doc_model_tier: high"));
588 }
589
590 #[test]
591 fn parse_null_fields() {
592 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
593 let (fm, body) = parse(content).unwrap();
594 assert!(fm.session.is_none());
595 assert!(fm.agent.is_none());
596 assert!(fm.model.is_none());
597 assert!(fm.branch.is_none());
598 assert!(body.contains("Body"));
599 }
600
601 #[test]
602 fn parse_unterminated_frontmatter() {
603 let content = "---\nsession: abc\nno closing block";
604 let err = parse(content).unwrap_err();
605 assert!(err.to_string().contains("Unterminated frontmatter"));
606 }
607
608 #[test]
609 fn parse_closing_at_eof() {
610 let content = "---\nsession: abc\n---";
611 let (fm, body) = parse(content).unwrap();
612 assert_eq!(fm.session.as_deref(), Some("abc"));
613 assert_eq!(body, "");
614 }
615
616 #[test]
617 fn parse_empty_body() {
618 let content = "---\nsession: abc\n---\n";
619 let (fm, _body) = parse(content).unwrap();
620 assert_eq!(fm.session.as_deref(), Some("abc"));
621 }
622
623 #[test]
624 fn write_roundtrip() {
625 let fm = Frontmatter {
627 session: Some("test-id".to_string()),
628 resume: Some("resume-id".to_string()),
629 agent: Some("claude".to_string()),
630 model: Some("opus".to_string()),
631 branch: Some("dev".to_string()),
632 tmux_session: None,
633 mode: None,
634 format: None,
635 write_mode: None,
636 stream_config: None,
637 claude_args: None,
638 no_mcp: None,
639 enable_tool_search: None,
640 debounce_ms: None,
641 links: vec![],
642 auto_compact: None,
643 model_tier: None,
644 hooks: std::collections::HashMap::new(),
645 env: indexmap::IndexMap::new(),
646 };
647 let body = "# Hello\n\nBody text.\n";
648 let written = write(&fm, body).unwrap();
649 let (fm2, body2) = parse(&written).unwrap();
650 assert_eq!(fm2.session, fm.session);
651 assert_eq!(fm2.agent, fm.agent);
652 assert_eq!(fm2.model, fm.model);
653 assert_eq!(fm2.branch, fm.branch);
654 assert!(body2.contains("# Hello"));
656 assert!(body2.contains("Body text."));
657 }
658
659 #[test]
660 fn write_default_frontmatter() {
661 let fm = Frontmatter::default();
662 let result = write(&fm, "body\n").unwrap();
663 assert!(result.starts_with("---\n"));
664 assert!(result.ends_with("---\nbody\n"));
665 }
666
667 #[test]
668 fn write_preserves_body_content() {
669 let fm = Frontmatter::default();
670 let body = "# Title\n\nSome **markdown** with `code`.\n";
671 let result = write(&fm, body).unwrap();
672 assert!(result.contains("# Title"));
673 assert!(result.contains("Some **markdown** with `code`."));
674 }
675
676 #[test]
677 fn set_session_id_creates_frontmatter() {
678 let content = "# No frontmatter\n\nJust body.\n";
679 let result = set_session_id(content, "new-session").unwrap();
680 let (fm, body) = parse(&result).unwrap();
681 assert_eq!(fm.session.as_deref(), Some("new-session"));
682 assert!(body.contains("# No frontmatter"));
683 }
684
685 #[test]
686 fn set_session_id_updates_existing() {
687 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
688 let result = set_session_id(content, "new-id").unwrap();
689 let (fm, body) = parse(&result).unwrap();
690 assert_eq!(fm.session.as_deref(), Some("new-id"));
691 assert_eq!(fm.agent.as_deref(), Some("claude"));
692 assert!(body.contains("Body"));
693 }
694
695 #[test]
696 fn set_session_id_preserves_other_fields() {
697 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
698 let result = set_session_id(content, "new").unwrap();
699 let (fm, _) = parse(&result).unwrap();
700 assert_eq!(fm.session.as_deref(), Some("new"));
701 assert_eq!(fm.agent.as_deref(), Some("claude"));
702 assert_eq!(fm.model.as_deref(), Some("opus"));
703 assert_eq!(fm.branch.as_deref(), Some("dev"));
704 }
705
706 #[test]
707 fn ensure_session_no_frontmatter() {
708 let content = "# Hello\n\nBody.\n";
709 let (updated, sid) = ensure_session(content).unwrap();
710 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
713 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
714 assert!(body.contains("# Hello"));
715 }
716
717 #[test]
718 fn ensure_session_null_session() {
719 let content = "---\nsession:\nagent: claude\n---\nBody\n";
720 let (updated, sid) = ensure_session(content).unwrap();
721 assert_eq!(sid.len(), 36);
722 let (fm, body) = parse(&updated).unwrap();
723 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
724 assert_eq!(fm.agent.as_deref(), Some("claude"));
725 assert!(body.contains("Body"));
726 }
727
728 #[test]
729 fn ensure_session_existing_session() {
730 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
731 let (updated, sid) = ensure_session(content).unwrap();
732 assert_eq!(sid, "existing-id");
733 assert_eq!(updated, content);
735 }
736
737 #[test]
738 fn parse_legacy_session_field() {
739 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
741 let (fm, body) = parse(content).unwrap();
742 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
743 assert_eq!(fm.agent.as_deref(), Some("claude"));
744 assert!(body.contains("Body"));
745 }
746
747 #[test]
748 fn parse_agent_doc_mode_canonical() {
749 let content = "---\nagent_doc_mode: template\n---\nBody\n";
750 let (fm, _) = parse(content).unwrap();
751 assert_eq!(fm.mode.as_deref(), Some("template"));
752 }
753
754 #[test]
755 fn parse_mode_shorthand_alias() {
756 let content = "---\nmode: template\n---\nBody\n";
757 let (fm, _) = parse(content).unwrap();
758 assert_eq!(fm.mode.as_deref(), Some("template"));
759 }
760
761 #[test]
762 fn parse_response_mode_legacy_alias() {
763 let content = "---\nresponse_mode: template\n---\nBody\n";
764 let (fm, _) = parse(content).unwrap();
765 assert_eq!(fm.mode.as_deref(), Some("template"));
766 }
767
768 #[test]
769 fn write_uses_agent_doc_mode_field() {
770 #[allow(deprecated)]
771 let fm = Frontmatter {
772 mode: Some("template".to_string()),
773 ..Default::default()
774 };
775 let result = write(&fm, "body\n").unwrap();
776 assert!(result.contains("agent_doc_mode:"));
777 assert!(!result.contains("response_mode:"));
778 assert!(!result.contains("\nmode:"));
779 }
780
781 #[test]
782 fn write_uses_new_field_name() {
783 let fm = Frontmatter {
784 session: Some("test-id".to_string()),
785 ..Default::default()
786 };
787 let result = write(&fm, "body\n").unwrap();
788 assert!(result.contains("agent_doc_session:"));
789 assert!(!result.contains("\nsession:"));
790 }
791
792 #[test]
795 fn resolve_mode_defaults() {
796 let fm = Frontmatter::default();
797 let resolved = fm.resolve_mode();
798 assert_eq!(resolved.format, AgentDocFormat::Template);
799 assert_eq!(resolved.write, AgentDocWrite::Crdt);
800 }
801
802 #[test]
803 fn resolve_mode_from_deprecated_append() {
804 let content = "---\nagent_doc_mode: append\n---\nBody\n";
805 let (fm, _) = parse(content).unwrap();
806 let resolved = fm.resolve_mode();
807 assert_eq!(resolved.format, AgentDocFormat::Append);
808 assert_eq!(resolved.write, AgentDocWrite::Crdt);
809 }
810
811 #[test]
812 fn resolve_mode_from_deprecated_template() {
813 let content = "---\nagent_doc_mode: template\n---\nBody\n";
814 let (fm, _) = parse(content).unwrap();
815 let resolved = fm.resolve_mode();
816 assert_eq!(resolved.format, AgentDocFormat::Template);
817 assert_eq!(resolved.write, AgentDocWrite::Crdt);
818 }
819
820 #[test]
821 fn resolve_mode_from_deprecated_stream() {
822 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
823 let (fm, _) = parse(content).unwrap();
824 let resolved = fm.resolve_mode();
825 assert_eq!(resolved.format, AgentDocFormat::Template);
826 assert_eq!(resolved.write, AgentDocWrite::Crdt);
827 }
828
829 #[test]
830 fn resolve_mode_new_fields_override_deprecated() {
831 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
832 let (fm, _) = parse(content).unwrap();
833 let resolved = fm.resolve_mode();
834 assert_eq!(resolved.format, AgentDocFormat::Template);
835 assert_eq!(resolved.write, AgentDocWrite::Merge);
836 }
837
838 #[test]
839 fn resolve_mode_explicit_new_fields_only() {
840 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
841 let (fm, _) = parse(content).unwrap();
842 let resolved = fm.resolve_mode();
843 assert_eq!(resolved.format, AgentDocFormat::Append);
844 assert_eq!(resolved.write, AgentDocWrite::Crdt);
845 }
846
847 #[test]
848 fn resolve_mode_partial_new_field_format_only() {
849 let content = "---\nagent_doc_format: append\n---\nBody\n";
850 let (fm, _) = parse(content).unwrap();
851 let resolved = fm.resolve_mode();
852 assert_eq!(resolved.format, AgentDocFormat::Append);
853 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
855
856 #[test]
857 fn resolve_mode_partial_new_field_write_only() {
858 let content = "---\nagent_doc_write: merge\n---\nBody\n";
859 let (fm, _) = parse(content).unwrap();
860 let resolved = fm.resolve_mode();
861 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
863 }
864
865 #[test]
866 fn resolve_mode_helper_methods() {
867 let fm = Frontmatter::default();
868 let resolved = fm.resolve_mode();
869 assert!(resolved.is_template());
870 assert!(!resolved.is_append());
871 assert!(resolved.is_crdt());
872 }
873
874 #[test]
875 fn parse_new_format_field() {
876 let content = "---\nagent_doc_format: template\n---\nBody\n";
877 let (fm, _) = parse(content).unwrap();
878 assert_eq!(fm.format, Some(AgentDocFormat::Template));
879 }
880
881 #[test]
882 fn parse_new_write_field() {
883 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
884 let (fm, _) = parse(content).unwrap();
885 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
886 }
887
888 #[test]
889 fn write_uses_new_format_write_fields() {
890 let fm = Frontmatter {
891 format: Some(AgentDocFormat::Template),
892 write_mode: Some(AgentDocWrite::Crdt),
893 ..Default::default()
894 };
895 let result = write(&fm, "body\n").unwrap();
896 assert!(result.contains("agent_doc_format:"));
897 assert!(result.contains("agent_doc_write:"));
898 assert!(!result.contains("agent_doc_mode:"));
899 }
900
901 #[test]
902 fn set_format_and_write_clears_deprecated_mode() {
903 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
904 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
905 let (fm, _) = parse(&result).unwrap();
906 assert!(fm.mode.is_none());
907 assert_eq!(fm.format, Some(AgentDocFormat::Template));
908 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
909 }
910
911 #[test]
914 fn merge_fields_adds_new_field() {
915 let content = "---\nagent_doc_session: abc\n---\nBody\n";
916 let result = merge_fields(content, "model: opus").unwrap();
917 let (fm, body) = parse(&result).unwrap();
918 assert_eq!(fm.session.as_deref(), Some("abc"));
919 assert_eq!(fm.model.as_deref(), Some("opus"));
920 assert!(body.contains("Body"));
921 }
922
923 #[test]
924 fn merge_fields_updates_existing_field() {
925 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
926 let result = merge_fields(content, "model: opus").unwrap();
927 let (fm, _) = parse(&result).unwrap();
928 assert_eq!(fm.model.as_deref(), Some("opus"));
929 assert_eq!(fm.session.as_deref(), Some("abc"));
930 }
931
932 #[test]
933 fn merge_fields_multiple_fields() {
934 let content = "---\nagent_doc_session: abc\n---\nBody\n";
935 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
936 let (fm, _) = parse(&result).unwrap();
937 assert_eq!(fm.model.as_deref(), Some("opus"));
938 assert_eq!(fm.agent.as_deref(), Some("claude"));
939 assert_eq!(fm.branch.as_deref(), Some("main"));
940 }
941
942 #[test]
943 fn merge_fields_format_enum() {
944 let content = "---\nagent_doc_session: abc\n---\nBody\n";
945 let result = merge_fields(content, "agent_doc_format: append").unwrap();
946 let (fm, _) = parse(&result).unwrap();
947 assert_eq!(fm.format, Some(AgentDocFormat::Append));
948 }
949
950 #[test]
951 fn merge_fields_write_enum() {
952 let content = "---\nagent_doc_session: abc\n---\nBody\n";
953 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
954 let (fm, _) = parse(&result).unwrap();
955 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
956 }
957
958 #[test]
959 fn merge_fields_ignores_unknown() {
960 let content = "---\nagent_doc_session: abc\n---\nBody\n";
961 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
962 let (fm, _) = parse(&result).unwrap();
963 assert_eq!(fm.model.as_deref(), Some("opus"));
964 }
965
966 #[test]
967 fn merge_fields_preserves_body() {
968 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
969 let result = merge_fields(content, "model: opus").unwrap();
970 assert!(result.contains("# Title"));
971 assert!(result.contains("Some **markdown** content."));
972 }
973
974 #[test]
975 fn set_format_and_write_clears_deprecated() {
976 let content = "---\nagent_doc_mode: append\n---\nBody\n";
977 let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
978 let (fm, _) = parse(&result).unwrap();
979 assert!(fm.mode.is_none());
980 assert_eq!(fm.format, Some(AgentDocFormat::Template));
981 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
982 }
983
984 #[test]
985 fn hooks_roundtrip() {
986 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
987 let (fm, _) = parse(content).unwrap();
988 assert_eq!(fm.hooks.get("session_start"), Some(&vec!["echo start {{session_id}}".to_string()]));
989 assert_eq!(fm.hooks.get("post_write"), Some(&vec!["notify {{file}}".to_string()]));
990 }
991
992 #[test]
993 fn hooks_omitted_when_empty() {
994 let fm = Frontmatter::default();
995 let result = write(&fm, "body\n").unwrap();
996 assert!(!result.contains("hooks"));
997 }
998
999 #[test]
1000 fn hooks_absent_parses_as_empty() {
1001 let content = "---\nsession: abc\n---\nBody\n";
1002 let (fm, _) = parse(content).unwrap();
1003 assert!(fm.hooks.is_empty());
1004 }
1005
1006 #[test]
1007 fn parse_no_mcp_field() {
1008 let content = "---\nno_mcp: true\n---\nBody\n";
1009 let (fm, _) = parse(content).unwrap();
1010 assert_eq!(fm.no_mcp, Some(true));
1011 }
1012
1013 #[test]
1014 fn parse_enable_tool_search_field() {
1015 let content = "---\nenable_tool_search: true\n---\nBody\n";
1016 let (fm, _) = parse(content).unwrap();
1017 assert_eq!(fm.enable_tool_search, Some(true));
1018 }
1019
1020 #[test]
1021 fn parse_missing_flags_default_none() {
1022 let content = "---\nsession: abc\n---\nBody\n";
1023 let (fm, _) = parse(content).unwrap();
1024 assert!(fm.no_mcp.is_none());
1025 assert!(fm.enable_tool_search.is_none());
1026 }
1027
1028 #[test]
1029 fn parse_env_map() {
1030 let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
1031 let (fm, _) = parse(content).unwrap();
1032 assert_eq!(fm.env.len(), 2);
1033 assert_eq!(fm.env["FOO"], "bar");
1034 assert_eq!(fm.env["BAZ"], "$(echo hello)");
1035 let keys: Vec<&String> = fm.env.keys().collect();
1037 assert_eq!(keys, vec!["FOO", "BAZ"]);
1038 }
1039
1040 #[test]
1041 fn parse_env_empty_default() {
1042 let content = "---\nsession: abc\n---\nBody\n";
1043 let (fm, _) = parse(content).unwrap();
1044 assert!(fm.env.is_empty());
1045 }
1046
1047 #[test]
1048 fn write_roundtrip_with_env() {
1049 let mut env = indexmap::IndexMap::new();
1050 env.insert("KEY1".to_string(), "value1".to_string());
1051 env.insert("KEY2".to_string(), "$KEY1".to_string());
1052 let fm = Frontmatter {
1053 env,
1054 ..Default::default()
1055 };
1056 let written = write(&fm, "body\n").unwrap();
1057 let (fm2, _) = parse(&written).unwrap();
1058 assert_eq!(fm2.env.len(), 2);
1059 assert_eq!(fm2.env["KEY1"], "value1");
1060 assert_eq!(fm2.env["KEY2"], "$KEY1");
1061 }
1062}