1use anyhow::Result;
98use serde::{Deserialize, Serialize};
99use std::fmt;
100use uuid::Uuid;
101
102use crate::model_tier::Tier;
103
104#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum CollaborationMode {
107 #[default]
108 SingleUser,
109 Shared,
110}
111
112#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum CodexNetworkAccess {
115 #[default]
116 Inherit,
117 Enabled,
118 Disabled,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
123pub enum AgentDocFormat {
124 #[clap(alias = "inline")]
126 Append,
127 Template,
129}
130
131impl fmt::Display for AgentDocFormat {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Append => write!(f, "inline"),
135 Self::Template => write!(f, "template"),
136 }
137 }
138}
139
140impl Serialize for AgentDocFormat {
141 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
142 where
143 S: serde::Serializer,
144 {
145 match self {
146 Self::Append => serializer.serialize_str("inline"),
147 Self::Template => serializer.serialize_str("template"),
148 }
149 }
150}
151
152impl<'de> Deserialize<'de> for AgentDocFormat {
153 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
154 where
155 D: serde::Deserializer<'de>,
156 {
157 let s = String::deserialize(deserializer)?;
158 match s.as_str() {
159 "append" | "inline" => Ok(Self::Append),
160 "template" => Ok(Self::Template),
161 other => Err(serde::de::Error::unknown_variant(
162 other,
163 &["inline", "append", "template"],
164 )),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
171#[serde(rename_all = "lowercase")]
172pub enum AgentDocWrite {
173 Merge,
175 Crdt,
177}
178
179impl fmt::Display for AgentDocWrite {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 Self::Merge => write!(f, "merge"),
183 Self::Crdt => write!(f, "crdt"),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct ResolvedMode {
191 pub format: AgentDocFormat,
192 pub write: AgentDocWrite,
193}
194
195impl ResolvedMode {
196 pub fn is_template(&self) -> bool {
197 self.format == AgentDocFormat::Template
198 }
199
200 pub fn is_append(&self) -> bool {
201 self.format == AgentDocFormat::Append
202 }
203
204 pub fn is_crdt(&self) -> bool {
205 self.write == AgentDocWrite::Crdt
206 }
207}
208
209#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct StreamConfig {
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub interval: Option<u64>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub strip_ansi: Option<bool>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub target: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub thinking: Option<bool>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub thinking_target: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub max_lines: Option<usize>,
232}
233
234#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum PendingCaptureGuardMode {
237 #[default]
238 Warn,
239 #[serde(alias = "error")]
240 Strict,
241 Off,
242}
243
244#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum LintDialectMode {
258 #[default]
259 Warn,
260 #[serde(alias = "error")]
261 Strict,
262 Off,
263}
264
265#[derive(Debug, Default, Clone, Serialize, Deserialize)]
266pub struct Frontmatter {
267 #[serde(
270 default,
271 skip_serializing_if = "Option::is_none",
272 rename = "agent_doc_session",
273 alias = "session"
274 )]
275 pub session: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub resume: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub agent: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub model: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub claude_model: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub codex_model: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub opencode_model: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub branch: Option<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub tmux_session: Option<String>,
305 #[serde(
309 default,
310 skip_serializing_if = "Option::is_none",
311 rename = "agent_doc_mode",
312 alias = "mode",
313 alias = "response_mode"
314 )]
315 pub mode: Option<String>,
316 #[serde(
318 default,
319 skip_serializing_if = "Option::is_none",
320 rename = "agent_doc_format"
321 )]
322 pub format: Option<AgentDocFormat>,
323 #[serde(
325 default,
326 skip_serializing_if = "Option::is_none",
327 rename = "agent_doc_write"
328 )]
329 pub write_mode: Option<AgentDocWrite>,
330 #[serde(
332 default,
333 skip_serializing_if = "Option::is_none",
334 rename = "agent_doc_stream"
335 )]
336 pub stream_config: Option<StreamConfig>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub agent_args: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub claude_args: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub codex_args: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub opencode_args: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub codex_network_access: Option<CodexNetworkAccess>,
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub required_ssh_targets: Vec<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub required_ssh_profile: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub no_mcp: Option<bool>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub enable_tool_search: Option<bool>,
374 #[serde(
377 default,
378 skip_serializing_if = "Option::is_none",
379 rename = "agent_doc_debounce"
380 )]
381 pub debounce_ms: Option<u64>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
385 pub links: Vec<String>,
386 #[serde(
389 default,
390 skip_serializing_if = "Option::is_none",
391 rename = "agent_doc_auto_compact"
392 )]
393 pub auto_compact: Option<usize>,
394 #[serde(
399 default,
400 skip_serializing_if = "Option::is_none",
401 rename = "agent_doc_model_tier"
402 )]
403 pub model_tier: Option<Tier>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub pending_capture_guard: Option<PendingCaptureGuardMode>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub pending_done_guard: Option<PendingCaptureGuardMode>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub review_done_guard: Option<PendingCaptureGuardMode>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub auto_done: Option<bool>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub agent_doc_lint_dialect: Option<LintDialectMode>,
426 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
448 pub hooks: std::collections::HashMap<String, Vec<String>>,
449 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
456 pub env: indexmap::IndexMap<String, Option<String>>,
457 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
467 pub prompt_presets: indexmap::IndexMap<String, String>,
468 #[serde(
472 default,
473 skip_serializing_if = "Option::is_none",
474 rename = "agent_doc_dispatch"
475 )]
476 pub dispatch: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub agent_doc_env_inherit: Option<bool>,
485 #[serde(
491 default,
492 skip_serializing_if = "Option::is_none",
493 rename = "agent_doc_cwd"
494 )]
495 pub cwd: Option<String>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub queue_active: Option<bool>,
501 #[serde(
504 default,
505 skip_serializing_if = "Option::is_none",
506 rename = "agent_doc_collaboration"
507 )]
508 pub collaboration: Option<CollaborationMode>,
509 #[serde(
511 default,
512 skip_serializing_if = "Option::is_none",
513 rename = "agent_doc_security_review"
514 )]
515 pub security_review: Option<String>,
516}
517
518pub fn resolve_prompt_preset_key(
524 prompt_presets: &indexmap::IndexMap<String, String>,
525 requested: &str,
526) -> Option<String> {
527 let requested = requested.trim();
528 if prompt_presets.contains_key(requested) {
529 return Some(requested.to_string());
530 }
531 if !requested.starts_with('#') {
532 let hashtag_key = format!("#{requested}");
533 if prompt_presets.contains_key(hashtag_key.as_str()) {
534 return Some(hashtag_key);
535 }
536 }
537 None
538}
539
540impl Frontmatter {
541 pub fn resolve_mode(&self) -> ResolvedMode {
548 let mut format = AgentDocFormat::Template;
550 let mut write = AgentDocWrite::Crdt;
551
552 if let Some(ref mode_str) = self.mode {
554 match mode_str.as_str() {
555 "append" => {
556 format = AgentDocFormat::Append;
557 }
559 "template" => {
560 format = AgentDocFormat::Template;
561 }
563 "stream" => {
564 format = AgentDocFormat::Template;
565 write = AgentDocWrite::Crdt;
566 }
567 _ => {} }
569 }
570
571 if let Some(f) = self.format {
573 format = f;
574 }
575 if let Some(w) = self.write_mode {
576 write = w;
577 }
578
579 ResolvedMode { format, write }
580 }
581
582 pub fn resolve_harness_model(&self, harness: &str) -> Option<&str> {
589 let harness_specific = match harness {
590 "claude-code" => self.claude_model.as_deref(),
591 "codex" => self.codex_model.as_deref(),
592 "opencode" => self.opencode_model.as_deref(),
593 _ => None,
594 };
595 harness_specific.or(self.model.as_deref())
596 }
597
598 pub fn collaboration_mode(&self) -> CollaborationMode {
599 self.collaboration.unwrap_or_default()
600 }
601
602 pub fn has_security_review(&self) -> bool {
603 self.security_review
604 .as_deref()
605 .map(str::trim)
606 .is_some_and(|value| !value.is_empty())
607 }
608}
609
610pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
613 let Some((yaml, body)) = split_frontmatter(content)? else {
614 return Ok((Frontmatter::default(), content));
615 };
616 let fm: Frontmatter = serde_yaml::from_str(yaml)?;
617 Ok((fm, body))
618}
619
620pub fn contextualize_parse_error(file_display: &str, err: anyhow::Error) -> anyhow::Error {
623 anyhow::anyhow!(
624 "invalid YAML frontmatter in {}: {}\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
625 file_display,
626 err
627 )
628}
629
630pub struct SshResolverContext<'a> {
636 pub project: &'a crate::project_config::ProjectConfig,
638 pub doc_relative: &'a str,
641 pub file_display: &'a str,
643}
644
645pub fn parse_with_ssh_resolver<'a>(
650 content: &'a str,
651 ctx: &SshResolverContext<'_>,
652) -> Result<(Frontmatter, &'a str)> {
653 let Some((yaml, body)) = split_frontmatter(content)? else {
654 let mut fm = Frontmatter::default();
655 apply_required_ssh_defaults(&mut fm, ctx)?;
656 return Ok((fm, content));
657 };
658 match serde_yaml::from_str(yaml) {
659 Ok(mut fm) => {
660 apply_required_ssh_defaults(&mut fm, ctx)?;
661 Ok((fm, body))
662 }
663 Err(err) => Err(contextualize_yaml_parse_error(ctx.file_display, yaml, err)),
664 }
665}
666
667pub fn ensure_session_with_ssh_resolver(
669 content: &str,
670 ctx: &SshResolverContext<'_>,
671) -> Result<(String, String)> {
672 let (mut fm, body) = parse_with_ssh_resolver(content, ctx)?;
673 if let Some(existing) = fm.session.clone() {
674 return Ok((content.to_string(), existing));
675 }
676 let session_id = Uuid::new_v4().to_string();
677 fm.session = Some(session_id.clone());
678 let updated = write(&fm, body)?;
679 Ok((updated, session_id))
680}
681
682pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
684 let yaml = serde_yaml::to_string(fm)?;
685 Ok(format!("---\n{}---\n{}", yaml, body))
686}
687
688pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
690 let (mut fm, body) = parse(content)?;
691 fm.session = Some(session_id.to_string());
692 write(&fm, body)
693}
694
695pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
697 let (mut fm, body) = parse(content)?;
698 fm.resume = Some(resume_id.to_string());
699 write(&fm, body)
700}
701
702pub fn set_format_and_write(
704 content: &str,
705 format: AgentDocFormat,
706 write_mode: AgentDocWrite,
707) -> Result<String> {
708 let (mut fm, body) = parse(content)?;
709 fm.format = Some(format);
710 fm.write_mode = Some(write_mode);
711 fm.mode = None;
712 write(&fm, body)
713}
714
715pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
721 let (mut fm, body) = parse(content)?;
722 let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
723 .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
724 let mapping = patch
725 .as_mapping()
726 .unwrap_or(&serde_yaml::Mapping::new())
727 .clone();
728
729 for (key, value) in &mapping {
730 let key_str = key.as_str().unwrap_or("");
731 let val_str = || value.as_str().map(|s| s.to_string());
732 match key_str {
733 "agent_doc_session" | "session" => fm.session = val_str(),
734 "resume" => fm.resume = val_str(),
735 "agent" => fm.agent = val_str(),
736 "model" => fm.model = val_str(),
737 "claude_model" => fm.claude_model = val_str(),
738 "codex_model" => fm.codex_model = val_str(),
739 "opencode_model" => fm.opencode_model = val_str(),
740 "branch" => fm.branch = val_str(),
741 "tmux_session" => fm.tmux_session = val_str(),
742 "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
743 "agent_doc_format" => {
744 if let Some(s) = value.as_str()
745 && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
746 {
747 fm.format = Some(f);
748 }
749 }
750 "agent_doc_write" => {
751 if let Some(s) = value.as_str()
752 && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
753 {
754 fm.write_mode = Some(w);
755 }
756 }
757 "pending_capture_guard" => {
758 if let Some(s) = value.as_str()
759 && let Ok(mode) =
760 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
761 {
762 fm.pending_capture_guard = Some(mode);
763 }
764 }
765 "pending_done_guard" => {
766 if let Some(s) = value.as_str()
767 && let Ok(mode) =
768 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
769 {
770 fm.pending_done_guard = Some(mode);
771 }
772 }
773 "review_done_guard" => {
774 if let Some(s) = value.as_str()
775 && let Ok(mode) =
776 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
777 {
778 fm.review_done_guard = Some(mode);
779 }
780 }
781 "auto_done" => {
782 if let Some(enabled) = value.as_bool() {
783 fm.auto_done = Some(enabled);
784 }
785 }
786 "prompt_presets" => {
787 if let Ok(presets) =
788 serde_yaml::from_value::<indexmap::IndexMap<String, String>>(value.clone())
789 {
790 fm.prompt_presets = presets;
791 }
792 }
793 "agent_args" => fm.agent_args = val_str(),
794 "claude_args" => fm.claude_args = val_str(),
795 "codex_args" => fm.codex_args = val_str(),
796 "opencode_args" => fm.opencode_args = val_str(),
797 "codex_network_access" => {
798 if let Some(s) = value.as_str()
799 && let Ok(mode) =
800 serde_yaml::from_str::<CodexNetworkAccess>(&format!("\"{}\"", s))
801 {
802 fm.codex_network_access = Some(mode);
803 }
804 }
805 "required_ssh_targets" => {
806 if let Ok(targets) = serde_yaml::from_value::<Vec<String>>(value.clone()) {
807 fm.required_ssh_targets = targets;
808 }
809 }
810 "required_ssh_profile" => fm.required_ssh_profile = val_str(),
811 "queue_active" => {
812 fm.queue_active = value.as_bool();
813 }
814 "agent_doc_collaboration" => {
815 if let Some(s) = value.as_str()
816 && let Ok(mode) =
817 serde_yaml::from_str::<CollaborationMode>(&format!("\"{}\"", s))
818 {
819 fm.collaboration = Some(mode);
820 }
821 }
822 "agent_doc_security_review" => fm.security_review = val_str(),
823 _ => {
824 eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
825 }
826 }
827 }
828
829 write(&fm, body)
830}
831
832pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
838 let (mut fm, body) = parse(content)?;
839 fm.tmux_session = Some(session_name.to_string());
840 write(&fm, body)
841}
842
843pub fn ensure_session(content: &str) -> Result<(String, String)> {
848 let (fm, _body) = parse(content)?;
849 if let Some(ref session_id) = fm.session {
850 return Ok((content.to_string(), session_id.clone()));
852 }
853 let session_id = Uuid::new_v4().to_string();
854 let updated = set_session_id(content, &session_id)?;
855 Ok((updated, session_id))
856}
857
858fn split_frontmatter(content: &str) -> Result<Option<(&str, &str)>> {
859 if !content.starts_with("---\n") {
860 return Ok(None);
861 }
862 let rest = &content[4..]; let (end, closing_len) = rest
864 .find("\n---\n")
865 .map(|end| (end, 5))
866 .or_else(|| rest.find("\n---").map(|end| (end, 4)))
867 .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
868 let yaml = &rest[..end];
869 let body_start = 4 + end + closing_len; let body = if body_start <= content.len() {
871 &content[body_start..]
872 } else {
873 ""
874 };
875 Ok(Some((yaml, body)))
876}
877
878pub fn apply_required_ssh_defaults(
884 fm: &mut Frontmatter,
885 ctx: &SshResolverContext<'_>,
886) -> Result<()> {
887 if !fm.required_ssh_targets.is_empty() {
888 fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
889 return Ok(());
890 }
891
892 if ctx.doc_relative.is_empty() {
893 return Ok(());
894 }
895
896 let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
897 resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
898 } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
899 let mut targets = doc_cfg.targets.clone();
900 if let Some(profile_name) = doc_cfg.profile.as_deref() {
901 targets.extend(resolve_required_ssh_profile_targets(
902 &ctx.project.ssh,
903 profile_name,
904 ctx.file_display,
905 )?);
906 }
907 if targets.is_empty() {
908 anyhow::bail!(
909 "document {} is configured as SSH-dependent in .agent-doc/config.toml but no required_ssh_targets resolved for `{}`.\n\nAdd direct targets under [ssh.docs.\"{}\"] or define a valid profile under [ssh.profiles.<name>].",
910 ctx.file_display,
911 ctx.doc_relative,
912 ctx.doc_relative
913 );
914 }
915 targets
916 } else {
917 Vec::new()
918 };
919
920 fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
921 Ok(())
922}
923
924fn resolve_required_ssh_profile_targets(
925 ssh: &crate::project_config::SshConfig,
926 profile_name: &str,
927 file_display: &str,
928) -> Result<Vec<String>> {
929 let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
930 anyhow::anyhow!(
931 "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
932 file_display,
933 profile_name,
934 profile_name
935 )
936 })?;
937 if profile.targets.is_empty() {
938 anyhow::bail!(
939 "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
940 file_display,
941 profile_name,
942 profile_name
943 );
944 }
945 Ok(profile.targets.clone())
946}
947
948fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
949 let mut normalized = Vec::new();
950 for target in targets {
951 let trimmed = target.trim();
952 if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
953 continue;
954 }
955 normalized.push(trimmed.to_string());
956 }
957 normalized
958}
959
960fn contextualize_yaml_parse_error(
961 file_display: &str,
962 yaml: &str,
963 err: serde_yaml::Error,
964) -> anyhow::Error {
965 let excerpt = err
966 .location()
967 .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
968 let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
969 if let Some(excerpt) = excerpt {
970 message.push_str("\n\nFrontmatter excerpt:\n");
971 message.push_str(&excerpt);
972 }
973 message.push_str(
974 "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
975 );
976 anyhow::anyhow!(message)
977}
978
979fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
980 let lines: Vec<&str> = yaml.lines().collect();
981 if line == 0 || line > lines.len() {
982 return None;
983 }
984
985 let start = line.saturating_sub(1).max(1);
986 let end = (line + 1).min(lines.len());
987 let width = end.to_string().len();
988 let mut rendered = Vec::new();
989
990 for current in start..=end {
991 let marker = if current == line { '>' } else { ' ' };
992 rendered.push(format!(
993 "{} {:>width$} | {}",
994 marker,
995 current,
996 lines[current - 1],
997 width = width
998 ));
999 if current == line {
1000 rendered.push(format!(
1001 " {:>width$} | {}^",
1002 "",
1003 " ".repeat(column.saturating_sub(1)),
1004 width = width
1005 ));
1006 }
1007 }
1008
1009 Some(rendered.join("\n"))
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use tempfile::TempDir;
1016
1017 #[test]
1018 fn parse_no_frontmatter() {
1019 let content = "# Hello\n\nBody text.\n";
1020 let (fm, body) = parse(content).unwrap();
1021 assert!(fm.session.is_none());
1022 assert!(fm.agent.is_none());
1023 assert!(fm.model.is_none());
1024 assert!(fm.branch.is_none());
1025 assert_eq!(body, content);
1026 }
1027
1028 #[test]
1029 fn parse_all_fields() {
1030 let content =
1031 "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1032 let (fm, body) = parse(content).unwrap();
1033 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1034 assert_eq!(fm.agent.as_deref(), Some("claude"));
1035 assert_eq!(fm.model.as_deref(), Some("opus"));
1036 assert_eq!(fm.branch.as_deref(), Some("main"));
1037 assert!(body.contains("Body"));
1038 }
1039
1040 #[test]
1041 fn parse_partial_fields() {
1042 let content = "---\nsession: xyz\n---\n# Doc\n";
1043 let (fm, body) = parse(content).unwrap();
1044 assert_eq!(fm.session.as_deref(), Some("xyz"));
1045 assert!(fm.agent.is_none());
1046 assert!(body.contains("# Doc"));
1047 }
1048
1049
1050 #[test]
1051 fn parse_model_tier_high() {
1052 let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
1053 let (fm, _) = parse(content).unwrap();
1054 assert_eq!(fm.model_tier, Some(Tier::High));
1055 }
1056
1057 #[test]
1058 fn parse_collaboration_mode_shared() {
1059 let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
1060 let (fm, _) = parse(content).unwrap();
1061 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1062 }
1063
1064 #[test]
1065 fn parse_security_review_roundtrip() {
1066 let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
1067 let (fm, _) = parse(content).unwrap();
1068 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1069 assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
1070 assert!(fm.has_security_review());
1071 let written = write(&fm, "Body\n").unwrap();
1072 assert!(written.contains("agent_doc_collaboration: shared"));
1073 assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
1074 }
1075
1076 #[test]
1077 fn parse_model_tier_low() {
1078 let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
1079 let (fm, _) = parse(content).unwrap();
1080 assert_eq!(fm.model_tier, Some(Tier::Low));
1081 }
1082
1083 #[test]
1084 fn parse_model_tier_med() {
1085 let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
1086 let (fm, _) = parse(content).unwrap();
1087 assert_eq!(fm.model_tier, Some(Tier::Med));
1088 }
1089
1090 #[test]
1091 fn parse_model_tier_auto() {
1092 let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
1093 let (fm, _) = parse(content).unwrap();
1094 assert_eq!(fm.model_tier, Some(Tier::Auto));
1095 }
1096
1097 #[test]
1098 fn parse_model_tier_absent() {
1099 let content = "---\nagent: claude\n---\nBody\n";
1100 let (fm, _) = parse(content).unwrap();
1101 assert_eq!(fm.model_tier, None);
1102 }
1103
1104 #[test]
1105 fn parse_pending_capture_guard_strict() {
1106 let content = "---\npending_capture_guard: strict\n---\nBody\n";
1107 let (fm, _) = parse(content).unwrap();
1108 assert_eq!(
1109 fm.pending_capture_guard,
1110 Some(PendingCaptureGuardMode::Strict)
1111 );
1112 }
1113
1114 #[test]
1115 fn parse_pending_capture_guard_invalid_rejected() {
1116 let content = "---\npending_capture_guard: loudly\n---\nBody\n";
1117 assert!(parse(content).is_err());
1118 }
1119
1120 #[test]
1121 fn parse_pending_done_guard_strict() {
1122 let content = "---\npending_done_guard: strict\n---\nBody\n";
1123 let (fm, _) = parse(content).unwrap();
1124 assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
1125 }
1126
1127 #[test]
1128 fn parse_pending_done_guard_invalid_rejected() {
1129 let content = "---\npending_done_guard: loudly\n---\nBody\n";
1130 assert!(parse(content).is_err());
1131 }
1132
1133 #[test]
1134 fn parse_model_tier_invalid_rejected() {
1135 let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
1136 let result = parse(content);
1137 assert!(result.is_err(), "invalid tier value should fail to parse");
1138 }
1139
1140 #[test]
1141 fn write_model_tier_roundtrip() {
1142 let fm = Frontmatter {
1143 model_tier: Some(Tier::High),
1144 ..Default::default()
1145 };
1146 let doc = write(&fm, "Body\n").unwrap();
1147 let (parsed, _) = parse(&doc).unwrap();
1148 assert_eq!(parsed.model_tier, Some(Tier::High));
1149 assert!(doc.contains("agent_doc_model_tier: high"));
1150 }
1151
1152 #[test]
1153 fn parse_null_fields() {
1154 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
1155 let (fm, body) = parse(content).unwrap();
1156 assert!(fm.session.is_none());
1157 assert!(fm.agent.is_none());
1158 assert!(fm.model.is_none());
1159 assert!(fm.branch.is_none());
1160 assert!(body.contains("Body"));
1161 }
1162
1163 #[test]
1164 fn parse_unterminated_frontmatter() {
1165 let content = "---\nsession: abc\nno closing block";
1166 let err = parse(content).unwrap_err();
1167 assert!(err.to_string().contains("Unterminated frontmatter"));
1168 }
1169
1170 #[test]
1171 fn parse_closing_at_eof() {
1172 let content = "---\nsession: abc\n---";
1173 let (fm, body) = parse(content).unwrap();
1174 assert_eq!(fm.session.as_deref(), Some("abc"));
1175 assert_eq!(body, "");
1176 }
1177
1178 #[test]
1179 fn parse_empty_body() {
1180 let content = "---\nsession: abc\n---\n";
1181 let (fm, _body) = parse(content).unwrap();
1182 assert_eq!(fm.session.as_deref(), Some("abc"));
1183 }
1184
1185 #[test]
1186 fn write_roundtrip() {
1187 let fm = Frontmatter {
1189 session: Some("test-id".to_string()),
1190 resume: Some("resume-id".to_string()),
1191 agent: Some("claude".to_string()),
1192 model: Some("opus".to_string()),
1193 claude_model: None,
1194 codex_model: None,
1195 opencode_model: None,
1196 branch: Some("dev".to_string()),
1197 tmux_session: None,
1198 mode: None,
1199 format: None,
1200 write_mode: None,
1201 stream_config: None,
1202 agent_args: None,
1203 claude_args: None,
1204 codex_args: None,
1205 opencode_args: None,
1206 codex_network_access: None,
1207 required_ssh_targets: Vec::new(),
1208 required_ssh_profile: None,
1209 no_mcp: None,
1210 enable_tool_search: None,
1211 debounce_ms: None,
1212 links: vec![],
1213 auto_compact: None,
1214 model_tier: None,
1215 pending_capture_guard: None,
1216 pending_done_guard: None,
1217 review_done_guard: None,
1218 auto_done: None,
1219 agent_doc_lint_dialect: None,
1220 hooks: std::collections::HashMap::new(),
1221 env: indexmap::IndexMap::new(),
1222 prompt_presets: indexmap::IndexMap::new(),
1223 dispatch: None,
1224 agent_doc_env_inherit: None,
1225 cwd: None,
1226 queue_active: None,
1227 collaboration: None,
1228 security_review: None,
1229 };
1230 let body = "# Hello\n\nBody text.\n";
1231 let written = write(&fm, body).unwrap();
1232 let (fm2, body2) = parse(&written).unwrap();
1233 assert_eq!(fm2.session, fm.session);
1234 assert_eq!(fm2.agent, fm.agent);
1235 assert_eq!(fm2.model, fm.model);
1236 assert_eq!(fm2.claude_model, fm.claude_model);
1237 assert_eq!(fm2.codex_model, fm.codex_model);
1238 assert_eq!(fm2.opencode_model, fm.opencode_model);
1239 assert_eq!(fm2.branch, fm.branch);
1240 assert_eq!(fm2.collaboration, fm.collaboration);
1241 assert_eq!(fm2.security_review, fm.security_review);
1242 assert_eq!(body2, body);
1243 }
1244
1245 #[test]
1246 fn repeated_parse_write_roundtrip_preserves_body_prefix() {
1247 let fm = Frontmatter {
1248 session: Some("abc".to_string()),
1249 agent: Some("codex".to_string()),
1250 ..Default::default()
1251 };
1252 let original = write(&fm, "## Status\n\nBody\n").unwrap();
1253 let mut current = original.clone();
1254
1255 for _ in 0..3 {
1256 let (fm, body) = parse(¤t).unwrap();
1257 assert_eq!(body, "## Status\n\nBody\n");
1258 current = write(&fm, body).unwrap();
1259 }
1260
1261 let (fm, body) = parse(¤t).unwrap();
1262 assert_eq!(fm.session.as_deref(), Some("abc"));
1263 assert_eq!(fm.agent.as_deref(), Some("codex"));
1264 assert_eq!(body, "## Status\n\nBody\n");
1265 assert_eq!(current, original);
1266 }
1267
1268 #[test]
1269 fn write_default_frontmatter() {
1270 let fm = Frontmatter::default();
1271 let result = write(&fm, "body\n").unwrap();
1272 assert!(result.starts_with("---\n"));
1273 assert!(result.ends_with("---\nbody\n"));
1274 }
1275
1276 #[test]
1277 fn write_preserves_body_content() {
1278 let fm = Frontmatter::default();
1279 let body = "# Title\n\nSome **markdown** with `code`.\n";
1280 let result = write(&fm, body).unwrap();
1281 assert!(result.contains("# Title"));
1282 assert!(result.contains("Some **markdown** with `code`."));
1283 }
1284
1285 #[test]
1286 fn set_session_id_creates_frontmatter() {
1287 let content = "# No frontmatter\n\nJust body.\n";
1288 let result = set_session_id(content, "new-session").unwrap();
1289 let (fm, body) = parse(&result).unwrap();
1290 assert_eq!(fm.session.as_deref(), Some("new-session"));
1291 assert!(body.contains("# No frontmatter"));
1292 }
1293
1294 #[test]
1295 fn set_session_id_updates_existing() {
1296 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
1297 let result = set_session_id(content, "new-id").unwrap();
1298 let (fm, body) = parse(&result).unwrap();
1299 assert_eq!(fm.session.as_deref(), Some("new-id"));
1300 assert_eq!(fm.agent.as_deref(), Some("claude"));
1301 assert!(body.contains("Body"));
1302 }
1303
1304 #[test]
1305 fn set_session_id_preserves_other_fields() {
1306 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
1307 let result = set_session_id(content, "new").unwrap();
1308 let (fm, _) = parse(&result).unwrap();
1309 assert_eq!(fm.session.as_deref(), Some("new"));
1310 assert_eq!(fm.agent.as_deref(), Some("claude"));
1311 assert_eq!(fm.model.as_deref(), Some("opus"));
1312 assert_eq!(fm.branch.as_deref(), Some("dev"));
1313 }
1314
1315 #[test]
1316 fn ensure_session_no_frontmatter() {
1317 let content = "# Hello\n\nBody.\n";
1318 let (updated, sid) = ensure_session(content).unwrap();
1319 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
1322 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
1323 assert!(body.contains("# Hello"));
1324 }
1325
1326 #[test]
1327 fn ensure_session_null_session() {
1328 let content = "---\nsession:\nagent: claude\n---\nBody\n";
1329 let (updated, sid) = ensure_session(content).unwrap();
1330 assert_eq!(sid.len(), 36);
1331 let (fm, body) = parse(&updated).unwrap();
1332 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
1333 assert_eq!(fm.agent.as_deref(), Some("claude"));
1334 assert!(body.contains("Body"));
1335 }
1336
1337 #[test]
1338 fn ensure_session_existing_session() {
1339 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
1340 let (updated, sid) = ensure_session(content).unwrap();
1341 assert_eq!(sid, "existing-id");
1342 assert_eq!(updated, content);
1344 }
1345
1346 #[test]
1347 fn parse_legacy_session_field() {
1348 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
1350 let (fm, body) = parse(content).unwrap();
1351 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
1352 assert_eq!(fm.agent.as_deref(), Some("claude"));
1353 assert!(body.contains("Body"));
1354 }
1355
1356 #[test]
1357 fn parse_agent_doc_mode_canonical() {
1358 let content = "---\nagent_doc_mode: template\n---\nBody\n";
1359 let (fm, _) = parse(content).unwrap();
1360 assert_eq!(fm.mode.as_deref(), Some("template"));
1361 }
1362
1363 #[test]
1364 fn parse_mode_shorthand_alias() {
1365 let content = "---\nmode: template\n---\nBody\n";
1366 let (fm, _) = parse(content).unwrap();
1367 assert_eq!(fm.mode.as_deref(), Some("template"));
1368 }
1369
1370 #[test]
1371 fn parse_response_mode_legacy_alias() {
1372 let content = "---\nresponse_mode: template\n---\nBody\n";
1373 let (fm, _) = parse(content).unwrap();
1374 assert_eq!(fm.mode.as_deref(), Some("template"));
1375 }
1376
1377 #[test]
1378 fn write_uses_agent_doc_mode_field() {
1379 #[allow(deprecated)]
1380 let fm = Frontmatter {
1381 mode: Some("template".to_string()),
1382 ..Default::default()
1383 };
1384 let result = write(&fm, "body\n").unwrap();
1385 assert!(result.contains("agent_doc_mode:"));
1386 assert!(!result.contains("response_mode:"));
1387 assert!(!result.contains("\nmode:"));
1388 }
1389
1390 #[test]
1391 fn write_uses_new_field_name() {
1392 let fm = Frontmatter {
1393 session: Some("test-id".to_string()),
1394 ..Default::default()
1395 };
1396 let result = write(&fm, "body\n").unwrap();
1397 assert!(result.contains("agent_doc_session:"));
1398 assert!(!result.contains("\nsession:"));
1399 }
1400
1401 #[test]
1402 fn write_pending_capture_guard_roundtrip() {
1403 let fm = Frontmatter {
1404 pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
1405 ..Default::default()
1406 };
1407 let written = write(&fm, "body\n").unwrap();
1408 assert!(written.contains("pending_capture_guard: strict"));
1409 let (parsed, _) = parse(&written).unwrap();
1410 assert_eq!(
1411 parsed.pending_capture_guard,
1412 Some(PendingCaptureGuardMode::Strict)
1413 );
1414 }
1415
1416 #[test]
1417 fn write_pending_done_guard_roundtrip() {
1418 let fm = Frontmatter {
1419 pending_done_guard: Some(PendingCaptureGuardMode::Strict),
1420 ..Default::default()
1421 };
1422 let written = write(&fm, "body\n").unwrap();
1423 assert!(written.contains("pending_done_guard: strict"));
1424 let (parsed, _) = parse(&written).unwrap();
1425 assert_eq!(
1426 parsed.pending_done_guard,
1427 Some(PendingCaptureGuardMode::Strict)
1428 );
1429 }
1430
1431 #[test]
1432 fn parse_review_done_guard_error_alias() {
1433 let content = "---\nreview_done_guard: error\n---\nBody\n";
1434 let (fm, _) = parse(content).unwrap();
1435 assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
1436 }
1437
1438 #[test]
1439 fn write_review_done_guard_roundtrip() {
1440 let fm = Frontmatter {
1441 review_done_guard: Some(PendingCaptureGuardMode::Strict),
1442 ..Default::default()
1443 };
1444 let written = write(&fm, "body\n").unwrap();
1445 assert!(written.contains("review_done_guard: strict"));
1446 let (parsed, _) = parse(&written).unwrap();
1447 assert_eq!(
1448 parsed.review_done_guard,
1449 Some(PendingCaptureGuardMode::Strict)
1450 );
1451 }
1452
1453 #[test]
1454 fn parse_auto_done_bool() {
1455 let content = "---\nauto_done: true\n---\nBody\n";
1456 let (fm, _) = parse(content).unwrap();
1457 assert_eq!(fm.auto_done, Some(true));
1458 }
1459
1460 #[test]
1461 fn write_auto_done_roundtrip() {
1462 let fm = Frontmatter {
1463 auto_done: Some(true),
1464 ..Default::default()
1465 };
1466 let written = write(&fm, "Body\n").unwrap();
1467 assert!(written.contains("auto_done: true"));
1468 let (parsed, _) = parse(&written).unwrap();
1469 assert_eq!(parsed.auto_done, Some(true));
1470 }
1471
1472 #[test]
1473 fn parse_prompt_presets_roundtrip() {
1474 let content = "---\nprompt_presets:\n \"#1\": |\n Today is 2026-04-25.\n Keep the work tree clean.\n release-check: |\n Run cargo test.\n---\nBody\n";
1475 let (fm, body) = parse(content).unwrap();
1476 assert_eq!(body, "Body\n");
1477 assert_eq!(
1478 fm.prompt_presets.get("#1").map(String::as_str),
1479 Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
1480 );
1481 assert_eq!(
1482 fm.prompt_presets.get("release-check").map(String::as_str),
1483 Some("Run cargo test.")
1484 );
1485
1486 let written = write(&fm, body).unwrap();
1487 let (parsed, body2) = parse(&written).unwrap();
1488 assert_eq!(body2, "Body\n");
1489 assert_eq!(parsed.prompt_presets, fm.prompt_presets);
1490 }
1491
1492 #[test]
1493 fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
1494 let mut prompt_presets = indexmap::IndexMap::new();
1495 prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
1496 prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
1497
1498 assert_eq!(
1499 resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
1500 Some("#spec-test")
1501 );
1502 assert_eq!(
1503 resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
1504 Some("#spec-test")
1505 );
1506 assert_eq!(
1507 resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
1508 Some("release-check")
1509 );
1510 assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
1511 }
1512
1513 #[test]
1516 fn resolve_mode_defaults() {
1517 let fm = Frontmatter::default();
1518 let resolved = fm.resolve_mode();
1519 assert_eq!(resolved.format, AgentDocFormat::Template);
1520 assert_eq!(resolved.write, AgentDocWrite::Crdt);
1521 }
1522
1523 #[test]
1524 fn resolve_mode_from_deprecated_append() {
1525 let content = "---\nagent_doc_mode: append\n---\nBody\n";
1526 let (fm, _) = parse(content).unwrap();
1527 let resolved = fm.resolve_mode();
1528 assert_eq!(resolved.format, AgentDocFormat::Append);
1529 assert_eq!(resolved.write, AgentDocWrite::Crdt);
1530 }
1531
1532 #[test]
1533 fn resolve_mode_from_deprecated_template() {
1534 let content = "---\nagent_doc_mode: template\n---\nBody\n";
1535 let (fm, _) = parse(content).unwrap();
1536 let resolved = fm.resolve_mode();
1537 assert_eq!(resolved.format, AgentDocFormat::Template);
1538 assert_eq!(resolved.write, AgentDocWrite::Crdt);
1539 }
1540
1541 #[test]
1542 fn resolve_mode_from_deprecated_stream() {
1543 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
1544 let (fm, _) = parse(content).unwrap();
1545 let resolved = fm.resolve_mode();
1546 assert_eq!(resolved.format, AgentDocFormat::Template);
1547 assert_eq!(resolved.write, AgentDocWrite::Crdt);
1548 }
1549
1550 #[test]
1551 fn resolve_mode_new_fields_override_deprecated() {
1552 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
1553 let (fm, _) = parse(content).unwrap();
1554 let resolved = fm.resolve_mode();
1555 assert_eq!(resolved.format, AgentDocFormat::Template);
1556 assert_eq!(resolved.write, AgentDocWrite::Merge);
1557 }
1558
1559 #[test]
1560 fn resolve_mode_explicit_new_fields_only() {
1561 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
1562 let (fm, _) = parse(content).unwrap();
1563 let resolved = fm.resolve_mode();
1564 assert_eq!(resolved.format, AgentDocFormat::Append);
1565 assert_eq!(resolved.write, AgentDocWrite::Crdt);
1566 }
1567
1568 #[test]
1569 fn resolve_mode_partial_new_field_format_only() {
1570 let content = "---\nagent_doc_format: append\n---\nBody\n";
1571 let (fm, _) = parse(content).unwrap();
1572 let resolved = fm.resolve_mode();
1573 assert_eq!(resolved.format, AgentDocFormat::Append);
1574 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
1576
1577 #[test]
1578 fn resolve_mode_partial_new_field_write_only() {
1579 let content = "---\nagent_doc_write: merge\n---\nBody\n";
1580 let (fm, _) = parse(content).unwrap();
1581 let resolved = fm.resolve_mode();
1582 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
1584 }
1585
1586 #[test]
1587 fn resolve_mode_helper_methods() {
1588 let fm = Frontmatter::default();
1589 let resolved = fm.resolve_mode();
1590 assert!(resolved.is_template());
1591 assert!(!resolved.is_append());
1592 assert!(resolved.is_crdt());
1593 }
1594
1595 #[test]
1596 fn parse_new_format_field() {
1597 let content = "---\nagent_doc_format: template\n---\nBody\n";
1598 let (fm, _) = parse(content).unwrap();
1599 assert_eq!(fm.format, Some(AgentDocFormat::Template));
1600 }
1601
1602 #[test]
1603 fn parse_new_write_field() {
1604 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
1605 let (fm, _) = parse(content).unwrap();
1606 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1607 }
1608
1609 #[test]
1610 fn write_uses_new_format_write_fields() {
1611 let fm = Frontmatter {
1612 format: Some(AgentDocFormat::Template),
1613 write_mode: Some(AgentDocWrite::Crdt),
1614 ..Default::default()
1615 };
1616 let result = write(&fm, "body\n").unwrap();
1617 assert!(result.contains("agent_doc_format:"));
1618 assert!(result.contains("agent_doc_write:"));
1619 assert!(!result.contains("agent_doc_mode:"));
1620 }
1621
1622 #[test]
1623 fn set_format_and_write_clears_deprecated_mode() {
1624 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
1625 let result =
1626 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1627 let (fm, _) = parse(&result).unwrap();
1628 assert!(fm.mode.is_none());
1629 assert_eq!(fm.format, Some(AgentDocFormat::Template));
1630 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1631 }
1632
1633 #[test]
1636 fn merge_fields_adds_new_field() {
1637 let content = "---\nagent_doc_session: abc\n---\nBody\n";
1638 let result = merge_fields(content, "model: opus").unwrap();
1639 let (fm, body) = parse(&result).unwrap();
1640 assert_eq!(fm.session.as_deref(), Some("abc"));
1641 assert_eq!(fm.model.as_deref(), Some("opus"));
1642 assert!(body.contains("Body"));
1643 }
1644
1645 #[test]
1646 fn merge_fields_updates_existing_field() {
1647 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
1648 let result = merge_fields(content, "model: opus").unwrap();
1649 let (fm, _) = parse(&result).unwrap();
1650 assert_eq!(fm.model.as_deref(), Some("opus"));
1651 assert_eq!(fm.session.as_deref(), Some("abc"));
1652 }
1653
1654 #[test]
1655 fn merge_fields_multiple_fields() {
1656 let content = "---\nagent_doc_session: abc\n---\nBody\n";
1657 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
1658 let (fm, _) = parse(&result).unwrap();
1659 assert_eq!(fm.model.as_deref(), Some("opus"));
1660 assert_eq!(fm.agent.as_deref(), Some("claude"));
1661 assert_eq!(fm.branch.as_deref(), Some("main"));
1662 }
1663
1664 #[test]
1665 fn merge_fields_format_enum() {
1666 let content = "---\nagent_doc_session: abc\n---\nBody\n";
1667 let result = merge_fields(content, "agent_doc_format: append").unwrap();
1668 let (fm, _) = parse(&result).unwrap();
1669 assert_eq!(fm.format, Some(AgentDocFormat::Append));
1670 }
1671
1672 #[test]
1673 fn merge_fields_write_enum() {
1674 let content = "---\nagent_doc_session: abc\n---\nBody\n";
1675 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
1676 let (fm, _) = parse(&result).unwrap();
1677 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
1678 }
1679
1680 #[test]
1681 fn merge_fields_ignores_unknown() {
1682 let content = "---\nagent_doc_session: abc\n---\nBody\n";
1683 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
1684 let (fm, _) = parse(&result).unwrap();
1685 assert_eq!(fm.model.as_deref(), Some("opus"));
1686 }
1687
1688 #[test]
1689 fn merge_fields_preserves_body() {
1690 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
1691 let result = merge_fields(content, "model: opus").unwrap();
1692 assert!(result.contains("# Title"));
1693 assert!(result.contains("Some **markdown** content."));
1694 }
1695
1696 #[test]
1697 fn set_format_and_write_clears_deprecated() {
1698 let content = "---\nagent_doc_mode: append\n---\nBody\n";
1699 let result =
1700 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1701 let (fm, _) = parse(&result).unwrap();
1702 assert!(fm.mode.is_none());
1703 assert_eq!(fm.format, Some(AgentDocFormat::Template));
1704 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1705 }
1706
1707 #[test]
1708 fn hooks_roundtrip() {
1709 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
1710 let (fm, _) = parse(content).unwrap();
1711 assert_eq!(
1712 fm.hooks.get("session_start"),
1713 Some(&vec!["echo start {{session_id}}".to_string()])
1714 );
1715 assert_eq!(
1716 fm.hooks.get("post_write"),
1717 Some(&vec!["notify {{file}}".to_string()])
1718 );
1719 }
1720
1721 #[test]
1722 fn hooks_omitted_when_empty() {
1723 let fm = Frontmatter::default();
1724 let result = write(&fm, "body\n").unwrap();
1725 assert!(!result.contains("hooks"));
1726 }
1727
1728 #[test]
1729 fn hooks_absent_parses_as_empty() {
1730 let content = "---\nsession: abc\n---\nBody\n";
1731 let (fm, _) = parse(content).unwrap();
1732 assert!(fm.hooks.is_empty());
1733 }
1734
1735 #[test]
1736 fn parse_no_mcp_field() {
1737 let content = "---\nno_mcp: true\n---\nBody\n";
1738 let (fm, _) = parse(content).unwrap();
1739 assert_eq!(fm.no_mcp, Some(true));
1740 }
1741
1742 #[test]
1743 fn parse_enable_tool_search_field() {
1744 let content = "---\nenable_tool_search: true\n---\nBody\n";
1745 let (fm, _) = parse(content).unwrap();
1746 assert_eq!(fm.enable_tool_search, Some(true));
1747 }
1748
1749 #[test]
1750 fn parse_missing_flags_default_none() {
1751 let content = "---\nsession: abc\n---\nBody\n";
1752 let (fm, _) = parse(content).unwrap();
1753 assert!(fm.no_mcp.is_none());
1754 assert!(fm.enable_tool_search.is_none());
1755 }
1756
1757 #[test]
1758 fn parse_env_map() {
1759 let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
1760 let (fm, _) = parse(content).unwrap();
1761 assert_eq!(fm.env.len(), 2);
1762 assert_eq!(fm.env["FOO"], Some("bar".to_string()));
1763 assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
1764 let keys: Vec<&String> = fm.env.keys().collect();
1766 assert_eq!(keys, vec!["FOO", "BAZ"]);
1767 }
1768
1769 #[test]
1770 fn parse_env_empty_default() {
1771 let content = "---\nsession: abc\n---\nBody\n";
1772 let (fm, _) = parse(content).unwrap();
1773 assert!(fm.env.is_empty());
1774 }
1775
1776 #[test]
1777 fn parse_env_unset_via_null() {
1778 let content = "---\nenv:\n SET_ME: value\n UNSET_ME: null\n---\nBody\n";
1779 let (fm, _) = parse(content).unwrap();
1780 assert_eq!(fm.env.len(), 2);
1781 assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
1782 assert_eq!(fm.env["UNSET_ME"], None);
1783 }
1784
1785 #[test]
1786 fn write_roundtrip_with_env() {
1787 let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
1788 env.insert("KEY1".to_string(), Some("value1".to_string()));
1789 env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
1790 env.insert("KEY3".to_string(), None);
1791 let fm = Frontmatter {
1792 env,
1793 ..Default::default()
1794 };
1795 let written = write(&fm, "body\n").unwrap();
1796 let (fm2, _) = parse(&written).unwrap();
1797 assert_eq!(fm2.env.len(), 3);
1798 assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
1799 assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
1800 assert_eq!(fm2.env["KEY3"], None);
1801 }
1802
1803 #[test]
1804 fn parse_agent_args_field() {
1805 let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
1806 let (fm, _) = parse(content).unwrap();
1807 assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
1808 assert!(fm.claude_args.is_none());
1809 assert!(fm.codex_args.is_none());
1810 assert!(fm.opencode_args.is_none());
1811 }
1812
1813 #[test]
1814 fn parse_agent_args_and_harness_aliases() {
1815 let content = "---\nagent_args: \"--json\"\nclaude_args: \"--dangerously-skip-permissions\"\ncodex_args: \"-s danger-full-access\"\nopencode_args: \"--dangerously-skip-permissions\"\ncodex_network_access: enabled\nrequired_ssh_targets:\n - monsterrodholders-server\n---\nBody\n";
1816 let (fm, _) = parse(content).unwrap();
1817 assert_eq!(fm.agent_args.as_deref(), Some("--json"));
1818 assert_eq!(
1819 fm.claude_args.as_deref(),
1820 Some("--dangerously-skip-permissions")
1821 );
1822 assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
1823 assert_eq!(
1824 fm.opencode_args.as_deref(),
1825 Some("--dangerously-skip-permissions")
1826 );
1827 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
1828 assert_eq!(
1829 fm.required_ssh_targets,
1830 vec!["monsterrodholders-server".to_string()]
1831 );
1832 }
1833
1834 #[test]
1835 fn write_roundtrip_agent_args() {
1836 let fm = Frontmatter {
1837 agent_args: Some("--json -s workspace-write".to_string()),
1838 codex_args: Some("-s danger-full-access".to_string()),
1839 opencode_args: Some("--dangerously-skip-permissions".to_string()),
1840 codex_network_access: Some(CodexNetworkAccess::Enabled),
1841 required_ssh_targets: vec!["monsterrodholders-server".to_string()],
1842 ..Default::default()
1843 };
1844 let written = write(&fm, "body\n").unwrap();
1845 let (fm2, _) = parse(&written).unwrap();
1846 assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
1847 assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
1848 assert_eq!(
1849 fm2.opencode_args.as_deref(),
1850 Some("--dangerously-skip-permissions")
1851 );
1852 assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
1853 assert_eq!(
1854 fm2.required_ssh_targets,
1855 vec!["monsterrodholders-server".to_string()]
1856 );
1857 }
1858
1859 #[test]
1860 fn merge_fields_agent_args() {
1861 let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
1862 let result = merge_fields(content, "agent_args: new").unwrap();
1863 let (fm, _) = parse(&result).unwrap();
1864 assert_eq!(fm.agent_args.as_deref(), Some("new"));
1865 assert_eq!(fm.claude_args.as_deref(), Some("old"));
1866 assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
1867 assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
1868 }
1869
1870 #[test]
1871 fn merge_fields_codex_args() {
1872 let content = "---\nagent_args: old\n---\nBody\n";
1873 let result = merge_fields(content, "codex_args: new").unwrap();
1874 let (fm, _) = parse(&result).unwrap();
1875 assert_eq!(fm.agent_args.as_deref(), Some("old"));
1876 assert_eq!(fm.codex_args.as_deref(), Some("new"));
1877 }
1878
1879 #[test]
1880 fn merge_fields_opencode_args() {
1881 let content = "---\nagent_args: old\n---\nBody\n";
1882 let result = merge_fields(content, "opencode_args: new").unwrap();
1883 let (fm, _) = parse(&result).unwrap();
1884 assert_eq!(fm.agent_args.as_deref(), Some("old"));
1885 assert_eq!(fm.opencode_args.as_deref(), Some("new"));
1886 }
1887
1888 #[test]
1889 fn merge_fields_codex_network_access() {
1890 let content = "Body\n";
1891 let result = merge_fields(content, "codex_network_access: disabled").unwrap();
1892 let (fm, _) = parse(&result).unwrap();
1893 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
1894 }
1895
1896 #[test]
1897 fn merge_fields_required_ssh_targets() {
1898 let content = "Body\n";
1899 let result = merge_fields(
1900 content,
1901 "required_ssh_targets:\n - monsterrodholders-server\n - root@50.28.2.199",
1902 )
1903 .unwrap();
1904 let (fm, _) = parse(&result).unwrap();
1905 assert_eq!(
1906 fm.required_ssh_targets,
1907 vec![
1908 "monsterrodholders-server".to_string(),
1909 "root@50.28.2.199".to_string(),
1910 ]
1911 );
1912 }
1913
1914 #[test]
1915 fn merge_fields_required_ssh_profile() {
1916 let content = "Body\n";
1917 let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
1918 let (fm, _) = parse(&result).unwrap();
1919 assert_eq!(
1920 fm.required_ssh_profile.as_deref(),
1921 Some("monsterrodholders")
1922 );
1923 }
1924
1925 #[test]
1928 fn resolve_harness_model_claude_code_uses_claude_model() {
1929 let fm = Frontmatter {
1930 model: Some("gpt-5".to_string()),
1931 claude_model: Some("opus".to_string()),
1932 codex_model: Some("o3-pro".to_string()),
1933 ..Default::default()
1934 };
1935 assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
1936 }
1937
1938 #[test]
1939 fn resolve_harness_model_codex_uses_codex_model() {
1940 let fm = Frontmatter {
1941 model: Some("gpt-5".to_string()),
1942 claude_model: Some("opus".to_string()),
1943 codex_model: Some("o3-pro".to_string()),
1944 opencode_model: Some("zai/glm-5".to_string()),
1945 ..Default::default()
1946 };
1947 assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
1948 }
1949
1950 #[test]
1951 fn resolve_harness_model_opencode_uses_opencode_model() {
1952 let fm = Frontmatter {
1953 model: Some("gpt-5".to_string()),
1954 claude_model: Some("opus".to_string()),
1955 codex_model: Some("o3-pro".to_string()),
1956 opencode_model: Some("zai/glm-5".to_string()),
1957 ..Default::default()
1958 };
1959 assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
1960 }
1961
1962 #[test]
1963 fn resolve_harness_model_falls_back_to_generic() {
1964 let fm = Frontmatter {
1965 model: Some("gpt-5".to_string()),
1966 ..Default::default()
1967 };
1968 assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
1969 assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
1970 assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
1971 assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
1972 }
1973
1974 #[test]
1975 fn resolve_harness_model_none_when_no_model() {
1976 let fm = Frontmatter::default();
1977 assert_eq!(fm.resolve_harness_model("claude-code"), None);
1978 }
1979
1980 #[test]
1981 fn resolve_harness_model_unknown_harness_uses_generic() {
1982 let fm = Frontmatter {
1983 model: Some("sonnet".to_string()),
1984 claude_model: Some("opus".to_string()),
1985 ..Default::default()
1986 };
1987 assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
1988 }
1989
1990 #[test]
1991 fn parse_harness_model_fields() {
1992 let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
1993 let (fm, _) = parse(content).unwrap();
1994 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
1995 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
1996 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
1997 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
1998 }
1999
2000 #[test]
2001 fn write_roundtrip_harness_model_fields() {
2002 let fm = Frontmatter {
2003 model: Some("gpt-5".to_string()),
2004 claude_model: Some("opus".to_string()),
2005 codex_model: Some("o3-pro".to_string()),
2006 opencode_model: Some("zai/glm-5".to_string()),
2007 ..Default::default()
2008 };
2009 let doc = write(&fm, "Body\n").unwrap();
2010 let (fm2, body) = parse(&doc).unwrap();
2011 assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
2012 assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
2013 assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
2014 assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
2015 assert_eq!(body, "Body\n");
2016 }
2017
2018 #[test]
2019 fn merge_fields_claude_model() {
2020 let content = "---\nmodel: gpt-5\n---\nBody\n";
2021 let result = merge_fields(content, "claude_model: opus").unwrap();
2022 let (fm, _) = parse(&result).unwrap();
2023 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2024 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2025 }
2026
2027 #[test]
2028 fn merge_fields_codex_model() {
2029 let content = "---\nmodel: gpt-5\n---\nBody\n";
2030 let result = merge_fields(content, "codex_model: o3-pro").unwrap();
2031 let (fm, _) = parse(&result).unwrap();
2032 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2033 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2034 }
2035
2036 #[test]
2037 fn merge_fields_opencode_model() {
2038 let content = "---\nmodel: gpt-5\n---\nBody\n";
2039 let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
2040 let (fm, _) = parse(&result).unwrap();
2041 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2042 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2043 }
2044}