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 apply_required_ssh_defaults(
1378 fm: &mut Frontmatter,
1379 ctx: &SshResolverContext<'_>,
1380) -> Result<()> {
1381 if !fm.required_ssh_targets.is_empty() {
1382 fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
1383 return Ok(());
1384 }
1385
1386 if ctx.doc_relative.is_empty() {
1387 return Ok(());
1388 }
1389
1390 let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
1391 resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
1392 } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
1393 let mut targets = doc_cfg.targets.clone();
1394 if let Some(profile_name) = doc_cfg.profile.as_deref() {
1395 targets.extend(resolve_required_ssh_profile_targets(
1396 &ctx.project.ssh,
1397 profile_name,
1398 ctx.file_display,
1399 )?);
1400 }
1401 if targets.is_empty() {
1402 anyhow::bail!(
1403 "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>].",
1404 ctx.file_display,
1405 ctx.doc_relative,
1406 ctx.doc_relative
1407 );
1408 }
1409 targets
1410 } else {
1411 Vec::new()
1412 };
1413
1414 fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
1415 Ok(())
1416}
1417
1418fn resolve_required_ssh_profile_targets(
1419 ssh: &crate::project_config::SshConfig,
1420 profile_name: &str,
1421 file_display: &str,
1422) -> Result<Vec<String>> {
1423 let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
1424 anyhow::anyhow!(
1425 "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
1426 file_display,
1427 profile_name,
1428 profile_name
1429 )
1430 })?;
1431 if profile.targets.is_empty() {
1432 anyhow::bail!(
1433 "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
1434 file_display,
1435 profile_name,
1436 profile_name
1437 );
1438 }
1439 Ok(profile.targets.clone())
1440}
1441
1442fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
1443 let mut normalized = Vec::new();
1444 for target in targets {
1445 let trimmed = target.trim();
1446 if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
1447 continue;
1448 }
1449 normalized.push(trimmed.to_string());
1450 }
1451 normalized
1452}
1453
1454fn contextualize_yaml_parse_error(
1455 file_display: &str,
1456 yaml: &str,
1457 err: serde_yaml::Error,
1458) -> anyhow::Error {
1459 let excerpt = err
1460 .location()
1461 .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
1462 let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
1463 if let Some(excerpt) = excerpt {
1464 message.push_str("\n\nFrontmatter excerpt:\n");
1465 message.push_str(&excerpt);
1466 }
1467 message.push_str(
1468 "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
1469 );
1470 anyhow::anyhow!(message)
1471}
1472
1473fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
1474 let lines: Vec<&str> = yaml.lines().collect();
1475 if line == 0 || line > lines.len() {
1476 return None;
1477 }
1478
1479 let start = line.saturating_sub(1).max(1);
1480 let end = (line + 1).min(lines.len());
1481 let width = end.to_string().len();
1482 let mut rendered = Vec::new();
1483
1484 for current in start..=end {
1485 let marker = if current == line { '>' } else { ' ' };
1486 rendered.push(format!(
1487 "{} {:>width$} | {}",
1488 marker,
1489 current,
1490 lines[current - 1],
1491 width = width
1492 ));
1493 if current == line {
1494 rendered.push(format!(
1495 " {:>width$} | {}^",
1496 "",
1497 " ".repeat(column.saturating_sub(1)),
1498 width = width
1499 ));
1500 }
1501 }
1502
1503 Some(rendered.join("\n"))
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use super::*;
1509
1510 #[test]
1511 fn parse_no_frontmatter() {
1512 let content = "# Hello\n\nBody text.\n";
1513 let (fm, body) = parse(content).unwrap();
1514 assert!(fm.session.is_none());
1515 assert!(fm.agent.is_none());
1516 assert!(fm.model.is_none());
1517 assert!(fm.branch.is_none());
1518 assert_eq!(body, content);
1519 }
1520
1521 #[test]
1522 fn parse_tolerates_leading_horizontal_space_before_frontmatter() {
1523 let content = " ---\nagent_doc_session: abc-123\n---\nBody\n";
1524 let (fm, body) = parse(content).unwrap();
1525 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1526 assert_eq!(body, "Body\n");
1527 }
1528
1529 #[test]
1530 fn parse_tolerates_bom_before_frontmatter() {
1531 let content = "\u{feff}---\nagent_doc_session: abc-123\n---\nBody\n";
1532 let (fm, body) = parse(content).unwrap();
1533 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1534 assert_eq!(body, "Body\n");
1535 }
1536
1537 #[test]
1538 fn parse_keeps_leading_blank_line_as_body_not_frontmatter() {
1539 let content = "\n---\nagent_doc_session: abc-123\n---\nBody\n";
1540 let (fm, body) = parse(content).unwrap();
1541 assert!(fm.session.is_none());
1542 assert_eq!(body, content);
1543 }
1544
1545 #[test]
1546 fn parse_all_fields() {
1547 let content =
1548 "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1549 let (fm, body) = parse(content).unwrap();
1550 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1551 assert_eq!(fm.agent.as_deref(), Some("claude"));
1552 assert_eq!(fm.model.as_deref(), Some("opus"));
1553 assert_eq!(fm.branch.as_deref(), Some("main"));
1554 assert!(body.contains("Body"));
1555 }
1556
1557 #[test]
1558 fn queue_control_parse_aliases() {
1559 assert_eq!(QueueControl::parse("start"), Some(QueueControl::Start));
1560 assert_eq!(QueueControl::parse("go"), Some(QueueControl::Start));
1561 assert_eq!(QueueControl::parse("STOP"), Some(QueueControl::Stop));
1562 assert_eq!(QueueControl::parse(" Start "), Some(QueueControl::Start));
1563 assert_eq!(QueueControl::parse("auto"), None);
1564 assert_eq!(QueueControl::parse(""), None);
1565 assert!(QueueControl::Start.is_active());
1566 assert!(!QueueControl::Stop.is_active());
1567 }
1568
1569 #[test]
1570 fn parse_queue_start_activates() {
1571 let (fm, _) = parse("---\nagent_doc_format: template\nqueue: start\n---\n\n").unwrap();
1574 assert_eq!(fm.queue.as_deref(), Some("start"));
1575 assert_eq!(fm.queue_active, Some(true));
1576 }
1577
1578 #[test]
1579 fn parse_queue_go_alias_activates() {
1580 let (fm, _) = parse("---\nqueue: go\n---\n\n").unwrap();
1581 assert_eq!(fm.queue_active, Some(true));
1582 }
1583
1584 #[test]
1585 fn parse_queue_stop_deactivates() {
1586 let (fm, _) = parse("---\nqueue: stop\n---\n\n").unwrap();
1587 assert_eq!(fm.queue_active, Some(false));
1588 }
1589
1590 #[test]
1591 fn parse_queue_canonical_wins_over_stale_queue_active() {
1592 let (fm, _) = parse("---\nqueue: stop\nqueue_active: true\n---\n\n").unwrap();
1594 assert_eq!(fm.queue_active, Some(false));
1595
1596 let (fm, _) = parse("---\nqueue: start\nqueue_active: false\n---\n\n").unwrap();
1597 assert_eq!(fm.queue_active, Some(true));
1598 }
1599
1600 #[test]
1601 fn pipeline_is_empty_default() {
1602 let p = AgentDocPipeline::default();
1603 assert!(p.is_empty());
1604 }
1605
1606 #[test]
1607 fn parse_pipeline_all_fields() {
1608 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";
1609 let (fm, _) = parse(content).unwrap();
1610 assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1611 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-1780600334196"));
1612 assert_eq!(fm.pipeline.step.as_deref(), Some("response_captured"));
1613 assert_eq!(
1614 fm.pipeline.queue_task_id.as_deref(),
1615 Some("#fm-run-id-step")
1616 );
1617 assert!(!fm.pipeline.is_empty());
1618 }
1619
1620 #[test]
1621 fn empty_pipeline_is_not_serialized() {
1622 let fm = Frontmatter {
1624 session: Some("abc".to_string()),
1625 ..Default::default()
1626 };
1627 let out = write(&fm, "Body\n").unwrap();
1628 assert!(!out.contains("agent_doc_pipeline"));
1629 }
1630
1631 #[test]
1632 fn set_pipeline_state_merges_without_disturbing_other_fields() {
1633 let content =
1634 "---\nagent_doc_session: abc-123\nagent: claude\nqueue: start\n---\n\n## Body\n";
1635 let updated =
1637 set_pipeline_state(content, Some("cycle-99"), Some("write_applied"), None, None)
1638 .unwrap();
1639 let (fm, body) = parse(&updated).unwrap();
1640 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1641 assert_eq!(fm.pipeline.step.as_deref(), Some("write_applied"));
1642 assert!(fm.pipeline.turn_id.is_none());
1643 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1645 assert_eq!(fm.agent.as_deref(), Some("claude"));
1646 assert_eq!(fm.queue_active, Some(true));
1647 assert!(body.contains("## Body"));
1648
1649 let updated2 = set_pipeline_state(
1651 &updated,
1652 None,
1653 Some("committed"),
1654 Some("#fm-run-id-step"),
1655 None,
1656 )
1657 .unwrap();
1658 let (fm2, _) = parse(&updated2).unwrap();
1659 assert_eq!(fm2.pipeline.run_id.as_deref(), Some("cycle-99"));
1660 assert_eq!(fm2.pipeline.step.as_deref(), Some("committed"));
1661 assert_eq!(fm2.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1662 }
1663
1664 #[test]
1665 fn splice_pipeline_block_is_byte_precise_and_strip_cancels() {
1666 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";
1669 let pipeline = AgentDocPipeline {
1670 run_id: Some("cycle-99".into()),
1671 step: Some("response_captured".into()),
1672 turn_id: Some("#22a8".into()),
1673 queue_task_id: None,
1674 };
1675 let spliced = splice_pipeline_block(base, &pipeline).unwrap();
1676 assert!(spliced.contains("agent_doc_pipeline:"));
1678 let (fm, _) = parse(&spliced).unwrap();
1679 assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1680 assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#22a8"));
1681 assert_eq!(fm.agent.as_deref(), Some("claude"));
1683 assert_eq!(strip_pipeline_block_lines(&spliced), base);
1685 assert_eq!(
1687 splice_pipeline_block(&spliced, &AgentDocPipeline::default()).unwrap(),
1688 base
1689 );
1690 let twice = splice_pipeline_block(&spliced, &pipeline).unwrap();
1692 assert_eq!(twice.matches("agent_doc_pipeline:").count(), 1);
1693 }
1694
1695 #[test]
1696 fn clear_pipeline_state_drops_block_only() {
1697 let content = "---\nagent_doc_session: abc-123\nagent_doc_pipeline:\n run_id: cycle-1\n step: response_captured\n---\n\nBody\n";
1698 let cleared = clear_pipeline_state(content).unwrap();
1699 assert!(!cleared.contains("agent_doc_pipeline"));
1700 let (fm, body) = parse(&cleared).unwrap();
1701 assert!(fm.pipeline.is_empty());
1702 assert_eq!(fm.session.as_deref(), Some("abc-123"));
1703 assert!(body.contains("Body"));
1704 }
1705
1706 #[test]
1707 fn parse_queue_unknown_value_leaves_queue_active_untouched() {
1708 let (fm, _) = parse("---\nqueue: bogus\nqueue_active: true\n---\n\n").unwrap();
1710 assert_eq!(fm.queue_active, Some(true));
1711 let (fm, _) = parse("---\nqueue: bogus\n---\n\n").unwrap();
1712 assert_eq!(fm.queue_active, None);
1713 }
1714
1715 #[test]
1716 fn write_emits_canonical_queue_for_direct_set_queue_active() {
1717 let mut fm = Frontmatter {
1722 queue_active: Some(true),
1723 ..Default::default()
1724 };
1725 let out = write(&fm, "body\n").unwrap();
1726 assert!(out.contains("queue: start"), "{out}");
1727 assert!(!out.contains("queue_active:"), "{out}");
1728
1729 fm.queue_active = Some(false);
1730 let out = write(&fm, "body\n").unwrap();
1731 assert!(out.contains("queue: stop"), "{out}");
1732 assert!(!out.contains("queue_active:"), "{out}");
1733
1734 let (fm, body) =
1737 parse("---\nagent_doc_format: template\nqueue: start\n---\n\nx\n").unwrap();
1738 let out = write(&fm, &body).unwrap();
1739 assert!(out.contains("queue: start"), "{out}");
1740 assert!(!out.contains("queue_active:"), "{out}");
1741
1742 let fm = Frontmatter::default();
1744 let out = write(&fm, "body\n").unwrap();
1745 assert!(
1746 !out.contains("queue_active:") && !out.contains("queue:"),
1747 "{out}"
1748 );
1749 }
1750
1751 #[test]
1752 fn merge_queue_state_writes_canonical_and_drops_legacy() {
1753 let legacy = "---\nagent_doc_format: template\nqueue_active: true\n---\n\nbody\n";
1756 let active = merge_queue_state(legacy, true).unwrap();
1757 assert!(active.contains("queue: start"), "{active}");
1758 assert!(!active.contains("queue_active:"), "{active}");
1759
1760 let stopped = merge_queue_state(legacy, false).unwrap();
1761 assert!(stopped.contains("queue: stop"), "{stopped}");
1762 assert!(!stopped.contains("queue_active:"), "{stopped}");
1763
1764 let (fm, _) = parse(&active).unwrap();
1766 assert_eq!(fm.queue_active, Some(true));
1767 let (fm, _) = parse(&stopped).unwrap();
1768 assert_eq!(fm.queue_active, Some(false));
1769 }
1770
1771 #[test]
1772 fn strip_deprecated_queue_active_line_drops_legacy_when_canonical_present() {
1773 let stuck = "---\nagent: claude\nqueue: start\nqueue_active: true\n---\n\nbody\n";
1778 let out = strip_deprecated_queue_active_line(stuck);
1779 assert_eq!(
1780 out, "---\nagent: claude\nqueue: start\n---\n\nbody\n",
1781 "legacy line dropped, canonical queue + other fields preserved"
1782 );
1783 assert_eq!(strip_deprecated_queue_active_line(&out), out);
1785 }
1786
1787 #[test]
1788 fn strip_deprecated_queue_active_line_keeps_legacy_without_canonical() {
1789 let legacy_only = "---\nqueue_active: true\n---\n\nbody\n";
1792 assert_eq!(strip_deprecated_queue_active_line(legacy_only), legacy_only);
1793 assert_eq!(
1795 strip_deprecated_queue_active_line("no frontmatter\nqueue_active: true\n"),
1796 "no frontmatter\nqueue_active: true\n"
1797 );
1798 }
1799
1800 #[test]
1801 fn parse_legacy_queue_active_still_honored() {
1802 let (fm, _) = parse("---\nqueue_active: true\n---\n\n").unwrap();
1804 assert_eq!(fm.queue.as_deref(), None);
1805 assert_eq!(fm.queue_active, Some(true));
1806 }
1807
1808 #[test]
1809 fn parse_partial_fields() {
1810 let content = "---\nsession: xyz\n---\n# Doc\n";
1811 let (fm, body) = parse(content).unwrap();
1812 assert_eq!(fm.session.as_deref(), Some("xyz"));
1813 assert!(fm.agent.is_none());
1814 assert!(body.contains("# Doc"));
1815 }
1816
1817 #[test]
1818 fn parse_model_tier_high() {
1819 let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
1820 let (fm, _) = parse(content).unwrap();
1821 assert_eq!(fm.model_tier, Some(Tier::High));
1822 }
1823
1824 #[test]
1825 fn parse_collaboration_mode_shared() {
1826 let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
1827 let (fm, _) = parse(content).unwrap();
1828 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1829 }
1830
1831 #[test]
1832 fn parse_security_review_roundtrip() {
1833 let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
1834 let (fm, _) = parse(content).unwrap();
1835 assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1836 assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
1837 assert!(fm.has_security_review());
1838 let written = write(&fm, "Body\n").unwrap();
1839 assert!(written.contains("agent_doc_collaboration: shared"));
1840 assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
1841 }
1842
1843 #[test]
1844 fn parse_model_tier_low() {
1845 let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
1846 let (fm, _) = parse(content).unwrap();
1847 assert_eq!(fm.model_tier, Some(Tier::Low));
1848 }
1849
1850 #[test]
1851 fn parse_model_tier_med() {
1852 let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
1853 let (fm, _) = parse(content).unwrap();
1854 assert_eq!(fm.model_tier, Some(Tier::Med));
1855 }
1856
1857 #[test]
1858 fn parse_model_tier_auto() {
1859 let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
1860 let (fm, _) = parse(content).unwrap();
1861 assert_eq!(fm.model_tier, Some(Tier::Auto));
1862 }
1863
1864 #[test]
1865 fn parse_model_tier_absent() {
1866 let content = "---\nagent: claude\n---\nBody\n";
1867 let (fm, _) = parse(content).unwrap();
1868 assert_eq!(fm.model_tier, None);
1869 }
1870
1871 #[test]
1872 fn parse_pending_capture_guard_strict() {
1873 let content = "---\npending_capture_guard: strict\n---\nBody\n";
1874 let (fm, _) = parse(content).unwrap();
1875 assert_eq!(
1876 fm.pending_capture_guard,
1877 Some(PendingCaptureGuardMode::Strict)
1878 );
1879 }
1880
1881 #[test]
1882 fn parse_pending_capture_guard_invalid_rejected() {
1883 let content = "---\npending_capture_guard: loudly\n---\nBody\n";
1884 assert!(parse(content).is_err());
1885 }
1886
1887 #[test]
1888 fn parse_pending_done_guard_strict() {
1889 let content = "---\npending_done_guard: strict\n---\nBody\n";
1890 let (fm, _) = parse(content).unwrap();
1891 assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
1892 }
1893
1894 #[test]
1895 fn parse_pending_done_guard_invalid_rejected() {
1896 let content = "---\npending_done_guard: loudly\n---\nBody\n";
1897 assert!(parse(content).is_err());
1898 }
1899
1900 #[test]
1901 fn parse_model_tier_invalid_rejected() {
1902 let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
1903 let result = parse(content);
1904 assert!(result.is_err(), "invalid tier value should fail to parse");
1905 }
1906
1907 #[test]
1908 fn write_model_tier_roundtrip() {
1909 let fm = Frontmatter {
1910 model_tier: Some(Tier::High),
1911 ..Default::default()
1912 };
1913 let doc = write(&fm, "Body\n").unwrap();
1914 let (parsed, _) = parse(&doc).unwrap();
1915 assert_eq!(parsed.model_tier, Some(Tier::High));
1916 assert!(doc.contains("agent_doc_model_tier: high"));
1917 }
1918
1919 #[test]
1920 fn parse_null_fields() {
1921 let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
1922 let (fm, body) = parse(content).unwrap();
1923 assert!(fm.session.is_none());
1924 assert!(fm.agent.is_none());
1925 assert!(fm.model.is_none());
1926 assert!(fm.branch.is_none());
1927 assert!(body.contains("Body"));
1928 }
1929
1930 #[test]
1931 fn parse_unterminated_frontmatter() {
1932 let content = "---\nsession: abc\nno closing block";
1933 let err = parse(content).unwrap_err();
1934 assert!(err.to_string().contains("Unterminated frontmatter"));
1935 }
1936
1937 #[test]
1938 fn parse_closing_at_eof() {
1939 let content = "---\nsession: abc\n---";
1940 let (fm, body) = parse(content).unwrap();
1941 assert_eq!(fm.session.as_deref(), Some("abc"));
1942 assert_eq!(body, "");
1943 }
1944
1945 #[test]
1946 fn parse_empty_body() {
1947 let content = "---\nsession: abc\n---\n";
1948 let (fm, _body) = parse(content).unwrap();
1949 assert_eq!(fm.session.as_deref(), Some("abc"));
1950 }
1951
1952 #[test]
1953 fn write_roundtrip() {
1954 let fm = Frontmatter {
1956 session: Some("test-id".to_string()),
1957 resume: Some("resume-id".to_string()),
1958 agent: Some("claude".to_string()),
1959 model: Some("opus".to_string()),
1960 claude_model: None,
1961 codex_model: None,
1962 opencode_model: None,
1963 branch: Some("dev".to_string()),
1964 tmux_session: None,
1965 mode: None,
1966 format: None,
1967 write_mode: None,
1968 stream_config: None,
1969 agent_args: None,
1970 claude_args: None,
1971 codex_args: None,
1972 opencode_args: None,
1973 codex_network_access: None,
1974 required_ssh_targets: Vec::new(),
1975 required_ssh_profile: None,
1976 managed_proof_max_attempts: None,
1977 managed_proof_retry_backoff_secs: None,
1978 managed_proof_probe_timeout_secs: None,
1979 no_mcp: None,
1980 enable_tool_search: None,
1981 debounce_ms: None,
1982 links: vec![],
1983 auto_compact: None,
1984 queue_context_reset: None,
1985 gate_autoverify: None,
1986 clear_threshold: None,
1987 supervisor_auto_recycle: None,
1988 agent_change_restart: None,
1989 supervisor_auto_install: None,
1990 model_tier: None,
1991 pending_capture_guard: None,
1992 pending_done_guard: None,
1993 review_done_guard: None,
1994 auto_done: None,
1995 agent_doc_lint_dialect: None,
1996 hooks: std::collections::HashMap::new(),
1997 env: indexmap::IndexMap::new(),
1998 prompt_presets: indexmap::IndexMap::new(),
1999 dispatch: None,
2000 agent_doc_env_inherit: None,
2001 cwd: None,
2002 queue: None,
2003 queue_active: None,
2004 queue_prompt_echo_max_chars: None,
2005 collaboration: None,
2006 security_review: None,
2007 pipeline: AgentDocPipeline::default(),
2008 };
2009 let body = "# Hello\n\nBody text.\n";
2010 let written = write(&fm, body).unwrap();
2011 let (fm2, body2) = parse(&written).unwrap();
2012 assert_eq!(fm2.session, fm.session);
2013 assert_eq!(fm2.agent, fm.agent);
2014 assert_eq!(fm2.model, fm.model);
2015 assert_eq!(fm2.claude_model, fm.claude_model);
2016 assert_eq!(fm2.codex_model, fm.codex_model);
2017 assert_eq!(fm2.opencode_model, fm.opencode_model);
2018 assert_eq!(fm2.branch, fm.branch);
2019 assert_eq!(fm2.collaboration, fm.collaboration);
2020 assert_eq!(fm2.security_review, fm.security_review);
2021 assert_eq!(body2, body);
2022 }
2023
2024 #[test]
2025 fn repeated_parse_write_roundtrip_preserves_body_prefix() {
2026 let fm = Frontmatter {
2027 session: Some("abc".to_string()),
2028 agent: Some("codex".to_string()),
2029 ..Default::default()
2030 };
2031 let original = write(&fm, "## Status\n\nBody\n").unwrap();
2032 let mut current = original.clone();
2033
2034 for _ in 0..3 {
2035 let (fm, body) = parse(¤t).unwrap();
2036 assert_eq!(body, "## Status\n\nBody\n");
2037 current = write(&fm, body).unwrap();
2038 }
2039
2040 let (fm, body) = parse(¤t).unwrap();
2041 assert_eq!(fm.session.as_deref(), Some("abc"));
2042 assert_eq!(fm.agent.as_deref(), Some("codex"));
2043 assert_eq!(body, "## Status\n\nBody\n");
2044 assert_eq!(current, original);
2045 }
2046
2047 #[test]
2048 fn write_default_frontmatter() {
2049 let fm = Frontmatter::default();
2050 let result = write(&fm, "body\n").unwrap();
2051 assert!(result.starts_with("---\n"));
2052 assert!(result.ends_with("---\nbody\n"));
2053 }
2054
2055 #[test]
2056 fn write_preserves_body_content() {
2057 let fm = Frontmatter::default();
2058 let body = "# Title\n\nSome **markdown** with `code`.\n";
2059 let result = write(&fm, body).unwrap();
2060 assert!(result.contains("# Title"));
2061 assert!(result.contains("Some **markdown** with `code`."));
2062 }
2063
2064 #[test]
2065 fn set_session_id_creates_frontmatter() {
2066 let content = "# No frontmatter\n\nJust body.\n";
2067 let result = set_session_id(content, "new-session").unwrap();
2068 let (fm, body) = parse(&result).unwrap();
2069 assert_eq!(fm.session.as_deref(), Some("new-session"));
2070 assert!(body.contains("# No frontmatter"));
2071 }
2072
2073 #[test]
2074 fn set_session_id_updates_existing() {
2075 let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
2076 let result = set_session_id(content, "new-id").unwrap();
2077 let (fm, body) = parse(&result).unwrap();
2078 assert_eq!(fm.session.as_deref(), Some("new-id"));
2079 assert_eq!(fm.agent.as_deref(), Some("claude"));
2080 assert!(body.contains("Body"));
2081 }
2082
2083 #[test]
2084 fn set_session_id_preserves_other_fields() {
2085 let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
2086 let result = set_session_id(content, "new").unwrap();
2087 let (fm, _) = parse(&result).unwrap();
2088 assert_eq!(fm.session.as_deref(), Some("new"));
2089 assert_eq!(fm.agent.as_deref(), Some("claude"));
2090 assert_eq!(fm.model.as_deref(), Some("opus"));
2091 assert_eq!(fm.branch.as_deref(), Some("dev"));
2092 }
2093
2094 #[test]
2095 fn ensure_session_no_frontmatter() {
2096 let content = "# Hello\n\nBody.\n";
2097 let (updated, sid) = ensure_session(content).unwrap();
2098 assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
2101 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2102 assert!(body.contains("# Hello"));
2103 }
2104
2105 #[test]
2106 fn ensure_session_null_session() {
2107 let content = "---\nsession:\nagent: claude\n---\nBody\n";
2108 let (updated, sid) = ensure_session(content).unwrap();
2109 assert_eq!(sid.len(), 36);
2110 let (fm, body) = parse(&updated).unwrap();
2111 assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2112 assert_eq!(fm.agent.as_deref(), Some("claude"));
2113 assert!(body.contains("Body"));
2114 }
2115
2116 #[test]
2117 fn ensure_session_existing_session() {
2118 let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
2119 let (updated, sid) = ensure_session(content).unwrap();
2120 assert_eq!(sid, "existing-id");
2121 assert_eq!(updated, content);
2123 }
2124
2125 #[test]
2126 fn parse_legacy_session_field() {
2127 let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
2129 let (fm, body) = parse(content).unwrap();
2130 assert_eq!(fm.session.as_deref(), Some("legacy-id"));
2131 assert_eq!(fm.agent.as_deref(), Some("claude"));
2132 assert!(body.contains("Body"));
2133 }
2134
2135 #[test]
2136 fn parse_agent_doc_mode_canonical() {
2137 let content = "---\nagent_doc_mode: template\n---\nBody\n";
2138 let (fm, _) = parse(content).unwrap();
2139 assert_eq!(fm.mode.as_deref(), Some("template"));
2140 }
2141
2142 #[test]
2143 fn parse_mode_shorthand_alias() {
2144 let content = "---\nmode: template\n---\nBody\n";
2145 let (fm, _) = parse(content).unwrap();
2146 assert_eq!(fm.mode.as_deref(), Some("template"));
2147 }
2148
2149 #[test]
2150 fn parse_response_mode_legacy_alias() {
2151 let content = "---\nresponse_mode: template\n---\nBody\n";
2152 let (fm, _) = parse(content).unwrap();
2153 assert_eq!(fm.mode.as_deref(), Some("template"));
2154 }
2155
2156 #[test]
2157 fn write_uses_agent_doc_mode_field() {
2158 #[allow(deprecated)]
2159 let fm = Frontmatter {
2160 mode: Some("template".to_string()),
2161 ..Default::default()
2162 };
2163 let result = write(&fm, "body\n").unwrap();
2164 assert!(result.contains("agent_doc_mode:"));
2165 assert!(!result.contains("response_mode:"));
2166 assert!(!result.contains("\nmode:"));
2167 }
2168
2169 #[test]
2170 fn write_uses_new_field_name() {
2171 let fm = Frontmatter {
2172 session: Some("test-id".to_string()),
2173 ..Default::default()
2174 };
2175 let result = write(&fm, "body\n").unwrap();
2176 assert!(result.contains("agent_doc_session:"));
2177 assert!(!result.contains("\nsession:"));
2178 }
2179
2180 #[test]
2181 fn write_pending_capture_guard_roundtrip() {
2182 let fm = Frontmatter {
2183 pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
2184 ..Default::default()
2185 };
2186 let written = write(&fm, "body\n").unwrap();
2187 assert!(written.contains("pending_capture_guard: strict"));
2188 let (parsed, _) = parse(&written).unwrap();
2189 assert_eq!(
2190 parsed.pending_capture_guard,
2191 Some(PendingCaptureGuardMode::Strict)
2192 );
2193 }
2194
2195 #[test]
2196 fn write_pending_done_guard_roundtrip() {
2197 let fm = Frontmatter {
2198 pending_done_guard: Some(PendingCaptureGuardMode::Strict),
2199 ..Default::default()
2200 };
2201 let written = write(&fm, "body\n").unwrap();
2202 assert!(written.contains("pending_done_guard: strict"));
2203 let (parsed, _) = parse(&written).unwrap();
2204 assert_eq!(
2205 parsed.pending_done_guard,
2206 Some(PendingCaptureGuardMode::Strict)
2207 );
2208 }
2209
2210 #[test]
2211 fn parse_review_done_guard_error_alias() {
2212 let content = "---\nreview_done_guard: error\n---\nBody\n";
2213 let (fm, _) = parse(content).unwrap();
2214 assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
2215 }
2216
2217 #[test]
2218 fn write_review_done_guard_roundtrip() {
2219 let fm = Frontmatter {
2220 review_done_guard: Some(PendingCaptureGuardMode::Strict),
2221 ..Default::default()
2222 };
2223 let written = write(&fm, "body\n").unwrap();
2224 assert!(written.contains("review_done_guard: strict"));
2225 let (parsed, _) = parse(&written).unwrap();
2226 assert_eq!(
2227 parsed.review_done_guard,
2228 Some(PendingCaptureGuardMode::Strict)
2229 );
2230 }
2231
2232 #[test]
2233 fn parse_auto_done_bool() {
2234 let content = "---\nauto_done: true\n---\nBody\n";
2235 let (fm, _) = parse(content).unwrap();
2236 assert_eq!(fm.auto_done, Some(true));
2237 }
2238
2239 #[test]
2240 fn write_auto_done_roundtrip() {
2241 let fm = Frontmatter {
2242 auto_done: Some(true),
2243 ..Default::default()
2244 };
2245 let written = write(&fm, "Body\n").unwrap();
2246 assert!(written.contains("auto_done: true"));
2247 let (parsed, _) = parse(&written).unwrap();
2248 assert_eq!(parsed.auto_done, Some(true));
2249 }
2250
2251 #[test]
2252 fn parse_prompt_presets_roundtrip() {
2253 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";
2254 let (fm, body) = parse(content).unwrap();
2255 assert_eq!(body, "Body\n");
2256 assert_eq!(
2257 fm.prompt_presets.get("#1").map(String::as_str),
2258 Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
2259 );
2260 assert_eq!(
2261 fm.prompt_presets.get("release-check").map(String::as_str),
2262 Some("Run cargo test.")
2263 );
2264
2265 let written = write(&fm, body).unwrap();
2266 let (parsed, body2) = parse(&written).unwrap();
2267 assert_eq!(body2, "Body\n");
2268 assert_eq!(parsed.prompt_presets, fm.prompt_presets);
2269 }
2270
2271 #[test]
2272 fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
2273 let mut prompt_presets = indexmap::IndexMap::new();
2274 prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
2275 prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
2276
2277 assert_eq!(
2278 resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
2279 Some("#spec-test")
2280 );
2281 assert_eq!(
2282 resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
2283 Some("#spec-test")
2284 );
2285 assert_eq!(
2286 resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
2287 Some("release-check")
2288 );
2289 assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
2290 }
2291
2292 #[test]
2295 fn resolve_mode_defaults() {
2296 let fm = Frontmatter::default();
2297 let resolved = fm.resolve_mode();
2298 assert_eq!(resolved.format, AgentDocFormat::Template);
2299 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2300 }
2301
2302 #[test]
2303 fn resolve_mode_from_deprecated_append() {
2304 let content = "---\nagent_doc_mode: append\n---\nBody\n";
2305 let (fm, _) = parse(content).unwrap();
2306 let resolved = fm.resolve_mode();
2307 assert_eq!(resolved.format, AgentDocFormat::Append);
2308 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2309 }
2310
2311 #[test]
2312 fn resolve_mode_from_deprecated_template() {
2313 let content = "---\nagent_doc_mode: template\n---\nBody\n";
2314 let (fm, _) = parse(content).unwrap();
2315 let resolved = fm.resolve_mode();
2316 assert_eq!(resolved.format, AgentDocFormat::Template);
2317 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2318 }
2319
2320 #[test]
2321 fn resolve_mode_from_deprecated_stream() {
2322 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2323 let (fm, _) = parse(content).unwrap();
2324 let resolved = fm.resolve_mode();
2325 assert_eq!(resolved.format, AgentDocFormat::Template);
2326 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2327 }
2328
2329 #[test]
2330 fn resolve_mode_new_fields_override_deprecated() {
2331 let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
2332 let (fm, _) = parse(content).unwrap();
2333 let resolved = fm.resolve_mode();
2334 assert_eq!(resolved.format, AgentDocFormat::Template);
2335 assert_eq!(resolved.write, AgentDocWrite::Merge);
2336 }
2337
2338 #[test]
2339 fn resolve_mode_explicit_new_fields_only() {
2340 let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
2341 let (fm, _) = parse(content).unwrap();
2342 let resolved = fm.resolve_mode();
2343 assert_eq!(resolved.format, AgentDocFormat::Append);
2344 assert_eq!(resolved.write, AgentDocWrite::Crdt);
2345 }
2346
2347 #[test]
2348 fn resolve_mode_partial_new_field_format_only() {
2349 let content = "---\nagent_doc_format: append\n---\nBody\n";
2350 let (fm, _) = parse(content).unwrap();
2351 let resolved = fm.resolve_mode();
2352 assert_eq!(resolved.format, AgentDocFormat::Append);
2353 assert_eq!(resolved.write, AgentDocWrite::Crdt); }
2355
2356 #[test]
2357 fn resolve_mode_partial_new_field_write_only() {
2358 let content = "---\nagent_doc_write: merge\n---\nBody\n";
2359 let (fm, _) = parse(content).unwrap();
2360 let resolved = fm.resolve_mode();
2361 assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
2363 }
2364
2365 #[test]
2366 fn resolve_mode_helper_methods() {
2367 let fm = Frontmatter::default();
2368 let resolved = fm.resolve_mode();
2369 assert!(resolved.is_template());
2370 assert!(!resolved.is_append());
2371 assert!(resolved.is_crdt());
2372 }
2373
2374 #[test]
2375 fn parse_new_format_field() {
2376 let content = "---\nagent_doc_format: template\n---\nBody\n";
2377 let (fm, _) = parse(content).unwrap();
2378 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2379 }
2380
2381 #[test]
2382 fn parse_new_write_field() {
2383 let content = "---\nagent_doc_write: crdt\n---\nBody\n";
2384 let (fm, _) = parse(content).unwrap();
2385 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2386 }
2387
2388 #[test]
2389 fn write_uses_new_format_write_fields() {
2390 let fm = Frontmatter {
2391 format: Some(AgentDocFormat::Template),
2392 write_mode: Some(AgentDocWrite::Crdt),
2393 ..Default::default()
2394 };
2395 let result = write(&fm, "body\n").unwrap();
2396 assert!(result.contains("agent_doc_format:"));
2397 assert!(result.contains("agent_doc_write:"));
2398 assert!(!result.contains("agent_doc_mode:"));
2399 }
2400
2401 #[test]
2402 fn set_format_and_write_clears_deprecated_mode() {
2403 let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2404 let result =
2405 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2406 let (fm, _) = parse(&result).unwrap();
2407 assert!(fm.mode.is_none());
2408 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2409 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2410 }
2411
2412 #[test]
2415 fn merge_fields_adds_new_field() {
2416 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2417 let result = merge_fields(content, "model: opus").unwrap();
2418 let (fm, body) = parse(&result).unwrap();
2419 assert_eq!(fm.session.as_deref(), Some("abc"));
2420 assert_eq!(fm.model.as_deref(), Some("opus"));
2421 assert!(body.contains("Body"));
2422 }
2423
2424 #[test]
2425 fn merge_fields_updates_existing_field() {
2426 let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
2427 let result = merge_fields(content, "model: opus").unwrap();
2428 let (fm, _) = parse(&result).unwrap();
2429 assert_eq!(fm.model.as_deref(), Some("opus"));
2430 assert_eq!(fm.session.as_deref(), Some("abc"));
2431 }
2432
2433 #[test]
2434 fn merge_fields_multiple_fields() {
2435 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2436 let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
2437 let (fm, _) = parse(&result).unwrap();
2438 assert_eq!(fm.model.as_deref(), Some("opus"));
2439 assert_eq!(fm.agent.as_deref(), Some("claude"));
2440 assert_eq!(fm.branch.as_deref(), Some("main"));
2441 }
2442
2443 #[test]
2444 fn merge_fields_format_enum() {
2445 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2446 let result = merge_fields(content, "agent_doc_format: append").unwrap();
2447 let (fm, _) = parse(&result).unwrap();
2448 assert_eq!(fm.format, Some(AgentDocFormat::Append));
2449 }
2450
2451 #[test]
2452 fn merge_fields_write_enum() {
2453 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2454 let result = merge_fields(content, "agent_doc_write: merge").unwrap();
2455 let (fm, _) = parse(&result).unwrap();
2456 assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
2457 }
2458
2459 #[test]
2460 fn merge_fields_ignores_unknown() {
2461 let content = "---\nagent_doc_session: abc\n---\nBody\n";
2462 let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
2463 let (fm, _) = parse(&result).unwrap();
2464 assert_eq!(fm.model.as_deref(), Some("opus"));
2465 }
2466
2467 #[test]
2468 fn merge_fields_preserves_body() {
2469 let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
2470 let result = merge_fields(content, "model: opus").unwrap();
2471 assert!(result.contains("# Title"));
2472 assert!(result.contains("Some **markdown** content."));
2473 }
2474
2475 #[test]
2476 fn set_format_and_write_clears_deprecated() {
2477 let content = "---\nagent_doc_mode: append\n---\nBody\n";
2478 let result =
2479 set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2480 let (fm, _) = parse(&result).unwrap();
2481 assert!(fm.mode.is_none());
2482 assert_eq!(fm.format, Some(AgentDocFormat::Template));
2483 assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2484 }
2485
2486 #[test]
2487 fn hooks_roundtrip() {
2488 let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
2489 let (fm, _) = parse(content).unwrap();
2490 assert_eq!(
2491 fm.hooks.get("session_start"),
2492 Some(&vec!["echo start {{session_id}}".to_string()])
2493 );
2494 assert_eq!(
2495 fm.hooks.get("post_write"),
2496 Some(&vec!["notify {{file}}".to_string()])
2497 );
2498 }
2499
2500 #[test]
2501 fn hooks_omitted_when_empty() {
2502 let fm = Frontmatter::default();
2503 let result = write(&fm, "body\n").unwrap();
2504 assert!(!result.contains("hooks"));
2505 }
2506
2507 #[test]
2508 fn hooks_absent_parses_as_empty() {
2509 let content = "---\nsession: abc\n---\nBody\n";
2510 let (fm, _) = parse(content).unwrap();
2511 assert!(fm.hooks.is_empty());
2512 }
2513
2514 #[test]
2515 fn parse_no_mcp_field() {
2516 let content = "---\nno_mcp: true\n---\nBody\n";
2517 let (fm, _) = parse(content).unwrap();
2518 assert_eq!(fm.no_mcp, Some(true));
2519 }
2520
2521 #[test]
2522 fn parse_enable_tool_search_field() {
2523 let content = "---\nenable_tool_search: true\n---\nBody\n";
2524 let (fm, _) = parse(content).unwrap();
2525 assert_eq!(fm.enable_tool_search, Some(true));
2526 }
2527
2528 #[test]
2529 fn parse_missing_flags_default_none() {
2530 let content = "---\nsession: abc\n---\nBody\n";
2531 let (fm, _) = parse(content).unwrap();
2532 assert!(fm.no_mcp.is_none());
2533 assert!(fm.enable_tool_search.is_none());
2534 }
2535
2536 #[test]
2537 fn parse_env_map() {
2538 let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
2539 let (fm, _) = parse(content).unwrap();
2540 assert_eq!(fm.env.len(), 2);
2541 assert_eq!(fm.env["FOO"], Some("bar".to_string()));
2542 assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
2543 let keys: Vec<&String> = fm.env.keys().collect();
2545 assert_eq!(keys, vec!["FOO", "BAZ"]);
2546 }
2547
2548 #[test]
2549 fn parse_env_empty_default() {
2550 let content = "---\nsession: abc\n---\nBody\n";
2551 let (fm, _) = parse(content).unwrap();
2552 assert!(fm.env.is_empty());
2553 }
2554
2555 #[test]
2556 fn parse_env_unset_via_null() {
2557 let content = "---\nenv:\n SET_ME: value\n UNSET_ME: null\n---\nBody\n";
2558 let (fm, _) = parse(content).unwrap();
2559 assert_eq!(fm.env.len(), 2);
2560 assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
2561 assert_eq!(fm.env["UNSET_ME"], None);
2562 }
2563
2564 #[test]
2565 fn write_roundtrip_with_env() {
2566 let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
2567 env.insert("KEY1".to_string(), Some("value1".to_string()));
2568 env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
2569 env.insert("KEY3".to_string(), None);
2570 let fm = Frontmatter {
2571 env,
2572 ..Default::default()
2573 };
2574 let written = write(&fm, "body\n").unwrap();
2575 let (fm2, _) = parse(&written).unwrap();
2576 assert_eq!(fm2.env.len(), 3);
2577 assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
2578 assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
2579 assert_eq!(fm2.env["KEY3"], None);
2580 }
2581
2582 #[test]
2583 fn parse_agent_args_field() {
2584 let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
2585 let (fm, _) = parse(content).unwrap();
2586 assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
2587 assert!(fm.claude_args.is_none());
2588 assert!(fm.codex_args.is_none());
2589 assert!(fm.opencode_args.is_none());
2590 }
2591
2592 #[test]
2593 fn parse_agent_args_and_harness_aliases() {
2594 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";
2595 let (fm, _) = parse(content).unwrap();
2596 assert_eq!(fm.agent_args.as_deref(), Some("--json"));
2597 assert_eq!(
2598 fm.claude_args.as_deref(),
2599 Some("--dangerously-skip-permissions")
2600 );
2601 assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
2602 assert_eq!(
2603 fm.opencode_args.as_deref(),
2604 Some("--dangerously-skip-permissions")
2605 );
2606 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
2607 assert_eq!(
2608 fm.required_ssh_targets,
2609 vec!["monsterrodholders-server".to_string()]
2610 );
2611 }
2612
2613 #[test]
2614 fn write_roundtrip_agent_args() {
2615 let fm = Frontmatter {
2616 agent_args: Some("--json -s workspace-write".to_string()),
2617 codex_args: Some("-s danger-full-access".to_string()),
2618 opencode_args: Some("--dangerously-skip-permissions".to_string()),
2619 codex_network_access: Some(CodexNetworkAccess::Enabled),
2620 required_ssh_targets: vec!["monsterrodholders-server".to_string()],
2621 ..Default::default()
2622 };
2623 let written = write(&fm, "body\n").unwrap();
2624 let (fm2, _) = parse(&written).unwrap();
2625 assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
2626 assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
2627 assert_eq!(
2628 fm2.opencode_args.as_deref(),
2629 Some("--dangerously-skip-permissions")
2630 );
2631 assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
2632 assert_eq!(
2633 fm2.required_ssh_targets,
2634 vec!["monsterrodholders-server".to_string()]
2635 );
2636 }
2637
2638 #[test]
2639 fn merge_fields_agent_args() {
2640 let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
2641 let result = merge_fields(content, "agent_args: new").unwrap();
2642 let (fm, _) = parse(&result).unwrap();
2643 assert_eq!(fm.agent_args.as_deref(), Some("new"));
2644 assert_eq!(fm.claude_args.as_deref(), Some("old"));
2645 assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
2646 assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
2647 }
2648
2649 #[test]
2650 fn merge_fields_codex_args() {
2651 let content = "---\nagent_args: old\n---\nBody\n";
2652 let result = merge_fields(content, "codex_args: new").unwrap();
2653 let (fm, _) = parse(&result).unwrap();
2654 assert_eq!(fm.agent_args.as_deref(), Some("old"));
2655 assert_eq!(fm.codex_args.as_deref(), Some("new"));
2656 }
2657
2658 #[test]
2659 fn merge_fields_opencode_args() {
2660 let content = "---\nagent_args: old\n---\nBody\n";
2661 let result = merge_fields(content, "opencode_args: new").unwrap();
2662 let (fm, _) = parse(&result).unwrap();
2663 assert_eq!(fm.agent_args.as_deref(), Some("old"));
2664 assert_eq!(fm.opencode_args.as_deref(), Some("new"));
2665 }
2666
2667 #[test]
2668 fn merge_fields_codex_network_access() {
2669 let content = "Body\n";
2670 let result = merge_fields(content, "codex_network_access: disabled").unwrap();
2671 let (fm, _) = parse(&result).unwrap();
2672 assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
2673 }
2674
2675 #[test]
2676 fn merge_fields_required_ssh_targets() {
2677 let content = "Body\n";
2678 let result = merge_fields(
2679 content,
2680 "required_ssh_targets:\n - monsterrodholders-server\n - root@50.28.2.199",
2681 )
2682 .unwrap();
2683 let (fm, _) = parse(&result).unwrap();
2684 assert_eq!(
2685 fm.required_ssh_targets,
2686 vec![
2687 "monsterrodholders-server".to_string(),
2688 "root@50.28.2.199".to_string(),
2689 ]
2690 );
2691 }
2692
2693 #[test]
2694 fn merge_fields_required_ssh_profile() {
2695 let content = "Body\n";
2696 let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
2697 let (fm, _) = parse(&result).unwrap();
2698 assert_eq!(
2699 fm.required_ssh_profile.as_deref(),
2700 Some("monsterrodholders")
2701 );
2702 }
2703
2704 #[test]
2707 fn resolve_harness_model_claude_code_uses_claude_model() {
2708 let fm = Frontmatter {
2709 model: Some("gpt-5".to_string()),
2710 claude_model: Some("opus".to_string()),
2711 codex_model: Some("o3-pro".to_string()),
2712 ..Default::default()
2713 };
2714 assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
2715 }
2716
2717 #[test]
2718 fn resolve_harness_model_codex_uses_codex_model() {
2719 let fm = Frontmatter {
2720 model: Some("gpt-5".to_string()),
2721 claude_model: Some("opus".to_string()),
2722 codex_model: Some("o3-pro".to_string()),
2723 opencode_model: Some("zai/glm-5".to_string()),
2724 ..Default::default()
2725 };
2726 assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
2727 }
2728
2729 #[test]
2730 fn resolve_harness_model_opencode_uses_opencode_model() {
2731 let fm = Frontmatter {
2732 model: Some("gpt-5".to_string()),
2733 claude_model: Some("opus".to_string()),
2734 codex_model: Some("o3-pro".to_string()),
2735 opencode_model: Some("zai/glm-5".to_string()),
2736 ..Default::default()
2737 };
2738 assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
2739 }
2740
2741 #[test]
2742 fn resolve_harness_model_falls_back_to_generic() {
2743 let fm = Frontmatter {
2744 model: Some("gpt-5".to_string()),
2745 ..Default::default()
2746 };
2747 assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
2748 assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
2749 assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
2750 assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
2751 }
2752
2753 #[test]
2754 fn resolve_harness_model_none_when_no_model() {
2755 let fm = Frontmatter::default();
2756 assert_eq!(fm.resolve_harness_model("claude-code"), None);
2757 }
2758
2759 #[test]
2760 fn resolve_harness_model_unknown_harness_uses_generic() {
2761 let fm = Frontmatter {
2762 model: Some("sonnet".to_string()),
2763 claude_model: Some("opus".to_string()),
2764 ..Default::default()
2765 };
2766 assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
2767 }
2768
2769 #[test]
2770 fn parse_harness_model_fields() {
2771 let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
2772 let (fm, _) = parse(content).unwrap();
2773 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2774 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2775 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2776 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2777 }
2778
2779 #[test]
2780 fn write_roundtrip_harness_model_fields() {
2781 let fm = Frontmatter {
2782 model: Some("gpt-5".to_string()),
2783 claude_model: Some("opus".to_string()),
2784 codex_model: Some("o3-pro".to_string()),
2785 opencode_model: Some("zai/glm-5".to_string()),
2786 ..Default::default()
2787 };
2788 let doc = write(&fm, "Body\n").unwrap();
2789 let (fm2, body) = parse(&doc).unwrap();
2790 assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
2791 assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
2792 assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
2793 assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
2794 assert_eq!(body, "Body\n");
2795 }
2796
2797 #[test]
2798 fn merge_fields_claude_model() {
2799 let content = "---\nmodel: gpt-5\n---\nBody\n";
2800 let result = merge_fields(content, "claude_model: opus").unwrap();
2801 let (fm, _) = parse(&result).unwrap();
2802 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2803 assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2804 }
2805
2806 #[test]
2807 fn merge_fields_codex_model() {
2808 let content = "---\nmodel: gpt-5\n---\nBody\n";
2809 let result = merge_fields(content, "codex_model: o3-pro").unwrap();
2810 let (fm, _) = parse(&result).unwrap();
2811 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2812 assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2813 }
2814
2815 #[test]
2816 fn merge_fields_opencode_model() {
2817 let content = "---\nmodel: gpt-5\n---\nBody\n";
2818 let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
2819 let (fm, _) = parse(&result).unwrap();
2820 assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2821 assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2822 }
2823}