1use anyhow::Result;
100use serde::{Deserialize, Serialize};
101use std::fmt;
102use uuid::Uuid;
103
104use crate::model_tier::Tier;
105
106#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum CollaborationMode {
109 #[default]
110 SingleUser,
111 Shared,
112}
113
114#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum CodexNetworkAccess {
117 #[default]
118 Inherit,
119 Enabled,
120 Disabled,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum QueueControl {
132 Start,
134 Stop,
136}
137
138impl QueueControl {
139 pub fn parse(raw: &str) -> Option<Self> {
142 match raw.trim().to_ascii_lowercase().as_str() {
143 "start" | "go" => Some(Self::Start),
144 "stop" => Some(Self::Stop),
145 _ => None,
146 }
147 }
148
149 pub fn is_active(self) -> bool {
151 matches!(self, Self::Start)
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
157pub enum AgentDocFormat {
158 #[clap(alias = "inline")]
160 Append,
161 Template,
163}
164
165impl fmt::Display for AgentDocFormat {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 match self {
168 Self::Append => write!(f, "inline"),
169 Self::Template => write!(f, "template"),
170 }
171 }
172}
173
174impl Serialize for AgentDocFormat {
175 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
176 where
177 S: serde::Serializer,
178 {
179 match self {
180 Self::Append => serializer.serialize_str("inline"),
181 Self::Template => serializer.serialize_str("template"),
182 }
183 }
184}
185
186impl<'de> Deserialize<'de> for AgentDocFormat {
187 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
188 where
189 D: serde::Deserializer<'de>,
190 {
191 let s = String::deserialize(deserializer)?;
192 match s.as_str() {
193 "append" | "inline" => Ok(Self::Append),
194 "template" => Ok(Self::Template),
195 other => Err(serde::de::Error::unknown_variant(
196 other,
197 &["inline", "append", "template"],
198 )),
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
205#[serde(rename_all = "lowercase")]
206pub enum AgentDocWrite {
207 Merge,
209 Crdt,
211}
212
213impl fmt::Display for AgentDocWrite {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 match self {
216 Self::Merge => write!(f, "merge"),
217 Self::Crdt => write!(f, "crdt"),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct ResolvedMode {
225 pub format: AgentDocFormat,
226 pub write: AgentDocWrite,
227}
228
229impl ResolvedMode {
230 pub fn is_template(&self) -> bool {
231 self.format == AgentDocFormat::Template
232 }
233
234 pub fn is_append(&self) -> bool {
235 self.format == AgentDocFormat::Append
236 }
237
238 pub fn is_crdt(&self) -> bool {
239 self.write == AgentDocWrite::Crdt
240 }
241}
242
243#[derive(Debug, Default, Clone, Serialize, Deserialize)]
245pub struct StreamConfig {
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub interval: Option<u64>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub strip_ansi: Option<bool>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub target: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub thinking: Option<bool>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub thinking_target: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub max_lines: Option<usize>,
266}
267
268#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct AgentDocPipeline {
285 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub turn_id: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub run_id: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub step: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub queue_task_id: Option<String>,
304}
305
306impl AgentDocPipeline {
307 pub fn is_empty(&self) -> bool {
310 self.turn_id.is_none()
311 && self.run_id.is_none()
312 && self.step.is_none()
313 && self.queue_task_id.is_none()
314 }
315}
316
317#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "lowercase")]
319pub enum PendingCaptureGuardMode {
320 #[default]
321 Warn,
322 #[serde(alias = "error")]
323 Strict,
324 Off,
325}
326
327#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum LintDialectMode {
341 #[default]
342 Warn,
343 #[serde(alias = "error")]
344 Strict,
345 Off,
346}
347
348#[derive(Debug, Default, Clone, Serialize, Deserialize)]
349pub struct Frontmatter {
350 #[serde(
353 default,
354 skip_serializing_if = "Option::is_none",
355 rename = "agent_doc_session",
356 alias = "session"
357 )]
358 pub session: Option<String>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub resume: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub agent: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub model: Option<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub claude_model: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub codex_model: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub opencode_model: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub branch: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub tmux_session: Option<String>,
388 #[serde(
392 default,
393 skip_serializing_if = "Option::is_none",
394 rename = "agent_doc_mode",
395 alias = "mode",
396 alias = "response_mode"
397 )]
398 pub mode: Option<String>,
399 #[serde(
401 default,
402 skip_serializing_if = "Option::is_none",
403 rename = "agent_doc_format"
404 )]
405 pub format: Option<AgentDocFormat>,
406 #[serde(
408 default,
409 skip_serializing_if = "Option::is_none",
410 rename = "agent_doc_write"
411 )]
412 pub write_mode: Option<AgentDocWrite>,
413 #[serde(
415 default,
416 skip_serializing_if = "Option::is_none",
417 rename = "agent_doc_stream"
418 )]
419 pub stream_config: Option<StreamConfig>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub agent_args: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub claude_args: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub codex_args: Option<String>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub opencode_args: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub codex_network_access: Option<CodexNetworkAccess>,
441 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub required_ssh_targets: Vec<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub required_ssh_profile: Option<String>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub managed_proof_max_attempts: Option<u32>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub managed_proof_retry_backoff_secs: Option<u64>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub managed_proof_probe_timeout_secs: Option<u64>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub no_mcp: Option<bool>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub enable_tool_search: Option<bool>,
472 #[serde(
475 default,
476 skip_serializing_if = "Option::is_none",
477 rename = "agent_doc_debounce"
478 )]
479 pub debounce_ms: Option<u64>,
480 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
483 pub links: Vec<String>,
484 #[serde(
487 default,
488 skip_serializing_if = "Option::is_none",
489 rename = "agent_doc_auto_compact"
490 )]
491 pub auto_compact: Option<usize>,
492 #[serde(
496 default,
497 skip_serializing_if = "Option::is_none",
498 rename = "agent_doc_queue_context_reset"
499 )]
500 pub queue_context_reset: Option<bool>,
501 #[serde(
508 default,
509 skip_serializing_if = "Option::is_none",
510 rename = "agent_doc_gate_autoverify"
511 )]
512 pub gate_autoverify: Option<bool>,
513 #[serde(
521 default,
522 skip_serializing_if = "Option::is_none",
523 rename = "agent_doc_clear_threshold"
524 )]
525 pub clear_threshold: Option<u8>,
526 #[serde(
536 default,
537 skip_serializing_if = "Option::is_none",
538 rename = "agent_doc_supervisor_auto_recycle"
539 )]
540 pub supervisor_auto_recycle: Option<bool>,
541 #[serde(
552 default,
553 skip_serializing_if = "Option::is_none",
554 rename = "agent_doc_agent_change_restart"
555 )]
556 pub agent_change_restart: Option<bool>,
557 #[serde(
569 default,
570 skip_serializing_if = "Option::is_none",
571 rename = "agent_doc_supervisor_auto_install"
572 )]
573 pub supervisor_auto_install: Option<bool>,
574 #[serde(
579 default,
580 skip_serializing_if = "Option::is_none",
581 rename = "agent_doc_model_tier"
582 )]
583 pub model_tier: Option<Tier>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub pending_capture_guard: Option<PendingCaptureGuardMode>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub pending_done_guard: Option<PendingCaptureGuardMode>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub review_done_guard: Option<PendingCaptureGuardMode>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub auto_done: Option<bool>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub agent_doc_lint_dialect: Option<LintDialectMode>,
606 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
628 pub hooks: std::collections::HashMap<String, Vec<String>>,
629 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
636 pub env: indexmap::IndexMap<String, Option<String>>,
637 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
647 pub prompt_presets: indexmap::IndexMap<String, String>,
648 #[serde(
652 default,
653 skip_serializing_if = "Option::is_none",
654 rename = "agent_doc_dispatch"
655 )]
656 pub dispatch: Option<String>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub agent_doc_env_inherit: Option<bool>,
665 #[serde(
671 default,
672 skip_serializing_if = "Option::is_none",
673 rename = "agent_doc_cwd"
674 )]
675 pub cwd: Option<String>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub queue: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub queue_active: Option<bool>,
691 #[serde(
699 default,
700 skip_serializing_if = "Option::is_none",
701 rename = "agent_doc_queue_prompt_echo_max_chars"
702 )]
703 pub queue_prompt_echo_max_chars: Option<usize>,
704 #[serde(
707 default,
708 skip_serializing_if = "Option::is_none",
709 rename = "agent_doc_collaboration"
710 )]
711 pub collaboration: Option<CollaborationMode>,
712 #[serde(
714 default,
715 skip_serializing_if = "Option::is_none",
716 rename = "agent_doc_security_review"
717 )]
718 pub security_review: Option<String>,
719 #[serde(
724 default,
725 skip_serializing_if = "AgentDocPipeline::is_empty",
726 rename = "agent_doc_pipeline"
727 )]
728 pub pipeline: AgentDocPipeline,
729}
730
731pub fn resolve_prompt_preset_key(
737 prompt_presets: &indexmap::IndexMap<String, String>,
738 requested: &str,
739) -> Option<String> {
740 let requested = requested.trim();
741 if prompt_presets.contains_key(requested) {
742 return Some(requested.to_string());
743 }
744 if !requested.starts_with('#') {
745 let hashtag_key = format!("#{requested}");
746 if prompt_presets.contains_key(hashtag_key.as_str()) {
747 return Some(hashtag_key);
748 }
749 }
750 None
751}
752
753impl Frontmatter {
754 pub fn has_agent_doc_marker(&self) -> bool {
763 self.session.is_some()
764 || self.format.is_some()
765 || self.write_mode.is_some()
766 || self.mode.is_some()
767 || self.stream_config.is_some()
768 || self.agent.is_some()
769 || self.resume.is_some()
770 || self.model.is_some()
771 || self.claude_model.is_some()
772 || self.codex_model.is_some()
773 || self.opencode_model.is_some()
774 || self.agent_args.is_some()
775 || self.claude_args.is_some()
776 || self.codex_args.is_some()
777 || self.opencode_args.is_some()
778 || self.branch.is_some()
779 || self.tmux_session.is_some()
780 || self.queue_active.is_some()
781 || self.dispatch.is_some()
782 || self.collaboration.is_some()
783 || !self.prompt_presets.is_empty()
784 || !self.pipeline.is_empty()
785 }
786
787 pub fn resolve_mode(&self) -> ResolvedMode {
794 let mut format = AgentDocFormat::Template;
796 let mut write = AgentDocWrite::Crdt;
797
798 if let Some(ref mode_str) = self.mode {
800 match mode_str.as_str() {
801 "append" => {
802 format = AgentDocFormat::Append;
803 }
805 "template" => {
806 format = AgentDocFormat::Template;
807 }
809 "stream" => {
810 format = AgentDocFormat::Template;
811 write = AgentDocWrite::Crdt;
812 }
813 _ => {} }
815 }
816
817 if let Some(f) = self.format {
819 format = f;
820 }
821 if let Some(w) = self.write_mode {
822 write = w;
823 }
824
825 ResolvedMode { format, write }
826 }
827
828 pub fn resolve_harness_model(&self, harness: &str) -> Option<&str> {
835 let harness_specific = match harness {
836 "claude-code" => self.claude_model.as_deref(),
837 "codex" => self.codex_model.as_deref(),
838 "opencode" => self.opencode_model.as_deref(),
839 _ => None,
840 };
841 harness_specific.or(self.model.as_deref())
842 }
843
844 pub fn collaboration_mode(&self) -> CollaborationMode {
845 self.collaboration.unwrap_or_default()
846 }
847
848 pub fn has_security_review(&self) -> bool {
849 self.security_review
850 .as_deref()
851 .map(str::trim)
852 .is_some_and(|value| !value.is_empty())
853 }
854}
855
856pub fn normalize_queue_control(fm: &mut Frontmatter) {
863 if let Some(control) = fm.queue.as_deref().and_then(QueueControl::parse) {
864 fm.queue_active = Some(control.is_active());
865 }
866}
867
868pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
871 let Some((yaml, body)) = split_frontmatter(content)? else {
872 return Ok((Frontmatter::default(), content));
873 };
874 let mut fm: Frontmatter = serde_yaml::from_str(yaml)?;
875 normalize_queue_control(&mut fm);
876 Ok((fm, body))
877}
878
879pub fn contextualize_parse_error(file_display: &str, err: anyhow::Error) -> anyhow::Error {
882 anyhow::anyhow!(
883 "invalid YAML frontmatter in {}: {}\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
884 file_display,
885 err
886 )
887}
888
889pub struct SshResolverContext<'a> {
895 pub project: &'a crate::project_config::ProjectConfig,
897 pub doc_relative: &'a str,
900 pub file_display: &'a str,
902}
903
904pub fn parse_with_ssh_resolver<'a>(
909 content: &'a str,
910 ctx: &SshResolverContext<'_>,
911) -> Result<(Frontmatter, &'a str)> {
912 let Some((yaml, body)) = split_frontmatter(content)? else {
913 let mut fm = Frontmatter::default();
914 apply_required_ssh_defaults(&mut fm, ctx)?;
915 return Ok((fm, content));
916 };
917 match serde_yaml::from_str(yaml) {
918 Ok(mut fm) => {
919 normalize_queue_control(&mut fm);
920 apply_required_ssh_defaults(&mut fm, ctx)?;
921 Ok((fm, body))
922 }
923 Err(err) => Err(contextualize_yaml_parse_error(ctx.file_display, yaml, err)),
924 }
925}
926
927pub fn ensure_session_with_ssh_resolver(
929 content: &str,
930 ctx: &SshResolverContext<'_>,
931) -> Result<(String, String)> {
932 let (mut fm, body) = parse_with_ssh_resolver(content, ctx)?;
933 if let Some(existing) = fm.session.clone() {
934 return Ok((content.to_string(), existing));
935 }
936 let session_id = Uuid::new_v4().to_string();
937 fm.session = Some(session_id.clone());
938 let updated = write(&fm, body)?;
939 Ok((updated, session_id))
940}
941
942pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
953 let fm = if fm.queue_active.is_some() {
954 let mut canonical = fm.clone();
955 if canonical.queue.is_none() {
956 canonical.queue = Some(
957 if fm.queue_active == Some(true) {
958 "start"
959 } else {
960 "stop"
961 }
962 .to_string(),
963 );
964 }
965 canonical.queue_active = None;
966 std::borrow::Cow::Owned(canonical)
967 } else {
968 std::borrow::Cow::Borrowed(fm)
969 };
970 let yaml = serde_yaml::to_string(fm.as_ref())?;
971 Ok(format!("---\n{}---\n{}", yaml, body))
972}
973
974pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
976 let (mut fm, body) = parse(content)?;
977 fm.session = Some(session_id.to_string());
978 write(&fm, body)
979}
980
981pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
983 let (mut fm, body) = parse(content)?;
984 fm.resume = Some(resume_id.to_string());
985 write(&fm, body)
986}
987
988pub fn set_pipeline_state(
995 content: &str,
996 run_id: Option<&str>,
997 step: Option<&str>,
998 turn_id: Option<&str>,
999 queue_task_id: Option<&str>,
1000) -> Result<String> {
1001 let (mut fm, body) = parse(content)?;
1002 if let Some(v) = run_id {
1003 fm.pipeline.run_id = Some(v.to_string());
1004 }
1005 if let Some(v) = step {
1006 fm.pipeline.step = Some(v.to_string());
1007 }
1008 if let Some(v) = turn_id {
1009 fm.pipeline.turn_id = Some(v.to_string());
1010 }
1011 if let Some(v) = queue_task_id {
1012 fm.pipeline.queue_task_id = Some(v.to_string());
1013 }
1014 write(&fm, body)
1015}
1016
1017pub fn clear_pipeline_state(content: &str) -> Result<String> {
1023 let (mut fm, body) = parse(content)?;
1024 fm.pipeline = AgentDocPipeline::default();
1025 write(&fm, body)
1026}
1027
1028pub fn strip_deprecated_queue_active_line(content: &str) -> String {
1043 let lines: Vec<&str> = content.split('\n').collect();
1044 if lines.first().map(|l| l.trim_end()) != Some("---") {
1045 return content.to_string();
1046 }
1047 let Some(close_idx) = lines
1048 .iter()
1049 .enumerate()
1050 .skip(1)
1051 .find(|(_, l)| l.trim_end() == "---")
1052 .map(|(i, _)| i)
1053 else {
1054 return content.to_string();
1055 };
1056 let fm_lines = &lines[1..close_idx];
1057 let has_canonical_queue = fm_lines
1058 .iter()
1059 .any(|l| l.trim_start().starts_with("queue:"));
1060 let has_deprecated = fm_lines
1061 .iter()
1062 .any(|l| l.trim_start().starts_with("queue_active:"));
1063 if !has_canonical_queue || !has_deprecated {
1064 return content.to_string();
1065 }
1066 let out: Vec<&str> = lines
1067 .iter()
1068 .enumerate()
1069 .filter(|(i, line)| {
1070 !(*i > 0 && *i < close_idx && line.trim_start().starts_with("queue_active:"))
1072 })
1073 .map(|(_, line)| *line)
1074 .collect();
1075 out.join("\n")
1076}
1077
1078pub fn strip_pipeline_block_lines(content: &str) -> String {
1085 let lines: Vec<&str> = content.split('\n').collect();
1086 if lines.first().map(|l| l.trim_end()) != Some("---") {
1087 return content.to_string();
1088 }
1089 let Some(close_idx) = lines
1090 .iter()
1091 .enumerate()
1092 .skip(1)
1093 .find(|(_, l)| l.trim_end() == "---")
1094 .map(|(i, _)| i)
1095 else {
1096 return content.to_string();
1097 };
1098 let mut out: Vec<&str> = Vec::with_capacity(lines.len());
1099 let mut skipping = false;
1100 for (i, line) in lines.iter().enumerate() {
1101 if i == 0 || i >= close_idx {
1102 skipping = false;
1103 out.push(line);
1104 continue;
1105 }
1106 if skipping {
1107 if line.starts_with(' ') || line.starts_with('\t') {
1108 continue;
1109 }
1110 skipping = false;
1111 }
1112 if line.trim_start().starts_with("agent_doc_pipeline:") {
1113 skipping = true;
1114 continue;
1115 }
1116 out.push(line);
1117 }
1118 out.join("\n")
1119}
1120
1121pub fn splice_pipeline_block(content: &str, pipeline: &AgentDocPipeline) -> Result<String> {
1129 let stripped = strip_pipeline_block_lines(content);
1130 if pipeline.is_empty() {
1131 return Ok(stripped);
1132 }
1133 #[derive(serde::Serialize)]
1134 struct Wrap<'a> {
1135 agent_doc_pipeline: &'a AgentDocPipeline,
1136 }
1137 let rendered = serde_yaml::to_string(&Wrap {
1138 agent_doc_pipeline: pipeline,
1139 })
1140 .map_err(|e| anyhow::anyhow!("serialize agent_doc_pipeline block: {e}"))?;
1141 let block_lines: Vec<&str> = rendered.trim_end_matches('\n').split('\n').collect();
1142
1143 let lines: Vec<&str> = stripped.split('\n').collect();
1144 if lines.first().map(|l| l.trim_end()) != Some("---") {
1145 return Ok(stripped);
1146 }
1147 let Some(close_idx) = lines
1148 .iter()
1149 .enumerate()
1150 .skip(1)
1151 .find(|(_, l)| l.trim_end() == "---")
1152 .map(|(i, _)| i)
1153 else {
1154 return Ok(stripped);
1155 };
1156 let mut out: Vec<String> = Vec::with_capacity(lines.len() + block_lines.len());
1157 for (i, line) in lines.iter().enumerate() {
1158 if i == close_idx {
1159 for bl in &block_lines {
1160 out.push((*bl).to_string());
1161 }
1162 }
1163 out.push((*line).to_string());
1164 }
1165 Ok(out.join("\n"))
1166}
1167
1168pub fn set_format_and_write(
1170 content: &str,
1171 format: AgentDocFormat,
1172 write_mode: AgentDocWrite,
1173) -> Result<String> {
1174 let (mut fm, body) = parse(content)?;
1175 fm.format = Some(format);
1176 fm.write_mode = Some(write_mode);
1177 fm.mode = None;
1178 write(&fm, body)
1179}
1180
1181pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
1187 let (mut fm, body) = parse(content)?;
1188 let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
1189 .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
1190 let mapping = patch
1191 .as_mapping()
1192 .unwrap_or(&serde_yaml::Mapping::new())
1193 .clone();
1194
1195 for (key, value) in &mapping {
1196 let key_str = key.as_str().unwrap_or("");
1197 let val_str = || value.as_str().map(|s| s.to_string());
1198 match key_str {
1199 "agent_doc_session" | "session" => fm.session = val_str(),
1200 "resume" => fm.resume = val_str(),
1201 "agent" => fm.agent = val_str(),
1202 "model" => fm.model = val_str(),
1203 "claude_model" => fm.claude_model = val_str(),
1204 "codex_model" => fm.codex_model = val_str(),
1205 "opencode_model" => fm.opencode_model = val_str(),
1206 "branch" => fm.branch = val_str(),
1207 "tmux_session" => fm.tmux_session = val_str(),
1208 "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
1209 "agent_doc_format" => {
1210 if let Some(s) = value.as_str()
1211 && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
1212 {
1213 fm.format = Some(f);
1214 }
1215 }
1216 "agent_doc_write" => {
1217 if let Some(s) = value.as_str()
1218 && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
1219 {
1220 fm.write_mode = Some(w);
1221 }
1222 }
1223 "pending_capture_guard" => {
1224 if let Some(s) = value.as_str()
1225 && let Ok(mode) =
1226 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1227 {
1228 fm.pending_capture_guard = Some(mode);
1229 }
1230 }
1231 "pending_done_guard" => {
1232 if let Some(s) = value.as_str()
1233 && let Ok(mode) =
1234 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1235 {
1236 fm.pending_done_guard = Some(mode);
1237 }
1238 }
1239 "review_done_guard" => {
1240 if let Some(s) = value.as_str()
1241 && let Ok(mode) =
1242 serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1243 {
1244 fm.review_done_guard = Some(mode);
1245 }
1246 }
1247 "auto_done" => {
1248 if let Some(enabled) = value.as_bool() {
1249 fm.auto_done = Some(enabled);
1250 }
1251 }
1252 "prompt_presets" => {
1253 if let Ok(presets) =
1254 serde_yaml::from_value::<indexmap::IndexMap<String, String>>(value.clone())
1255 {
1256 fm.prompt_presets = presets;
1257 }
1258 }
1259 "agent_args" => fm.agent_args = val_str(),
1260 "claude_args" => fm.claude_args = val_str(),
1261 "codex_args" => fm.codex_args = val_str(),
1262 "opencode_args" => fm.opencode_args = val_str(),
1263 "codex_network_access" => {
1264 if let Some(s) = value.as_str()
1265 && let Ok(mode) =
1266 serde_yaml::from_str::<CodexNetworkAccess>(&format!("\"{}\"", s))
1267 {
1268 fm.codex_network_access = Some(mode);
1269 }
1270 }
1271 "required_ssh_targets" => {
1272 if let Ok(targets) = serde_yaml::from_value::<Vec<String>>(value.clone()) {
1273 fm.required_ssh_targets = targets;
1274 }
1275 }
1276 "required_ssh_profile" => fm.required_ssh_profile = val_str(),
1277 "queue" => {
1278 fm.queue = val_str();
1279 normalize_queue_control(&mut fm);
1280 }
1281 "queue_active" => {
1282 fm.queue_active = value.as_bool();
1283 }
1284 "agent_doc_collaboration" => {
1285 if let Some(s) = value.as_str()
1286 && let Ok(mode) =
1287 serde_yaml::from_str::<CollaborationMode>(&format!("\"{}\"", s))
1288 {
1289 fm.collaboration = Some(mode);
1290 }
1291 }
1292 "agent_doc_security_review" => fm.security_review = val_str(),
1293 _ => {
1294 eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
1295 }
1296 }
1297 }
1298
1299 write(&fm, body)
1300}
1301
1302pub fn merge_queue_state(content: &str, active: bool) -> Result<String> {
1308 let (mut fm, body) = parse(content)?;
1309 fm.queue = Some(if active { "start" } else { "stop" }.to_string());
1310 fm.queue_active = None;
1311 write(&fm, body)
1312}
1313
1314pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
1320 let (mut fm, body) = parse(content)?;
1321 fm.tmux_session = Some(session_name.to_string());
1322 write(&fm, body)
1323}
1324
1325pub fn ensure_session(content: &str) -> Result<(String, String)> {
1330 let (fm, _body) = parse(content)?;
1331 if let Some(ref session_id) = fm.session {
1332 return Ok((content.to_string(), session_id.clone()));
1334 }
1335 let session_id = Uuid::new_v4().to_string();
1336 let updated = set_session_id(content, &session_id)?;
1337 Ok((updated, session_id))
1338}
1339
1340fn split_frontmatter(content: &str) -> Result<Option<(&str, &str)>> {
1341 let (opening_end, rest) = if let Some(rest) = content.strip_prefix("---\n") {
1342 (4, rest)
1343 } else {
1344 let without_bom = content.strip_prefix('\u{feff}').unwrap_or(content);
1345 let bom_len = content.len() - without_bom.len();
1346 let horizontal_ws_len = without_bom
1347 .bytes()
1348 .take_while(|byte| matches!(byte, b' ' | b'\t'))
1349 .count();
1350 let marker_start = bom_len + horizontal_ws_len;
1351 if (bom_len == 0 && horizontal_ws_len == 0) || !content[marker_start..].starts_with("---\n")
1352 {
1353 return Ok(None);
1354 }
1355 (marker_start + 4, &content[marker_start + 4..])
1356 };
1357 let (end, closing_len) = rest
1358 .find("\n---\n")
1359 .map(|end| (end, 5))
1360 .or_else(|| rest.find("\n---").map(|end| (end, 4)))
1361 .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
1362 let yaml = &rest[..end];
1363 let body_start = opening_end + end + closing_len; let body = if body_start <= content.len() {
1365 &content[body_start..]
1366 } else {
1367 ""
1368 };
1369 Ok(Some((yaml, body)))
1370}
1371
1372pub fn split_frontmatter_parts(content: &str) -> (Option<&str>, &str) {
1379 match split_frontmatter(content) {
1380 Ok(Some((yaml, body))) => (Some(yaml), body),
1381 _ => (None, content),
1382 }
1383}
1384
1385fn is_agent_managed_frontmatter_key(key: &str) -> bool {
1397 matches!(
1398 key,
1399 "agent_doc_session" | "session" | "resume" | "agent_doc_pipeline"
1400 )
1401}
1402
1403pub fn merge_frontmatter_3way(
1428 base_yaml: Option<&str>,
1429 ours_yaml: Option<&str>,
1430 theirs_yaml: Option<&str>,
1431) -> Option<String> {
1432 if base_yaml.is_none() && ours_yaml.is_none() && theirs_yaml.is_none() {
1433 return None;
1434 }
1435
1436 let wrap = |yaml: &str| {
1437 let trimmed = yaml.trim_end_matches('\n');
1438 if trimmed.is_empty() {
1439 "---\n---\n".to_string()
1440 } else {
1441 format!("---\n{trimmed}\n---\n")
1442 }
1443 };
1444
1445 let parse = |yaml: Option<&str>| -> Option<Option<serde_yaml::Mapping>> {
1448 match yaml {
1449 None => Some(None),
1450 Some(s) if s.trim().is_empty() => Some(Some(serde_yaml::Mapping::new())),
1451 Some(s) => match serde_yaml::from_str::<serde_yaml::Value>(s) {
1452 Ok(serde_yaml::Value::Mapping(m)) => Some(Some(m)),
1453 Ok(serde_yaml::Value::Null) => Some(Some(serde_yaml::Mapping::new())),
1454 _ => None,
1455 },
1456 }
1457 };
1458
1459 let (base_m, ours_m, theirs_m) = match (parse(base_yaml), parse(ours_yaml), parse(theirs_yaml))
1460 {
1461 (Some(b), Some(o), Some(t)) => (b, o, t),
1462 _ => {
1463 let operator_changed = base_yaml != theirs_yaml;
1466 let chosen = if operator_changed {
1467 theirs_yaml
1468 } else {
1469 ours_yaml.or(theirs_yaml)
1470 };
1471 return chosen.or(base_yaml).map(wrap);
1472 }
1473 };
1474
1475 let base_m = base_m.unwrap_or_default();
1476 let ours_m = ours_m.unwrap_or_else(|| base_m.clone());
1479 let theirs_m = theirs_m.unwrap_or_else(|| base_m.clone());
1480
1481 let key_str = |k: &serde_yaml::Value| k.as_str().map(|s| s.to_string());
1482
1483 let mut order: Vec<serde_yaml::Value> = Vec::new();
1485 let push_unique = |order: &mut Vec<serde_yaml::Value>, k: &serde_yaml::Value| {
1486 if !order.iter().any(|o| o == k) {
1487 order.push(k.clone());
1488 }
1489 };
1490 for k in theirs_m.keys() {
1491 push_unique(&mut order, k);
1492 }
1493 for k in ours_m.keys() {
1494 push_unique(&mut order, k);
1495 }
1496 for k in base_m.keys() {
1497 push_unique(&mut order, k);
1498 }
1499
1500 let mut merged = serde_yaml::Mapping::new();
1501 for key in &order {
1502 let b = base_m.get(key);
1503 let o = ours_m.get(key);
1504 let t = theirs_m.get(key);
1505
1506 let agent_changed = o != b;
1507 let operator_changed = t != b;
1508
1509 let chosen: Option<&serde_yaml::Value> = if !agent_changed && !operator_changed {
1510 b.or(o).or(t)
1511 } else if agent_changed && operator_changed {
1512 if o == t {
1513 t
1514 } else if key_str(key)
1515 .as_deref()
1516 .is_some_and(is_agent_managed_frontmatter_key)
1517 {
1518 o
1519 } else {
1520 t
1521 }
1522 } else if operator_changed {
1523 t
1524 } else {
1525 o
1526 };
1527
1528 if let Some(v) = chosen {
1529 merged.insert(key.clone(), v.clone());
1530 }
1531 }
1532
1533 if merged.is_empty() {
1534 return Some("---\n---\n".to_string());
1535 }
1536 let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(merged)).ok()?;
1537 Some(wrap(&yaml))
1538}
1539
1540pub fn apply_required_ssh_defaults(
1546 fm: &mut Frontmatter,
1547 ctx: &SshResolverContext<'_>,
1548) -> Result<()> {
1549 if !fm.required_ssh_targets.is_empty() {
1550 fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
1551 return Ok(());
1552 }
1553
1554 if ctx.doc_relative.is_empty() {
1555 return Ok(());
1556 }
1557
1558 let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
1559 resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
1560 } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
1561 let mut targets = doc_cfg.targets.clone();
1562 if let Some(profile_name) = doc_cfg.profile.as_deref() {
1563 targets.extend(resolve_required_ssh_profile_targets(
1564 &ctx.project.ssh,
1565 profile_name,
1566 ctx.file_display,
1567 )?);
1568 }
1569 if targets.is_empty() {
1570 anyhow::bail!(
1571 "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>].",
1572 ctx.file_display,
1573 ctx.doc_relative,
1574 ctx.doc_relative
1575 );
1576 }
1577 targets
1578 } else {
1579 Vec::new()
1580 };
1581
1582 fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
1583 Ok(())
1584}
1585
1586fn resolve_required_ssh_profile_targets(
1587 ssh: &crate::project_config::SshConfig,
1588 profile_name: &str,
1589 file_display: &str,
1590) -> Result<Vec<String>> {
1591 let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
1592 anyhow::anyhow!(
1593 "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
1594 file_display,
1595 profile_name,
1596 profile_name
1597 )
1598 })?;
1599 if profile.targets.is_empty() {
1600 anyhow::bail!(
1601 "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
1602 file_display,
1603 profile_name,
1604 profile_name
1605 );
1606 }
1607 Ok(profile.targets.clone())
1608}
1609
1610fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
1611 let mut normalized = Vec::new();
1612 for target in targets {
1613 let trimmed = target.trim();
1614 if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
1615 continue;
1616 }
1617 normalized.push(trimmed.to_string());
1618 }
1619 normalized
1620}
1621
1622fn contextualize_yaml_parse_error(
1623 file_display: &str,
1624 yaml: &str,
1625 err: serde_yaml::Error,
1626) -> anyhow::Error {
1627 let excerpt = err
1628 .location()
1629 .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
1630 let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
1631 if let Some(excerpt) = excerpt {
1632 message.push_str("\n\nFrontmatter excerpt:\n");
1633 message.push_str(&excerpt);
1634 }
1635 message.push_str(
1636 "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
1637 );
1638 anyhow::anyhow!(message)
1639}
1640
1641fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
1642 let lines: Vec<&str> = yaml.lines().collect();
1643 if line == 0 || line > lines.len() {
1644 return None;
1645 }
1646
1647 let start = line.saturating_sub(1).max(1);
1648 let end = (line + 1).min(lines.len());
1649 let width = end.to_string().len();
1650 let mut rendered = Vec::new();
1651
1652 for current in start..=end {
1653 let marker = if current == line { '>' } else { ' ' };
1654 rendered.push(format!(
1655 "{} {:>width$} | {}",
1656 marker,
1657 current,
1658 lines[current - 1],
1659 width = width
1660 ));
1661 if current == line {
1662 rendered.push(format!(
1663 " {:>width$} | {}^",
1664 "",
1665 " ".repeat(column.saturating_sub(1)),
1666 width = width
1667 ));
1668 }
1669 }
1670
1671 Some(rendered.join("\n"))
1672}
1673
1674#[cfg(test)]
1675mod tests {
1676 use super::*;
1677
1678 fn yaml_of(merged: &str) -> serde_yaml::Mapping {
1681 let (y, _) = split_frontmatter_parts(merged);
1682 serde_yaml::from_str(y.unwrap()).unwrap()
1683 }
1684
1685 #[test]
1686 fn fm3way_operator_only_edit_wins_not_reverted() {
1687 let base = "agent_doc_format: template\nclaude_model: sonnet";
1688 let ours = base; let theirs = "agent_doc_format: template\nclaude_model: opus"; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1691 let m = yaml_of(&merged);
1692 assert_eq!(m["claude_model"].as_str(), Some("opus"));
1693 }
1694
1695 #[test]
1696 fn fm3way_agent_only_managed_write_applies() {
1697 let base = "agent_doc_session: sid\nclaude_model: sonnet";
1698 let ours = "agent_doc_session: sid\nresume: conv-1\nclaude_model: sonnet";
1699 let theirs = base; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1701 let m = yaml_of(&merged);
1702 assert_eq!(m["resume"].as_str(), Some("conv-1"));
1703 assert_eq!(m["claude_model"].as_str(), Some("sonnet"));
1704 }
1705
1706 #[test]
1707 fn fm3way_concurrent_operator_edit_and_agent_managed_write_field_wise() {
1708 let base = "agent_doc_session: sid\nclaude_model: sonnet";
1709 let ours = "agent_doc_session: sid\nresume: conv-1\nclaude_model: sonnet"; let theirs = "agent_doc_session: sid\nclaude_model: opus"; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1712 let m = yaml_of(&merged);
1713 assert_eq!(m["resume"].as_str(), Some("conv-1"), "agent write kept");
1714 assert_eq!(
1715 m["claude_model"].as_str(),
1716 Some("opus"),
1717 "operator edit kept"
1718 );
1719 }
1720
1721 #[test]
1722 fn fm3way_same_key_conflict_operator_wins_for_operator_key() {
1723 let base = "claude_model: sonnet";
1725 let ours = "claude_model: haiku"; let theirs = "claude_model: opus"; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1728 let m = yaml_of(&merged);
1729 assert_eq!(m["claude_model"].as_str(), Some("opus"));
1730 }
1731
1732 #[test]
1733 fn fm3way_same_key_conflict_agent_wins_for_managed_key() {
1734 let base = "resume: c0";
1736 let ours = "resume: agent-new"; let theirs = "resume: operator-typo"; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1739 let m = yaml_of(&merged);
1740 assert_eq!(m["resume"].as_str(), Some("agent-new"));
1741 }
1742
1743 #[test]
1744 fn fm3way_operator_adds_unknown_key_preserved() {
1745 let base = "agent_doc_format: template";
1746 let ours = base;
1747 let theirs = "agent_doc_format: template\nmy_custom_key: hello";
1748 let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1749 let m = yaml_of(&merged);
1750 assert_eq!(m["my_custom_key"].as_str(), Some("hello"));
1751 }
1752
1753 #[test]
1754 fn fm3way_operator_deletes_key() {
1755 let base = "agent_doc_format: template\nbranch: feature";
1756 let ours = base; let theirs = "agent_doc_format: template"; let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1759 let m = yaml_of(&merged);
1760 assert!(
1761 !m.contains_key("branch"),
1762 "operator deletion honored: {merged}"
1763 );
1764 assert!(m.contains_key("agent_doc_format"));
1765 }
1766
1767 #[test]
1768 fn fm3way_none_when_no_frontmatter() {
1769 assert!(merge_frontmatter_3way(None, None, None).is_none());
1770 }
1771
1772 #[test]
1773 fn parse_no_frontmatter() {
1774 let content = "# Hello\n\nBody text.\n";
1775 let (fm, body) = parse(content).unwrap();
1776 assert!(fm.session.is_none());
1777 assert!(fm.agent.is_none());
1778 assert!(fm.model.is_none());
1779 assert!(fm.branch.is_none());
1780 assert_eq!(body, content);
1781 }
1782
1783 #[test]
1784 fn parse_tolerates_leading_horizontal_space_before_frontmatter() {
1785 let content = " ---\nagent_doc_session: abc-123\n---\nBody\n";
1786 let (fm, body) = parse(content).unwrap();
1787 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1788 assert_eq!(body, "Body\n");
1789 }
1790
1791 #[test]
1792 fn parse_tolerates_bom_before_frontmatter() {
1793 let content = "\u{feff}---\nagent_doc_session: abc-123\n---\nBody\n";
1794 let (fm, body) = parse(content).unwrap();
1795 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1796 assert_eq!(body, "Body\n");
1797 }
1798
1799 #[test]
1800 fn parse_keeps_leading_blank_line_as_body_not_frontmatter() {
1801 let content = "\n---\nagent_doc_session: abc-123\n---\nBody\n";
1802 let (fm, body) = parse(content).unwrap();
1803 assert!(fm.session.is_none());
1804 assert_eq!(body, content);
1805 }
1806
1807 #[test]
1808 fn parse_all_fields() {
1809 let content =
1810 "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1811 let (fm, body) = parse(content).unwrap();
1812 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1813 assert_eq!(fm.agent.as_deref(), Some("claude"));
1814 assert_eq!(fm.model.as_deref(), Some("opus"));
1815 assert_eq!(fm.branch.as_deref(), Some("main"));
1816 assert!(body.contains("Body"));
1817 }
1818
1819 #[test]
1820 fn queue_control_parse_aliases() {
1821 assert_eq!(QueueControl::parse("start"), Some(QueueControl::Start));
1822 assert_eq!(QueueControl::parse("go"), Some(QueueControl::Start));
1823 assert_eq!(QueueControl::parse("STOP"), Some(QueueControl::Stop));
1824 assert_eq!(QueueControl::parse(" Start "), Some(QueueControl::Start));
1825 assert_eq!(QueueControl::parse("auto"), None);
1826 assert_eq!(QueueControl::parse(""), None);
1827 assert!(QueueControl::Start.is_active());
1828 assert!(!QueueControl::Stop.is_active());
1829 }
1830
1831 #[test]
1832 fn parse_queue_start_activates() {
1833 let (fm, _) = parse("---\nagent_doc_format: template\nqueue: start\n---\n\n").unwrap();
1836 assert_eq!(fm.queue.as_deref(), Some("start"));
1837 assert_eq!(fm.queue_active, Some(true));
1838 }
1839
1840 #[test]
1841 fn parse_queue_go_alias_activates() {
1842 let (fm, _) = parse("---\nqueue: go\n---\n\n").unwrap();
1843 assert_eq!(fm.queue_active, Some(true));
1844 }
1845
1846 #[test]
1847 fn parse_queue_stop_deactivates() {
1848 let (fm, _) = parse("---\nqueue: stop\n---\n\n").unwrap();
1849 assert_eq!(fm.queue_active, Some(false));
1850 }
1851
1852 #[test]
1853 fn parse_queue_canonical_wins_over_stale_queue_active() {
1854 let (fm, _) = parse("---\nqueue: stop\nqueue_active: true\n---\n\n").unwrap();
1856 assert_eq!(fm.queue_active, Some(false));
1857
1858 let (fm, _) = parse("---\nqueue: start\nqueue_active: false\n---\n\n").unwrap();
1859 assert_eq!(fm.queue_active, Some(true));
1860 }
1861
1862 #[test]
1863 fn pipeline_is_empty_default() {
1864 let p = AgentDocPipeline::default();
1865 assert!(p.is_empty());
1866 }
1867
1868 #[test]
1869 fn parse_pipeline_all_fields() {
1870 let content = "---\nagent_doc_pipeline:\n turn_id: \"#fm-run-id-step\"\n run_id: cycle-1780600334196\n step: response_captured\n queue_task_id: \"#fm-run-id-step\"\n---\nBody\n";
1871 let (fm, _) = parse(content).unwrap();
1872 assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1873 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-1780600334196"));
1874 assert_eq!(fm.pipeline.step.as_deref(), Some("response_captured"));
1875 assert_eq!(
1876 fm.pipeline.queue_task_id.as_deref(),
1877 Some("#fm-run-id-step")
1878 );
1879 assert!(!fm.pipeline.is_empty());
1880 }
1881
1882 #[test]
1883 fn empty_pipeline_is_not_serialized() {
1884 let fm = Frontmatter {
1886 session: Some("abc".to_string()),
1887 ..Default::default()
1888 };
1889 let out = write(&fm, "Body\n").unwrap();
1890 assert!(!out.contains("agent_doc_pipeline"));
1891 }
1892
1893 #[test]
1894 fn set_pipeline_state_merges_without_disturbing_other_fields() {
1895 let content =
1896 "---\nagent_doc_session: abc-123\nagent: claude\nqueue: start\n---\n\n## Body\n";
1897 let updated =
1899 set_pipeline_state(content, Some("cycle-99"), Some("write_applied"), None, None)
1900 .unwrap();
1901 let (fm, body) = parse(&updated).unwrap();
1902 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1903 assert_eq!(fm.pipeline.step.as_deref(), Some("write_applied"));
1904 assert!(fm.pipeline.turn_id.is_none());
1905 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1907 assert_eq!(fm.agent.as_deref(), Some("claude"));
1908 assert_eq!(fm.queue_active, Some(true));
1909 assert!(body.contains("## Body"));
1910
1911 let updated2 = set_pipeline_state(
1913 &updated,
1914 None,
1915 Some("committed"),
1916 Some("#fm-run-id-step"),
1917 None,
1918 )
1919 .unwrap();
1920 let (fm2, _) = parse(&updated2).unwrap();
1921 assert_eq!(fm2.pipeline.run_id.as_deref(), Some("cycle-99"));
1922 assert_eq!(fm2.pipeline.step.as_deref(), Some("committed"));
1923 assert_eq!(fm2.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1924 }
1925
1926 #[test]
1927 fn splice_pipeline_block_is_byte_precise_and_strip_cancels() {
1928 let base = "---\nagent_doc_session: abc-123\nagent: claude\nprompt_presets:\n '#x': do the thing\nqueue: start\n---\n\n## Body\n- item\n";
1931 let pipeline = AgentDocPipeline {
1932 run_id: Some("cycle-99".into()),
1933 step: Some("response_captured".into()),
1934 turn_id: Some("#22a8".into()),
1935 queue_task_id: None,
1936 };
1937 let spliced = splice_pipeline_block(base, &pipeline).unwrap();
1938 assert!(spliced.contains("agent_doc_pipeline:"));
1940 let (fm, _) = parse(&spliced).unwrap();
1941 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1942 assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#22a8"));
1943 assert_eq!(fm.agent.as_deref(), Some("claude"));
1945 assert_eq!(strip_pipeline_block_lines(&spliced), base);
1947 assert_eq!(
1949 splice_pipeline_block(&spliced, &AgentDocPipeline::default()).unwrap(),
1950 base
1951 );
1952 let twice = splice_pipeline_block(&spliced, &pipeline).unwrap();
1954 assert_eq!(twice.matches("agent_doc_pipeline:").count(), 1);
1955 }
1956
1957 #[test]
1958 fn clear_pipeline_state_drops_block_only() {
1959 let content = "---\nagent_doc_session: abc-123\nagent_doc_pipeline:\n run_id: cycle-1\n step: response_captured\n---\n\nBody\n";
1960 let cleared = clear_pipeline_state(content).unwrap();
1961 assert!(!cleared.contains("agent_doc_pipeline"));
1962 let (fm, body) = parse(&cleared).unwrap();
1963 assert!(fm.pipeline.is_empty());
1964 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1965 assert!(body.contains("Body"));
1966 }
1967
1968 #[test]
1969 fn parse_queue_unknown_value_leaves_queue_active_untouched() {
1970 let (fm, _) = parse("---\nqueue: bogus\nqueue_active: true\n---\n\n").unwrap();
1972 assert_eq!(fm.queue_active, Some(true));
1973 let (fm, _) = parse("---\nqueue: bogus\n---\n\n").unwrap();
1974 assert_eq!(fm.queue_active, None);
1975 }
1976
1977 #[test]
1978 fn write_emits_canonical_queue_for_direct_set_queue_active() {
1979 let mut fm = Frontmatter {
1984 queue_active: Some(true),
1985 ..Default::default()
1986 };
1987 let out = write(&fm, "body\n").unwrap();
1988 assert!(out.contains("queue: start"), "{out}");
1989 assert!(!out.contains("queue_active:"), "{out}");
1990
1991 fm.queue_active = Some(false);
1992 let out = write(&fm, "body\n").unwrap();
1993 assert!(out.contains("queue: stop"), "{out}");
1994 assert!(!out.contains("queue_active:"), "{out}");
1995
1996 let (fm, body) =
1999 parse("---\nagent_doc_format: template\nqueue: start\n---\n\nx\n").unwrap();
2000 let out = write(&fm, &body).unwrap();
2001 assert!(out.contains("queue: start"), "{out}");
2002 assert!(!out.contains("queue_active:"), "{out}");
2003
2004 let fm = Frontmatter::default();
2006 let out = write(&fm, "body\n").unwrap();
2007 assert!(
2008 !out.contains("queue_active:") && !out.contains("queue:"),
2009 "{out}"
2010 );
2011 }
2012
2013 #[test]
2014 fn merge_queue_state_writes_canonical_and_drops_legacy() {
2015 let legacy = "---\nagent_doc_format: template\nqueue_active: true\n---\n\nbody\n";
2018 let active = merge_queue_state(legacy, true).unwrap();
2019 assert!(active.contains("queue: start"), "{active}");
2020 assert!(!active.contains("queue_active:"), "{active}");
2021
2022 let stopped = merge_queue_state(legacy, false).unwrap();
2023 assert!(stopped.contains("queue: stop"), "{stopped}");
2024 assert!(!stopped.contains("queue_active:"), "{stopped}");
2025
2026 let (fm, _) = parse(&active).unwrap();
2028 assert_eq!(fm.queue_active, Some(true));
2029 let (fm, _) = parse(&stopped).unwrap();
2030 assert_eq!(fm.queue_active, Some(false));
2031 }
2032
2033 #[test]
2034 fn strip_deprecated_queue_active_line_drops_legacy_when_canonical_present() {
2035 let stuck = "---\nagent: claude\nqueue: start\nqueue_active: true\n---\n\nbody\n";
2040 let out = strip_deprecated_queue_active_line(stuck);
2041 assert_eq!(
2042 out, "---\nagent: claude\nqueue: start\n---\n\nbody\n",
2043 "legacy line dropped, canonical queue + other fields preserved"
2044 );
2045 assert_eq!(strip_deprecated_queue_active_line(&out), out);
2047 }
2048
2049 #[test]
2050 fn strip_deprecated_queue_active_line_keeps_legacy_without_canonical() {
2051 let legacy_only = "---\nqueue_active: true\n---\n\nbody\n";
2054 assert_eq!(strip_deprecated_queue_active_line(legacy_only), legacy_only);
2055 assert_eq!(
2057 strip_deprecated_queue_active_line("no frontmatter\nqueue_active: true\n"),
2058 "no frontmatter\nqueue_active: true\n"
2059 );
2060 }
2061
2062 #[test]
2063 fn parse_legacy_queue_active_still_honored() {
2064 let (fm, _) = parse("---\nqueue_active: true\n---\n\n").unwrap();
2066 assert_eq!(fm.queue.as_deref(), None);
2067 assert_eq!(fm.queue_active, Some(true));
2068 }
2069
2070 #[test]
2071 fn parse_partial_fields() {
2072 let content = "---\nsession: xyz\n---\n# Doc\n";
2073 let (fm, body) = parse(content).unwrap();
2074 assert_eq!(fm.session.as_deref(), Some("xyz"));
2075 assert!(fm.agent.is_none());
2076 assert!(body.contains("# Doc"));
2077 }
2078
2079 #[test]
2080 fn parse_model_tier_high() {
2081 let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
2082 let (fm, _) = parse(content).unwrap();
2083 assert_eq!(fm.model_tier, Some(Tier::High));
2084 }
2085
2086 #[test]
2087 fn parse_collaboration_mode_shared() {
2088 let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
2089 let (fm, _) = parse(content).unwrap();
2090 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
2091 }
2092
2093 #[test]
2094 fn parse_security_review_roundtrip() {
2095 let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
2096 let (fm, _) = parse(content).unwrap();
2097 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
2098 assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
2099 assert!(fm.has_security_review());
2100 let written = write(&fm, "Body\n").unwrap();
2101 assert!(written.contains("agent_doc_collaboration: shared"));
2102 assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
2103 }
2104
2105 #[test]
2106 fn parse_model_tier_low() {
2107 let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
2108 let (fm, _) = parse(content).unwrap();
2109 assert_eq!(fm.model_tier, Some(Tier::Low));
2110 }
2111
2112 #[test]
2113 fn parse_model_tier_med() {
2114 let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
2115 let (fm, _) = parse(content).unwrap();
2116 assert_eq!(fm.model_tier, Some(Tier::Med));
2117 }
2118
2119 #[test]
2120 fn parse_model_tier_auto() {
2121 let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
2122 let (fm, _) = parse(content).unwrap();
2123 assert_eq!(fm.model_tier, Some(Tier::Auto));
2124 }
2125
2126 #[test]
2127 fn parse_model_tier_absent() {
2128 let content = "---\nagent: claude\n---\nBody\n";
2129 let (fm, _) = parse(content).unwrap();
2130 assert_eq!(fm.model_tier, None);
2131 }
2132
2133 #[test]
2134 fn parse_pending_capture_guard_strict() {
2135 let content = "---\npending_capture_guard: strict\n---\nBody\n";
2136 let (fm, _) = parse(content).unwrap();
2137 assert_eq!(
2138 fm.pending_capture_guard,
2139 Some(PendingCaptureGuardMode::Strict)
2140 );
2141 }
2142
2143 #[test]
2144 fn parse_pending_capture_guard_invalid_rejected() {
2145 let content = "---\npending_capture_guard: loudly\n---\nBody\n";
2146 assert!(parse(content).is_err());
2147 }
2148
2149 #[test]
2150 fn parse_pending_done_guard_strict() {
2151 let content = "---\npending_done_guard: strict\n---\nBody\n";
2152 let (fm, _) = parse(content).unwrap();
2153 assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
2154 }
2155
2156 #[test]
2157 fn parse_pending_done_guard_invalid_rejected() {
2158 let content = "---\npending_done_guard: loudly\n---\nBody\n";
2159 assert!(parse(content).is_err());
2160 }
2161
2162 #[test]
2163 fn parse_model_tier_invalid_rejected() {
2164 let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
2165 let result = parse(content);
2166 assert!(result.is_err(), "invalid tier value should fail to parse");
2167 }
2168
2169 #[test]
2170 fn write_model_tier_roundtrip() {
2171 let fm = Frontmatter {
2172 model_tier: Some(Tier::High),
2173 ..Default::default()
2174 };
2175 let doc = write(&fm, "Body\n").unwrap();
2176 let (parsed, _) = parse(&doc).unwrap();
2177 assert_eq!(parsed.model_tier, Some(Tier::High));
2178 assert!(doc.contains("agent_doc_model_tier: high"));
2179 }
2180
2181 #[test]
2182 fn parse_null_fields() {
2183 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
2184 let (fm, body) = parse(content).unwrap();
2185 assert!(fm.session.is_none());
2186 assert!(fm.agent.is_none());
2187 assert!(fm.model.is_none());
2188 assert!(fm.branch.is_none());
2189 assert!(body.contains("Body"));
2190 }
2191
2192 #[test]
2193 fn parse_unterminated_frontmatter() {
2194 let content = "---\nsession: abc\nno closing block";
2195 let err = parse(content).unwrap_err();
2196 assert!(err.to_string().contains("Unterminated frontmatter"));
2197 }
2198
2199 #[test]
2200 fn parse_closing_at_eof() {
2201 let content = "---\nsession: abc\n---";
2202 let (fm, body) = parse(content).unwrap();
2203 assert_eq!(fm.session.as_deref(), Some("abc"));
2204 assert_eq!(body, "");
2205 }
2206
2207 #[test]
2208 fn parse_empty_body() {
2209 let content = "---\nsession: abc\n---\n";
2210 let (fm, _body) = parse(content).unwrap();
2211 assert_eq!(fm.session.as_deref(), Some("abc"));
2212 }
2213
2214 #[test]
2215 fn write_roundtrip() {
2216 let fm = Frontmatter {
2218 session: Some("test-id".to_string()),
2219 resume: Some("resume-id".to_string()),
2220 agent: Some("claude".to_string()),
2221 model: Some("opus".to_string()),
2222 claude_model: None,
2223 codex_model: None,
2224 opencode_model: None,
2225 branch: Some("dev".to_string()),
2226 tmux_session: None,
2227 mode: None,
2228 format: None,
2229 write_mode: None,
2230 stream_config: None,
2231 agent_args: None,
2232 claude_args: None,
2233 codex_args: None,
2234 opencode_args: None,
2235 codex_network_access: None,
2236 required_ssh_targets: Vec::new(),
2237 required_ssh_profile: None,
2238 managed_proof_max_attempts: None,
2239 managed_proof_retry_backoff_secs: None,
2240 managed_proof_probe_timeout_secs: None,
2241 no_mcp: None,
2242 enable_tool_search: None,
2243 debounce_ms: None,
2244 links: vec![],
2245 auto_compact: None,
2246 queue_context_reset: None,
2247 gate_autoverify: None,
2248 clear_threshold: None,
2249 supervisor_auto_recycle: None,
2250 agent_change_restart: None,
2251 supervisor_auto_install: None,
2252 model_tier: None,
2253 pending_capture_guard: None,
2254 pending_done_guard: None,
2255 review_done_guard: None,
2256 auto_done: None,
2257 agent_doc_lint_dialect: None,
2258 hooks: std::collections::HashMap::new(),
2259 env: indexmap::IndexMap::new(),
2260 prompt_presets: indexmap::IndexMap::new(),
2261 dispatch: None,
2262 agent_doc_env_inherit: None,
2263 cwd: None,
2264 queue: None,
2265 queue_active: None,
2266 queue_prompt_echo_max_chars: None,
2267 collaboration: None,
2268 security_review: None,
2269 pipeline: AgentDocPipeline::default(),
2270 };
2271 let body = "# Hello\n\nBody text.\n";
2272 let written = write(&fm, body).unwrap();
2273 let (fm2, body2) = parse(&written).unwrap();
2274 assert_eq!(fm2.session, fm.session);
2275 assert_eq!(fm2.agent, fm.agent);
2276 assert_eq!(fm2.model, fm.model);
2277 assert_eq!(fm2.claude_model, fm.claude_model);
2278 assert_eq!(fm2.codex_model, fm.codex_model);
2279 assert_eq!(fm2.opencode_model, fm.opencode_model);
2280 assert_eq!(fm2.branch, fm.branch);
2281 assert_eq!(fm2.collaboration, fm.collaboration);
2282 assert_eq!(fm2.security_review, fm.security_review);
2283 assert_eq!(body2, body);
2284 }
2285
2286 #[test]
2287 fn repeated_parse_write_roundtrip_preserves_body_prefix() {
2288 let fm = Frontmatter {
2289 session: Some("abc".to_string()),
2290 agent: Some("codex".to_string()),
2291 ..Default::default()
2292 };
2293 let original = write(&fm, "## Status\n\nBody\n").unwrap();
2294 let mut current = original.clone();
2295
2296 for _ in 0..3 {
2297 let (fm, body) = parse(¤t).unwrap();
2298 assert_eq!(body, "## Status\n\nBody\n");
2299 current = write(&fm, body).unwrap();
2300 }
2301
2302 let (fm, body) = parse(¤t).unwrap();
2303 assert_eq!(fm.session.as_deref(), Some("abc"));
2304 assert_eq!(fm.agent.as_deref(), Some("codex"));
2305 assert_eq!(body, "## Status\n\nBody\n");
2306 assert_eq!(current, original);
2307 }
2308
2309 #[test]
2310 fn write_default_frontmatter() {
2311 let fm = Frontmatter::default();
2312 let result = write(&fm, "body\n").unwrap();
2313 assert!(result.starts_with("---\n"));
2314 assert!(result.ends_with("---\nbody\n"));
2315 }
2316
2317 #[test]
2318 fn write_preserves_body_content() {
2319 let fm = Frontmatter::default();
2320 let body = "# Title\n\nSome **markdown** with `code`.\n";
2321 let result = write(&fm, body).unwrap();
2322 assert!(result.contains("# Title"));
2323 assert!(result.contains("Some **markdown** with `code`."));
2324 }
2325
2326 #[test]
2327 fn set_session_id_creates_frontmatter() {
2328 let content = "# No frontmatter\n\nJust body.\n";
2329 let result = set_session_id(content, "new-session").unwrap();
2330 let (fm, body) = parse(&result).unwrap();
2331 assert_eq!(fm.session.as_deref(), Some("new-session"));
2332 assert!(body.contains("# No frontmatter"));
2333 }
2334
2335 #[test]
2336 fn set_session_id_updates_existing() {
2337 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
2338 let result = set_session_id(content, "new-id").unwrap();
2339 let (fm, body) = parse(&result).unwrap();
2340 assert_eq!(fm.session.as_deref(), Some("new-id"));
2341 assert_eq!(fm.agent.as_deref(), Some("claude"));
2342 assert!(body.contains("Body"));
2343 }
2344
2345 #[test]
2346 fn set_session_id_preserves_other_fields() {
2347 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
2348 let result = set_session_id(content, "new").unwrap();
2349 let (fm, _) = parse(&result).unwrap();
2350 assert_eq!(fm.session.as_deref(), Some("new"));
2351 assert_eq!(fm.agent.as_deref(), Some("claude"));
2352 assert_eq!(fm.model.as_deref(), Some("opus"));
2353 assert_eq!(fm.branch.as_deref(), Some("dev"));
2354 }
2355
2356 #[test]
2357 fn ensure_session_no_frontmatter() {
2358 let content = "# Hello\n\nBody.\n";
2359 let (updated, sid) = ensure_session(content).unwrap();
2360 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
2363 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2364 assert!(body.contains("# Hello"));
2365 }
2366
2367 #[test]
2368 fn ensure_session_null_session() {
2369 let content = "---\nsession:\nagent: claude\n---\nBody\n";
2370 let (updated, sid) = ensure_session(content).unwrap();
2371 assert_eq!(sid.len(), 36);
2372 let (fm, body) = parse(&updated).unwrap();
2373 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2374 assert_eq!(fm.agent.as_deref(), Some("claude"));
2375 assert!(body.contains("Body"));
2376 }
2377
2378 #[test]
2379 fn ensure_session_existing_session() {
2380 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
2381 let (updated, sid) = ensure_session(content).unwrap();
2382 assert_eq!(sid, "existing-id");
2383 assert_eq!(updated, content);
2385 }
2386
2387 #[test]
2388 fn parse_legacy_session_field() {
2389 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
2391 let (fm, body) = parse(content).unwrap();
2392 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
2393 assert_eq!(fm.agent.as_deref(), Some("claude"));
2394 assert!(body.contains("Body"));
2395 }
2396
2397 #[test]
2398 fn parse_agent_doc_mode_canonical() {
2399 let content = "---\nagent_doc_mode: template\n---\nBody\n";
2400 let (fm, _) = parse(content).unwrap();
2401 assert_eq!(fm.mode.as_deref(), Some("template"));
2402 }
2403
2404 #[test]
2405 fn parse_mode_shorthand_alias() {
2406 let content = "---\nmode: template\n---\nBody\n";
2407 let (fm, _) = parse(content).unwrap();
2408 assert_eq!(fm.mode.as_deref(), Some("template"));
2409 }
2410
2411 #[test]
2412 fn parse_response_mode_legacy_alias() {
2413 let content = "---\nresponse_mode: template\n---\nBody\n";
2414 let (fm, _) = parse(content).unwrap();
2415 assert_eq!(fm.mode.as_deref(), Some("template"));
2416 }
2417
2418 #[test]
2419 fn write_uses_agent_doc_mode_field() {
2420 #[allow(deprecated)]
2421 let fm = Frontmatter {
2422 mode: Some("template".to_string()),
2423 ..Default::default()
2424 };
2425 let result = write(&fm, "body\n").unwrap();
2426 assert!(result.contains("agent_doc_mode:"));
2427 assert!(!result.contains("response_mode:"));
2428 assert!(!result.contains("\nmode:"));
2429 }
2430
2431 #[test]
2432 fn write_uses_new_field_name() {
2433 let fm = Frontmatter {
2434 session: Some("test-id".to_string()),
2435 ..Default::default()
2436 };
2437 let result = write(&fm, "body\n").unwrap();
2438 assert!(result.contains("agent_doc_session:"));
2439 assert!(!result.contains("\nsession:"));
2440 }
2441
2442 #[test]
2443 fn write_pending_capture_guard_roundtrip() {
2444 let fm = Frontmatter {
2445 pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
2446 ..Default::default()
2447 };
2448 let written = write(&fm, "body\n").unwrap();
2449 assert!(written.contains("pending_capture_guard: strict"));
2450 let (parsed, _) = parse(&written).unwrap();
2451 assert_eq!(
2452 parsed.pending_capture_guard,
2453 Some(PendingCaptureGuardMode::Strict)
2454 );
2455 }
2456
2457 #[test]
2458 fn write_pending_done_guard_roundtrip() {
2459 let fm = Frontmatter {
2460 pending_done_guard: Some(PendingCaptureGuardMode::Strict),
2461 ..Default::default()
2462 };
2463 let written = write(&fm, "body\n").unwrap();
2464 assert!(written.contains("pending_done_guard: strict"));
2465 let (parsed, _) = parse(&written).unwrap();
2466 assert_eq!(
2467 parsed.pending_done_guard,
2468 Some(PendingCaptureGuardMode::Strict)
2469 );
2470 }
2471
2472 #[test]
2473 fn parse_review_done_guard_error_alias() {
2474 let content = "---\nreview_done_guard: error\n---\nBody\n";
2475 let (fm, _) = parse(content).unwrap();
2476 assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
2477 }
2478
2479 #[test]
2480 fn write_review_done_guard_roundtrip() {
2481 let fm = Frontmatter {
2482 review_done_guard: Some(PendingCaptureGuardMode::Strict),
2483 ..Default::default()
2484 };
2485 let written = write(&fm, "body\n").unwrap();
2486 assert!(written.contains("review_done_guard: strict"));
2487 let (parsed, _) = parse(&written).unwrap();
2488 assert_eq!(
2489 parsed.review_done_guard,
2490 Some(PendingCaptureGuardMode::Strict)
2491 );
2492 }
2493
2494 #[test]
2495 fn parse_auto_done_bool() {
2496 let content = "---\nauto_done: true\n---\nBody\n";
2497 let (fm, _) = parse(content).unwrap();
2498 assert_eq!(fm.auto_done, Some(true));
2499 }
2500
2501 #[test]
2502 fn write_auto_done_roundtrip() {
2503 let fm = Frontmatter {
2504 auto_done: Some(true),
2505 ..Default::default()
2506 };
2507 let written = write(&fm, "Body\n").unwrap();
2508 assert!(written.contains("auto_done: true"));
2509 let (parsed, _) = parse(&written).unwrap();
2510 assert_eq!(parsed.auto_done, Some(true));
2511 }
2512
2513 #[test]
2514 fn parse_prompt_presets_roundtrip() {
2515 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";
2516 let (fm, body) = parse(content).unwrap();
2517 assert_eq!(body, "Body\n");
2518 assert_eq!(
2519 fm.prompt_presets.get("#1").map(String::as_str),
2520 Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
2521 );
2522 assert_eq!(
2523 fm.prompt_presets.get("release-check").map(String::as_str),
2524 Some("Run cargo test.")
2525 );
2526
2527 let written = write(&fm, body).unwrap();
2528 let (parsed, body2) = parse(&written).unwrap();
2529 assert_eq!(body2, "Body\n");
2530 assert_eq!(parsed.prompt_presets, fm.prompt_presets);
2531 }
2532
2533 #[test]
2534 fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
2535 let mut prompt_presets = indexmap::IndexMap::new();
2536 prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
2537 prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
2538
2539 assert_eq!(
2540 resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
2541 Some("#spec-test")
2542 );
2543 assert_eq!(
2544 resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
2545 Some("#spec-test")
2546 );
2547 assert_eq!(
2548 resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
2549 Some("release-check")
2550 );
2551 assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
2552 }
2553
2554 #[test]
2557 fn resolve_mode_defaults() {
2558 let fm = Frontmatter::default();
2559 let resolved = fm.resolve_mode();
2560 assert_eq!(resolved.format, AgentDocFormat::Template);
2561 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2562 }
2563
2564 #[test]
2565 fn resolve_mode_from_deprecated_append() {
2566 let content = "---\nagent_doc_mode: append\n---\nBody\n";
2567 let (fm, _) = parse(content).unwrap();
2568 let resolved = fm.resolve_mode();
2569 assert_eq!(resolved.format, AgentDocFormat::Append);
2570 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2571 }
2572
2573 #[test]
2574 fn resolve_mode_from_deprecated_template() {
2575 let content = "---\nagent_doc_mode: template\n---\nBody\n";
2576 let (fm, _) = parse(content).unwrap();
2577 let resolved = fm.resolve_mode();
2578 assert_eq!(resolved.format, AgentDocFormat::Template);
2579 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2580 }
2581
2582 #[test]
2583 fn resolve_mode_from_deprecated_stream() {
2584 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2585 let (fm, _) = parse(content).unwrap();
2586 let resolved = fm.resolve_mode();
2587 assert_eq!(resolved.format, AgentDocFormat::Template);
2588 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2589 }
2590
2591 #[test]
2592 fn resolve_mode_new_fields_override_deprecated() {
2593 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
2594 let (fm, _) = parse(content).unwrap();
2595 let resolved = fm.resolve_mode();
2596 assert_eq!(resolved.format, AgentDocFormat::Template);
2597 assert_eq!(resolved.write, AgentDocWrite::Merge);
2598 }
2599
2600 #[test]
2601 fn resolve_mode_explicit_new_fields_only() {
2602 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
2603 let (fm, _) = parse(content).unwrap();
2604 let resolved = fm.resolve_mode();
2605 assert_eq!(resolved.format, AgentDocFormat::Append);
2606 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2607 }
2608
2609 #[test]
2610 fn resolve_mode_partial_new_field_format_only() {
2611 let content = "---\nagent_doc_format: append\n---\nBody\n";
2612 let (fm, _) = parse(content).unwrap();
2613 let resolved = fm.resolve_mode();
2614 assert_eq!(resolved.format, AgentDocFormat::Append);
2615 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
2617
2618 #[test]
2619 fn resolve_mode_partial_new_field_write_only() {
2620 let content = "---\nagent_doc_write: merge\n---\nBody\n";
2621 let (fm, _) = parse(content).unwrap();
2622 let resolved = fm.resolve_mode();
2623 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
2625 }
2626
2627 #[test]
2628 fn resolve_mode_helper_methods() {
2629 let fm = Frontmatter::default();
2630 let resolved = fm.resolve_mode();
2631 assert!(resolved.is_template());
2632 assert!(!resolved.is_append());
2633 assert!(resolved.is_crdt());
2634 }
2635
2636 #[test]
2637 fn parse_new_format_field() {
2638 let content = "---\nagent_doc_format: template\n---\nBody\n";
2639 let (fm, _) = parse(content).unwrap();
2640 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2641 }
2642
2643 #[test]
2644 fn parse_new_write_field() {
2645 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
2646 let (fm, _) = parse(content).unwrap();
2647 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2648 }
2649
2650 #[test]
2651 fn write_uses_new_format_write_fields() {
2652 let fm = Frontmatter {
2653 format: Some(AgentDocFormat::Template),
2654 write_mode: Some(AgentDocWrite::Crdt),
2655 ..Default::default()
2656 };
2657 let result = write(&fm, "body\n").unwrap();
2658 assert!(result.contains("agent_doc_format:"));
2659 assert!(result.contains("agent_doc_write:"));
2660 assert!(!result.contains("agent_doc_mode:"));
2661 }
2662
2663 #[test]
2664 fn set_format_and_write_clears_deprecated_mode() {
2665 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2666 let result =
2667 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2668 let (fm, _) = parse(&result).unwrap();
2669 assert!(fm.mode.is_none());
2670 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2671 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2672 }
2673
2674 #[test]
2677 fn merge_fields_adds_new_field() {
2678 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2679 let result = merge_fields(content, "model: opus").unwrap();
2680 let (fm, body) = parse(&result).unwrap();
2681 assert_eq!(fm.session.as_deref(), Some("abc"));
2682 assert_eq!(fm.model.as_deref(), Some("opus"));
2683 assert!(body.contains("Body"));
2684 }
2685
2686 #[test]
2687 fn merge_fields_updates_existing_field() {
2688 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
2689 let result = merge_fields(content, "model: opus").unwrap();
2690 let (fm, _) = parse(&result).unwrap();
2691 assert_eq!(fm.model.as_deref(), Some("opus"));
2692 assert_eq!(fm.session.as_deref(), Some("abc"));
2693 }
2694
2695 #[test]
2696 fn merge_fields_multiple_fields() {
2697 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2698 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
2699 let (fm, _) = parse(&result).unwrap();
2700 assert_eq!(fm.model.as_deref(), Some("opus"));
2701 assert_eq!(fm.agent.as_deref(), Some("claude"));
2702 assert_eq!(fm.branch.as_deref(), Some("main"));
2703 }
2704
2705 #[test]
2706 fn merge_fields_format_enum() {
2707 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2708 let result = merge_fields(content, "agent_doc_format: append").unwrap();
2709 let (fm, _) = parse(&result).unwrap();
2710 assert_eq!(fm.format, Some(AgentDocFormat::Append));
2711 }
2712
2713 #[test]
2714 fn merge_fields_write_enum() {
2715 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2716 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
2717 let (fm, _) = parse(&result).unwrap();
2718 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
2719 }
2720
2721 #[test]
2722 fn merge_fields_ignores_unknown() {
2723 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2724 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
2725 let (fm, _) = parse(&result).unwrap();
2726 assert_eq!(fm.model.as_deref(), Some("opus"));
2727 }
2728
2729 #[test]
2730 fn merge_fields_preserves_body() {
2731 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
2732 let result = merge_fields(content, "model: opus").unwrap();
2733 assert!(result.contains("# Title"));
2734 assert!(result.contains("Some **markdown** content."));
2735 }
2736
2737 #[test]
2738 fn set_format_and_write_clears_deprecated() {
2739 let content = "---\nagent_doc_mode: append\n---\nBody\n";
2740 let result =
2741 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2742 let (fm, _) = parse(&result).unwrap();
2743 assert!(fm.mode.is_none());
2744 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2745 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2746 }
2747
2748 #[test]
2749 fn hooks_roundtrip() {
2750 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
2751 let (fm, _) = parse(content).unwrap();
2752 assert_eq!(
2753 fm.hooks.get("session_start"),
2754 Some(&vec!["echo start {{session_id}}".to_string()])
2755 );
2756 assert_eq!(
2757 fm.hooks.get("post_write"),
2758 Some(&vec!["notify {{file}}".to_string()])
2759 );
2760 }
2761
2762 #[test]
2763 fn hooks_omitted_when_empty() {
2764 let fm = Frontmatter::default();
2765 let result = write(&fm, "body\n").unwrap();
2766 assert!(!result.contains("hooks"));
2767 }
2768
2769 #[test]
2770 fn hooks_absent_parses_as_empty() {
2771 let content = "---\nsession: abc\n---\nBody\n";
2772 let (fm, _) = parse(content).unwrap();
2773 assert!(fm.hooks.is_empty());
2774 }
2775
2776 #[test]
2777 fn parse_no_mcp_field() {
2778 let content = "---\nno_mcp: true\n---\nBody\n";
2779 let (fm, _) = parse(content).unwrap();
2780 assert_eq!(fm.no_mcp, Some(true));
2781 }
2782
2783 #[test]
2784 fn parse_enable_tool_search_field() {
2785 let content = "---\nenable_tool_search: true\n---\nBody\n";
2786 let (fm, _) = parse(content).unwrap();
2787 assert_eq!(fm.enable_tool_search, Some(true));
2788 }
2789
2790 #[test]
2791 fn parse_missing_flags_default_none() {
2792 let content = "---\nsession: abc\n---\nBody\n";
2793 let (fm, _) = parse(content).unwrap();
2794 assert!(fm.no_mcp.is_none());
2795 assert!(fm.enable_tool_search.is_none());
2796 }
2797
2798 #[test]
2799 fn parse_env_map() {
2800 let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
2801 let (fm, _) = parse(content).unwrap();
2802 assert_eq!(fm.env.len(), 2);
2803 assert_eq!(fm.env["FOO"], Some("bar".to_string()));
2804 assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
2805 let keys: Vec<&String> = fm.env.keys().collect();
2807 assert_eq!(keys, vec!["FOO", "BAZ"]);
2808 }
2809
2810 #[test]
2811 fn parse_env_empty_default() {
2812 let content = "---\nsession: abc\n---\nBody\n";
2813 let (fm, _) = parse(content).unwrap();
2814 assert!(fm.env.is_empty());
2815 }
2816
2817 #[test]
2818 fn parse_env_unset_via_null() {
2819 let content = "---\nenv:\n SET_ME: value\n UNSET_ME: null\n---\nBody\n";
2820 let (fm, _) = parse(content).unwrap();
2821 assert_eq!(fm.env.len(), 2);
2822 assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
2823 assert_eq!(fm.env["UNSET_ME"], None);
2824 }
2825
2826 #[test]
2827 fn write_roundtrip_with_env() {
2828 let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
2829 env.insert("KEY1".to_string(), Some("value1".to_string()));
2830 env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
2831 env.insert("KEY3".to_string(), None);
2832 let fm = Frontmatter {
2833 env,
2834 ..Default::default()
2835 };
2836 let written = write(&fm, "body\n").unwrap();
2837 let (fm2, _) = parse(&written).unwrap();
2838 assert_eq!(fm2.env.len(), 3);
2839 assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
2840 assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
2841 assert_eq!(fm2.env["KEY3"], None);
2842 }
2843
2844 #[test]
2845 fn parse_agent_args_field() {
2846 let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
2847 let (fm, _) = parse(content).unwrap();
2848 assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
2849 assert!(fm.claude_args.is_none());
2850 assert!(fm.codex_args.is_none());
2851 assert!(fm.opencode_args.is_none());
2852 }
2853
2854 #[test]
2855 fn parse_agent_args_and_harness_aliases() {
2856 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";
2857 let (fm, _) = parse(content).unwrap();
2858 assert_eq!(fm.agent_args.as_deref(), Some("--json"));
2859 assert_eq!(
2860 fm.claude_args.as_deref(),
2861 Some("--dangerously-skip-permissions")
2862 );
2863 assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
2864 assert_eq!(
2865 fm.opencode_args.as_deref(),
2866 Some("--dangerously-skip-permissions")
2867 );
2868 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
2869 assert_eq!(
2870 fm.required_ssh_targets,
2871 vec!["monsterrodholders-server".to_string()]
2872 );
2873 }
2874
2875 #[test]
2876 fn write_roundtrip_agent_args() {
2877 let fm = Frontmatter {
2878 agent_args: Some("--json -s workspace-write".to_string()),
2879 codex_args: Some("-s danger-full-access".to_string()),
2880 opencode_args: Some("--dangerously-skip-permissions".to_string()),
2881 codex_network_access: Some(CodexNetworkAccess::Enabled),
2882 required_ssh_targets: vec!["monsterrodholders-server".to_string()],
2883 ..Default::default()
2884 };
2885 let written = write(&fm, "body\n").unwrap();
2886 let (fm2, _) = parse(&written).unwrap();
2887 assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
2888 assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
2889 assert_eq!(
2890 fm2.opencode_args.as_deref(),
2891 Some("--dangerously-skip-permissions")
2892 );
2893 assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
2894 assert_eq!(
2895 fm2.required_ssh_targets,
2896 vec!["monsterrodholders-server".to_string()]
2897 );
2898 }
2899
2900 #[test]
2901 fn merge_fields_agent_args() {
2902 let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
2903 let result = merge_fields(content, "agent_args: new").unwrap();
2904 let (fm, _) = parse(&result).unwrap();
2905 assert_eq!(fm.agent_args.as_deref(), Some("new"));
2906 assert_eq!(fm.claude_args.as_deref(), Some("old"));
2907 assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
2908 assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
2909 }
2910
2911 #[test]
2912 fn merge_fields_codex_args() {
2913 let content = "---\nagent_args: old\n---\nBody\n";
2914 let result = merge_fields(content, "codex_args: new").unwrap();
2915 let (fm, _) = parse(&result).unwrap();
2916 assert_eq!(fm.agent_args.as_deref(), Some("old"));
2917 assert_eq!(fm.codex_args.as_deref(), Some("new"));
2918 }
2919
2920 #[test]
2921 fn merge_fields_opencode_args() {
2922 let content = "---\nagent_args: old\n---\nBody\n";
2923 let result = merge_fields(content, "opencode_args: new").unwrap();
2924 let (fm, _) = parse(&result).unwrap();
2925 assert_eq!(fm.agent_args.as_deref(), Some("old"));
2926 assert_eq!(fm.opencode_args.as_deref(), Some("new"));
2927 }
2928
2929 #[test]
2930 fn merge_fields_codex_network_access() {
2931 let content = "Body\n";
2932 let result = merge_fields(content, "codex_network_access: disabled").unwrap();
2933 let (fm, _) = parse(&result).unwrap();
2934 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
2935 }
2936
2937 #[test]
2938 fn merge_fields_required_ssh_targets() {
2939 let content = "Body\n";
2940 let result = merge_fields(
2941 content,
2942 "required_ssh_targets:\n - monsterrodholders-server\n - root@50.28.2.199",
2943 )
2944 .unwrap();
2945 let (fm, _) = parse(&result).unwrap();
2946 assert_eq!(
2947 fm.required_ssh_targets,
2948 vec![
2949 "monsterrodholders-server".to_string(),
2950 "root@50.28.2.199".to_string(),
2951 ]
2952 );
2953 }
2954
2955 #[test]
2956 fn merge_fields_required_ssh_profile() {
2957 let content = "Body\n";
2958 let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
2959 let (fm, _) = parse(&result).unwrap();
2960 assert_eq!(
2961 fm.required_ssh_profile.as_deref(),
2962 Some("monsterrodholders")
2963 );
2964 }
2965
2966 #[test]
2969 fn resolve_harness_model_claude_code_uses_claude_model() {
2970 let fm = Frontmatter {
2971 model: Some("gpt-5".to_string()),
2972 claude_model: Some("opus".to_string()),
2973 codex_model: Some("o3-pro".to_string()),
2974 ..Default::default()
2975 };
2976 assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
2977 }
2978
2979 #[test]
2980 fn resolve_harness_model_codex_uses_codex_model() {
2981 let fm = Frontmatter {
2982 model: Some("gpt-5".to_string()),
2983 claude_model: Some("opus".to_string()),
2984 codex_model: Some("o3-pro".to_string()),
2985 opencode_model: Some("zai/glm-5".to_string()),
2986 ..Default::default()
2987 };
2988 assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
2989 }
2990
2991 #[test]
2992 fn resolve_harness_model_opencode_uses_opencode_model() {
2993 let fm = Frontmatter {
2994 model: Some("gpt-5".to_string()),
2995 claude_model: Some("opus".to_string()),
2996 codex_model: Some("o3-pro".to_string()),
2997 opencode_model: Some("zai/glm-5".to_string()),
2998 ..Default::default()
2999 };
3000 assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
3001 }
3002
3003 #[test]
3004 fn resolve_harness_model_falls_back_to_generic() {
3005 let fm = Frontmatter {
3006 model: Some("gpt-5".to_string()),
3007 ..Default::default()
3008 };
3009 assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
3010 assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
3011 assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
3012 assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
3013 }
3014
3015 #[test]
3016 fn resolve_harness_model_none_when_no_model() {
3017 let fm = Frontmatter::default();
3018 assert_eq!(fm.resolve_harness_model("claude-code"), None);
3019 }
3020
3021 #[test]
3022 fn resolve_harness_model_unknown_harness_uses_generic() {
3023 let fm = Frontmatter {
3024 model: Some("sonnet".to_string()),
3025 claude_model: Some("opus".to_string()),
3026 ..Default::default()
3027 };
3028 assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
3029 }
3030
3031 #[test]
3032 fn parse_harness_model_fields() {
3033 let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
3034 let (fm, _) = parse(content).unwrap();
3035 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3036 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
3037 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
3038 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
3039 }
3040
3041 #[test]
3042 fn write_roundtrip_harness_model_fields() {
3043 let fm = Frontmatter {
3044 model: Some("gpt-5".to_string()),
3045 claude_model: Some("opus".to_string()),
3046 codex_model: Some("o3-pro".to_string()),
3047 opencode_model: Some("zai/glm-5".to_string()),
3048 ..Default::default()
3049 };
3050 let doc = write(&fm, "Body\n").unwrap();
3051 let (fm2, body) = parse(&doc).unwrap();
3052 assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
3053 assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
3054 assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
3055 assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
3056 assert_eq!(body, "Body\n");
3057 }
3058
3059 #[test]
3060 fn merge_fields_claude_model() {
3061 let content = "---\nmodel: gpt-5\n---\nBody\n";
3062 let result = merge_fields(content, "claude_model: opus").unwrap();
3063 let (fm, _) = parse(&result).unwrap();
3064 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3065 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
3066 }
3067
3068 #[test]
3069 fn merge_fields_codex_model() {
3070 let content = "---\nmodel: gpt-5\n---\nBody\n";
3071 let result = merge_fields(content, "codex_model: o3-pro").unwrap();
3072 let (fm, _) = parse(&result).unwrap();
3073 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3074 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
3075 }
3076
3077 #[test]
3078 fn merge_fields_opencode_model() {
3079 let content = "---\nmodel: gpt-5\n---\nBody\n";
3080 let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
3081 let (fm, _) = parse(&result).unwrap();
3082 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3083 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
3084 }
3085}