use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
use crate::model_tier::Tier;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollaborationMode {
#[default]
SingleUser,
Shared,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CodexNetworkAccess {
#[default]
Inherit,
Enabled,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueControl {
Start,
Stop,
}
impl QueueControl {
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"start" | "go" => Some(Self::Start),
"stop" => Some(Self::Stop),
_ => None,
}
}
pub fn is_active(self) -> bool {
matches!(self, Self::Start)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum AgentDocFormat {
#[clap(alias = "inline")]
Append,
Template,
}
impl fmt::Display for AgentDocFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Append => write!(f, "inline"),
Self::Template => write!(f, "template"),
}
}
}
impl Serialize for AgentDocFormat {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Append => serializer.serialize_str("inline"),
Self::Template => serializer.serialize_str("template"),
}
}
}
impl<'de> Deserialize<'de> for AgentDocFormat {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"append" | "inline" => Ok(Self::Append),
"template" => Ok(Self::Template),
other => Err(serde::de::Error::unknown_variant(
other,
&["inline", "append", "template"],
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum AgentDocWrite {
Merge,
Crdt,
}
impl fmt::Display for AgentDocWrite {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Merge => write!(f, "merge"),
Self::Crdt => write!(f, "crdt"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedMode {
pub format: AgentDocFormat,
pub write: AgentDocWrite,
}
impl ResolvedMode {
pub fn is_template(&self) -> bool {
self.format == AgentDocFormat::Template
}
pub fn is_append(&self) -> bool {
self.format == AgentDocFormat::Append
}
pub fn is_crdt(&self) -> bool {
self.write == AgentDocWrite::Crdt
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct StreamConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strip_ansi: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lines: Option<usize>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentDocPipeline {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_task_id: Option<String>,
}
impl AgentDocPipeline {
pub fn is_empty(&self) -> bool {
self.turn_id.is_none()
&& self.run_id.is_none()
&& self.step.is_none()
&& self.queue_task_id.is_none()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PendingCaptureGuardMode {
#[default]
Warn,
#[serde(alias = "error")]
Strict,
Off,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LintDialectMode {
#[default]
Warn,
#[serde(alias = "error")]
Strict,
Off,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Frontmatter {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_session",
alias = "session"
)]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmux_session: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_mode",
alias = "mode",
alias = "response_mode"
)]
pub mode: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_format"
)]
pub format: Option<AgentDocFormat>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_write"
)]
pub write_mode: Option<AgentDocWrite>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_stream"
)]
pub stream_config: Option<StreamConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_network_access: Option<CodexNetworkAccess>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_ssh_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_ssh_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_proof_max_attempts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_proof_retry_backoff_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_proof_probe_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_mcp: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_tool_search: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_debounce"
)]
pub debounce_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
pub links: Vec<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_auto_compact"
)]
pub auto_compact: Option<usize>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_queue_context_reset"
)]
pub queue_context_reset: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_gate_autoverify"
)]
pub gate_autoverify: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_clear_threshold"
)]
pub clear_threshold: Option<u8>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_supervisor_auto_recycle"
)]
pub supervisor_auto_recycle: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_agent_change_restart"
)]
pub agent_change_restart: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_supervisor_auto_install"
)]
pub supervisor_auto_install: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_model_tier"
)]
pub model_tier: Option<Tier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_capture_guard: Option<PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_done_guard: Option<PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_done_guard: Option<PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_done: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_doc_lint_dialect: Option<LintDialectMode>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub hooks: std::collections::HashMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
pub env: indexmap::IndexMap<String, Option<String>>,
#[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
pub prompt_presets: indexmap::IndexMap<String, String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_dispatch"
)]
pub dispatch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_doc_env_inherit: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_cwd"
)]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_active: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_queue_prompt_echo_max_chars"
)]
pub queue_prompt_echo_max_chars: Option<usize>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_collaboration"
)]
pub collaboration: Option<CollaborationMode>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "agent_doc_security_review"
)]
pub security_review: Option<String>,
#[serde(
default,
skip_serializing_if = "AgentDocPipeline::is_empty",
rename = "agent_doc_pipeline"
)]
pub pipeline: AgentDocPipeline,
}
pub fn resolve_prompt_preset_key(
prompt_presets: &indexmap::IndexMap<String, String>,
requested: &str,
) -> Option<String> {
let requested = requested.trim();
if prompt_presets.contains_key(requested) {
return Some(requested.to_string());
}
if !requested.starts_with('#') {
let hashtag_key = format!("#{requested}");
if prompt_presets.contains_key(hashtag_key.as_str()) {
return Some(hashtag_key);
}
}
None
}
impl Frontmatter {
pub fn has_agent_doc_marker(&self) -> bool {
self.session.is_some()
|| self.format.is_some()
|| self.write_mode.is_some()
|| self.mode.is_some()
|| self.stream_config.is_some()
|| self.agent.is_some()
|| self.resume.is_some()
|| self.model.is_some()
|| self.claude_model.is_some()
|| self.codex_model.is_some()
|| self.opencode_model.is_some()
|| self.agent_args.is_some()
|| self.claude_args.is_some()
|| self.codex_args.is_some()
|| self.opencode_args.is_some()
|| self.branch.is_some()
|| self.tmux_session.is_some()
|| self.queue_active.is_some()
|| self.dispatch.is_some()
|| self.collaboration.is_some()
|| !self.prompt_presets.is_empty()
|| !self.pipeline.is_empty()
}
pub fn resolve_mode(&self) -> ResolvedMode {
let mut format = AgentDocFormat::Template;
let mut write = AgentDocWrite::Crdt;
if let Some(ref mode_str) = self.mode {
match mode_str.as_str() {
"append" => {
format = AgentDocFormat::Append;
}
"template" => {
format = AgentDocFormat::Template;
}
"stream" => {
format = AgentDocFormat::Template;
write = AgentDocWrite::Crdt;
}
_ => {} }
}
if let Some(f) = self.format {
format = f;
}
if let Some(w) = self.write_mode {
write = w;
}
ResolvedMode { format, write }
}
pub fn resolve_harness_model(&self, harness: &str) -> Option<&str> {
let harness_specific = match harness {
"claude-code" => self.claude_model.as_deref(),
"codex" => self.codex_model.as_deref(),
"opencode" => self.opencode_model.as_deref(),
_ => None,
};
harness_specific.or(self.model.as_deref())
}
pub fn collaboration_mode(&self) -> CollaborationMode {
self.collaboration.unwrap_or_default()
}
pub fn has_security_review(&self) -> bool {
self.security_review
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
}
pub fn normalize_queue_control(fm: &mut Frontmatter) {
if let Some(control) = fm.queue.as_deref().and_then(QueueControl::parse) {
fm.queue_active = Some(control.is_active());
}
}
pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
let Some((yaml, body)) = split_frontmatter(content)? else {
return Ok((Frontmatter::default(), content));
};
let mut fm: Frontmatter = serde_yaml::from_str(yaml)?;
normalize_queue_control(&mut fm);
Ok((fm, body))
}
pub fn contextualize_parse_error(file_display: &str, err: anyhow::Error) -> anyhow::Error {
anyhow::anyhow!(
"invalid YAML frontmatter in {}: {}\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
file_display,
err
)
}
pub struct SshResolverContext<'a> {
pub project: &'a crate::project_config::ProjectConfig,
pub doc_relative: &'a str,
pub file_display: &'a str,
}
pub fn parse_with_ssh_resolver<'a>(
content: &'a str,
ctx: &SshResolverContext<'_>,
) -> Result<(Frontmatter, &'a str)> {
let Some((yaml, body)) = split_frontmatter(content)? else {
let mut fm = Frontmatter::default();
apply_required_ssh_defaults(&mut fm, ctx)?;
return Ok((fm, content));
};
match serde_yaml::from_str(yaml) {
Ok(mut fm) => {
normalize_queue_control(&mut fm);
apply_required_ssh_defaults(&mut fm, ctx)?;
Ok((fm, body))
}
Err(err) => Err(contextualize_yaml_parse_error(ctx.file_display, yaml, err)),
}
}
pub fn ensure_session_with_ssh_resolver(
content: &str,
ctx: &SshResolverContext<'_>,
) -> Result<(String, String)> {
let (mut fm, body) = parse_with_ssh_resolver(content, ctx)?;
if let Some(existing) = fm.session.clone() {
return Ok((content.to_string(), existing));
}
let session_id = Uuid::new_v4().to_string();
fm.session = Some(session_id.clone());
let updated = write(&fm, body)?;
Ok((updated, session_id))
}
pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
let fm = if fm.queue_active.is_some() {
let mut canonical = fm.clone();
if canonical.queue.is_none() {
canonical.queue = Some(
if fm.queue_active == Some(true) {
"start"
} else {
"stop"
}
.to_string(),
);
}
canonical.queue_active = None;
std::borrow::Cow::Owned(canonical)
} else {
std::borrow::Cow::Borrowed(fm)
};
let yaml = serde_yaml::to_string(fm.as_ref())?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.session = Some(session_id.to_string());
write(&fm, body)
}
pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.resume = Some(resume_id.to_string());
write(&fm, body)
}
pub fn set_pipeline_state(
content: &str,
run_id: Option<&str>,
step: Option<&str>,
turn_id: Option<&str>,
queue_task_id: Option<&str>,
) -> Result<String> {
let (mut fm, body) = parse(content)?;
if let Some(v) = run_id {
fm.pipeline.run_id = Some(v.to_string());
}
if let Some(v) = step {
fm.pipeline.step = Some(v.to_string());
}
if let Some(v) = turn_id {
fm.pipeline.turn_id = Some(v.to_string());
}
if let Some(v) = queue_task_id {
fm.pipeline.queue_task_id = Some(v.to_string());
}
write(&fm, body)
}
pub fn clear_pipeline_state(content: &str) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.pipeline = AgentDocPipeline::default();
write(&fm, body)
}
pub fn strip_deprecated_queue_active_line(content: &str) -> String {
let lines: Vec<&str> = content.split('\n').collect();
if lines.first().map(|l| l.trim_end()) != Some("---") {
return content.to_string();
}
let Some(close_idx) = lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim_end() == "---")
.map(|(i, _)| i)
else {
return content.to_string();
};
let fm_lines = &lines[1..close_idx];
let has_canonical_queue = fm_lines
.iter()
.any(|l| l.trim_start().starts_with("queue:"));
let has_deprecated = fm_lines
.iter()
.any(|l| l.trim_start().starts_with("queue_active:"));
if !has_canonical_queue || !has_deprecated {
return content.to_string();
}
let out: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, line)| {
!(*i > 0 && *i < close_idx && line.trim_start().starts_with("queue_active:"))
})
.map(|(_, line)| *line)
.collect();
out.join("\n")
}
pub fn strip_pipeline_block_lines(content: &str) -> String {
let lines: Vec<&str> = content.split('\n').collect();
if lines.first().map(|l| l.trim_end()) != Some("---") {
return content.to_string();
}
let Some(close_idx) = lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim_end() == "---")
.map(|(i, _)| i)
else {
return content.to_string();
};
let mut out: Vec<&str> = Vec::with_capacity(lines.len());
let mut skipping = false;
for (i, line) in lines.iter().enumerate() {
if i == 0 || i >= close_idx {
skipping = false;
out.push(line);
continue;
}
if skipping {
if line.starts_with(' ') || line.starts_with('\t') {
continue;
}
skipping = false;
}
if line.trim_start().starts_with("agent_doc_pipeline:") {
skipping = true;
continue;
}
out.push(line);
}
out.join("\n")
}
pub fn splice_pipeline_block(content: &str, pipeline: &AgentDocPipeline) -> Result<String> {
let stripped = strip_pipeline_block_lines(content);
if pipeline.is_empty() {
return Ok(stripped);
}
#[derive(serde::Serialize)]
struct Wrap<'a> {
agent_doc_pipeline: &'a AgentDocPipeline,
}
let rendered = serde_yaml::to_string(&Wrap {
agent_doc_pipeline: pipeline,
})
.map_err(|e| anyhow::anyhow!("serialize agent_doc_pipeline block: {e}"))?;
let block_lines: Vec<&str> = rendered.trim_end_matches('\n').split('\n').collect();
let lines: Vec<&str> = stripped.split('\n').collect();
if lines.first().map(|l| l.trim_end()) != Some("---") {
return Ok(stripped);
}
let Some(close_idx) = lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim_end() == "---")
.map(|(i, _)| i)
else {
return Ok(stripped);
};
let mut out: Vec<String> = Vec::with_capacity(lines.len() + block_lines.len());
for (i, line) in lines.iter().enumerate() {
if i == close_idx {
for bl in &block_lines {
out.push((*bl).to_string());
}
}
out.push((*line).to_string());
}
Ok(out.join("\n"))
}
pub fn set_format_and_write(
content: &str,
format: AgentDocFormat,
write_mode: AgentDocWrite,
) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.format = Some(format);
fm.write_mode = Some(write_mode);
fm.mode = None;
write(&fm, body)
}
pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
let (mut fm, body) = parse(content)?;
let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
.unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
let mapping = patch
.as_mapping()
.unwrap_or(&serde_yaml::Mapping::new())
.clone();
for (key, value) in &mapping {
let key_str = key.as_str().unwrap_or("");
let val_str = || value.as_str().map(|s| s.to_string());
match key_str {
"agent_doc_session" | "session" => fm.session = val_str(),
"resume" => fm.resume = val_str(),
"agent" => fm.agent = val_str(),
"model" => fm.model = val_str(),
"claude_model" => fm.claude_model = val_str(),
"codex_model" => fm.codex_model = val_str(),
"opencode_model" => fm.opencode_model = val_str(),
"branch" => fm.branch = val_str(),
"tmux_session" => fm.tmux_session = val_str(),
"agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
"agent_doc_format" => {
if let Some(s) = value.as_str()
&& let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
{
fm.format = Some(f);
}
}
"agent_doc_write" => {
if let Some(s) = value.as_str()
&& let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
{
fm.write_mode = Some(w);
}
}
"pending_capture_guard" => {
if let Some(s) = value.as_str()
&& let Ok(mode) =
serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
{
fm.pending_capture_guard = Some(mode);
}
}
"pending_done_guard" => {
if let Some(s) = value.as_str()
&& let Ok(mode) =
serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
{
fm.pending_done_guard = Some(mode);
}
}
"review_done_guard" => {
if let Some(s) = value.as_str()
&& let Ok(mode) =
serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
{
fm.review_done_guard = Some(mode);
}
}
"auto_done" => {
if let Some(enabled) = value.as_bool() {
fm.auto_done = Some(enabled);
}
}
"prompt_presets" => {
if let Ok(presets) =
serde_yaml::from_value::<indexmap::IndexMap<String, String>>(value.clone())
{
fm.prompt_presets = presets;
}
}
"agent_args" => fm.agent_args = val_str(),
"claude_args" => fm.claude_args = val_str(),
"codex_args" => fm.codex_args = val_str(),
"opencode_args" => fm.opencode_args = val_str(),
"codex_network_access" => {
if let Some(s) = value.as_str()
&& let Ok(mode) =
serde_yaml::from_str::<CodexNetworkAccess>(&format!("\"{}\"", s))
{
fm.codex_network_access = Some(mode);
}
}
"required_ssh_targets" => {
if let Ok(targets) = serde_yaml::from_value::<Vec<String>>(value.clone()) {
fm.required_ssh_targets = targets;
}
}
"required_ssh_profile" => fm.required_ssh_profile = val_str(),
"queue" => {
fm.queue = val_str();
normalize_queue_control(&mut fm);
}
"queue_active" => {
fm.queue_active = value.as_bool();
}
"agent_doc_collaboration" => {
if let Some(s) = value.as_str()
&& let Ok(mode) =
serde_yaml::from_str::<CollaborationMode>(&format!("\"{}\"", s))
{
fm.collaboration = Some(mode);
}
}
"agent_doc_security_review" => fm.security_review = val_str(),
_ => {
eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
}
}
}
write(&fm, body)
}
pub fn merge_queue_state(content: &str, active: bool) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.queue = Some(if active { "start" } else { "stop" }.to_string());
fm.queue_active = None;
write(&fm, body)
}
pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
let (mut fm, body) = parse(content)?;
fm.tmux_session = Some(session_name.to_string());
write(&fm, body)
}
pub fn ensure_session(content: &str) -> Result<(String, String)> {
let (fm, _body) = parse(content)?;
if let Some(ref session_id) = fm.session {
return Ok((content.to_string(), session_id.clone()));
}
let session_id = Uuid::new_v4().to_string();
let updated = set_session_id(content, &session_id)?;
Ok((updated, session_id))
}
fn split_frontmatter(content: &str) -> Result<Option<(&str, &str)>> {
let (opening_end, rest) = if let Some(rest) = content.strip_prefix("---\n") {
(4, rest)
} else {
let without_bom = content.strip_prefix('\u{feff}').unwrap_or(content);
let bom_len = content.len() - without_bom.len();
let horizontal_ws_len = without_bom
.bytes()
.take_while(|byte| matches!(byte, b' ' | b'\t'))
.count();
let marker_start = bom_len + horizontal_ws_len;
if (bom_len == 0 && horizontal_ws_len == 0) || !content[marker_start..].starts_with("---\n")
{
return Ok(None);
}
(marker_start + 4, &content[marker_start + 4..])
};
let (end, closing_len) = rest
.find("\n---\n")
.map(|end| (end, 5))
.or_else(|| rest.find("\n---").map(|end| (end, 4)))
.ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
let yaml = &rest[..end];
let body_start = opening_end + end + closing_len; let body = if body_start <= content.len() {
&content[body_start..]
} else {
""
};
Ok(Some((yaml, body)))
}
pub fn apply_required_ssh_defaults(
fm: &mut Frontmatter,
ctx: &SshResolverContext<'_>,
) -> Result<()> {
if !fm.required_ssh_targets.is_empty() {
fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
return Ok(());
}
if ctx.doc_relative.is_empty() {
return Ok(());
}
let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
} else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
let mut targets = doc_cfg.targets.clone();
if let Some(profile_name) = doc_cfg.profile.as_deref() {
targets.extend(resolve_required_ssh_profile_targets(
&ctx.project.ssh,
profile_name,
ctx.file_display,
)?);
}
if targets.is_empty() {
anyhow::bail!(
"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>].",
ctx.file_display,
ctx.doc_relative,
ctx.doc_relative
);
}
targets
} else {
Vec::new()
};
fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
Ok(())
}
fn resolve_required_ssh_profile_targets(
ssh: &crate::project_config::SshConfig,
profile_name: &str,
file_display: &str,
) -> Result<Vec<String>> {
let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
anyhow::anyhow!(
"document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
file_display,
profile_name,
profile_name
)
})?;
if profile.targets.is_empty() {
anyhow::bail!(
"document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
file_display,
profile_name,
profile_name
);
}
Ok(profile.targets.clone())
}
fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for target in targets {
let trimmed = target.trim();
if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
continue;
}
normalized.push(trimmed.to_string());
}
normalized
}
fn contextualize_yaml_parse_error(
file_display: &str,
yaml: &str,
err: serde_yaml::Error,
) -> anyhow::Error {
let excerpt = err
.location()
.and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
if let Some(excerpt) = excerpt {
message.push_str("\n\nFrontmatter excerpt:\n");
message.push_str(&excerpt);
}
message.push_str(
"\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
);
anyhow::anyhow!(message)
}
fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
let lines: Vec<&str> = yaml.lines().collect();
if line == 0 || line > lines.len() {
return None;
}
let start = line.saturating_sub(1).max(1);
let end = (line + 1).min(lines.len());
let width = end.to_string().len();
let mut rendered = Vec::new();
for current in start..=end {
let marker = if current == line { '>' } else { ' ' };
rendered.push(format!(
"{} {:>width$} | {}",
marker,
current,
lines[current - 1],
width = width
));
if current == line {
rendered.push(format!(
" {:>width$} | {}^",
"",
" ".repeat(column.saturating_sub(1)),
width = width
));
}
}
Some(rendered.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_no_frontmatter() {
let content = "# Hello\n\nBody text.\n";
let (fm, body) = parse(content).unwrap();
assert!(fm.session.is_none());
assert!(fm.agent.is_none());
assert!(fm.model.is_none());
assert!(fm.branch.is_none());
assert_eq!(body, content);
}
#[test]
fn parse_tolerates_leading_horizontal_space_before_frontmatter() {
let content = " ---\nagent_doc_session: abc-123\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc-123"));
assert_eq!(body, "Body\n");
}
#[test]
fn parse_tolerates_bom_before_frontmatter() {
let content = "\u{feff}---\nagent_doc_session: abc-123\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc-123"));
assert_eq!(body, "Body\n");
}
#[test]
fn parse_keeps_leading_blank_line_as_body_not_frontmatter() {
let content = "\n---\nagent_doc_session: abc-123\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert!(fm.session.is_none());
assert_eq!(body, content);
}
#[test]
fn parse_all_fields() {
let content =
"---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc-123"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert_eq!(fm.model.as_deref(), Some("opus"));
assert_eq!(fm.branch.as_deref(), Some("main"));
assert!(body.contains("Body"));
}
#[test]
fn queue_control_parse_aliases() {
assert_eq!(QueueControl::parse("start"), Some(QueueControl::Start));
assert_eq!(QueueControl::parse("go"), Some(QueueControl::Start));
assert_eq!(QueueControl::parse("STOP"), Some(QueueControl::Stop));
assert_eq!(QueueControl::parse(" Start "), Some(QueueControl::Start));
assert_eq!(QueueControl::parse("auto"), None);
assert_eq!(QueueControl::parse(""), None);
assert!(QueueControl::Start.is_active());
assert!(!QueueControl::Stop.is_active());
}
#[test]
fn parse_queue_start_activates() {
let (fm, _) = parse("---\nagent_doc_format: template\nqueue: start\n---\n\n").unwrap();
assert_eq!(fm.queue.as_deref(), Some("start"));
assert_eq!(fm.queue_active, Some(true));
}
#[test]
fn parse_queue_go_alias_activates() {
let (fm, _) = parse("---\nqueue: go\n---\n\n").unwrap();
assert_eq!(fm.queue_active, Some(true));
}
#[test]
fn parse_queue_stop_deactivates() {
let (fm, _) = parse("---\nqueue: stop\n---\n\n").unwrap();
assert_eq!(fm.queue_active, Some(false));
}
#[test]
fn parse_queue_canonical_wins_over_stale_queue_active() {
let (fm, _) = parse("---\nqueue: stop\nqueue_active: true\n---\n\n").unwrap();
assert_eq!(fm.queue_active, Some(false));
let (fm, _) = parse("---\nqueue: start\nqueue_active: false\n---\n\n").unwrap();
assert_eq!(fm.queue_active, Some(true));
}
#[test]
fn pipeline_is_empty_default() {
let p = AgentDocPipeline::default();
assert!(p.is_empty());
}
#[test]
fn parse_pipeline_all_fields() {
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";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-1780600334196"));
assert_eq!(fm.pipeline.step.as_deref(), Some("response_captured"));
assert_eq!(
fm.pipeline.queue_task_id.as_deref(),
Some("#fm-run-id-step")
);
assert!(!fm.pipeline.is_empty());
}
#[test]
fn empty_pipeline_is_not_serialized() {
let fm = Frontmatter {
session: Some("abc".to_string()),
..Default::default()
};
let out = write(&fm, "Body\n").unwrap();
assert!(!out.contains("agent_doc_pipeline"));
}
#[test]
fn set_pipeline_state_merges_without_disturbing_other_fields() {
let content =
"---\nagent_doc_session: abc-123\nagent: claude\nqueue: start\n---\n\n## Body\n";
let updated =
set_pipeline_state(content, Some("cycle-99"), Some("write_applied"), None, None)
.unwrap();
let (fm, body) = parse(&updated).unwrap();
assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
assert_eq!(fm.pipeline.step.as_deref(), Some("write_applied"));
assert!(fm.pipeline.turn_id.is_none());
assert_eq!(fm.session.as_deref(), Some("abc-123"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert_eq!(fm.queue_active, Some(true));
assert!(body.contains("## Body"));
let updated2 = set_pipeline_state(
&updated,
None,
Some("committed"),
Some("#fm-run-id-step"),
None,
)
.unwrap();
let (fm2, _) = parse(&updated2).unwrap();
assert_eq!(fm2.pipeline.run_id.as_deref(), Some("cycle-99"));
assert_eq!(fm2.pipeline.step.as_deref(), Some("committed"));
assert_eq!(fm2.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
}
#[test]
fn splice_pipeline_block_is_byte_precise_and_strip_cancels() {
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";
let pipeline = AgentDocPipeline {
run_id: Some("cycle-99".into()),
step: Some("response_captured".into()),
turn_id: Some("#22a8".into()),
queue_task_id: None,
};
let spliced = splice_pipeline_block(base, &pipeline).unwrap();
assert!(spliced.contains("agent_doc_pipeline:"));
let (fm, _) = parse(&spliced).unwrap();
assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#22a8"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert_eq!(strip_pipeline_block_lines(&spliced), base);
assert_eq!(
splice_pipeline_block(&spliced, &AgentDocPipeline::default()).unwrap(),
base
);
let twice = splice_pipeline_block(&spliced, &pipeline).unwrap();
assert_eq!(twice.matches("agent_doc_pipeline:").count(), 1);
}
#[test]
fn clear_pipeline_state_drops_block_only() {
let content = "---\nagent_doc_session: abc-123\nagent_doc_pipeline:\n run_id: cycle-1\n step: response_captured\n---\n\nBody\n";
let cleared = clear_pipeline_state(content).unwrap();
assert!(!cleared.contains("agent_doc_pipeline"));
let (fm, body) = parse(&cleared).unwrap();
assert!(fm.pipeline.is_empty());
assert_eq!(fm.session.as_deref(), Some("abc-123"));
assert!(body.contains("Body"));
}
#[test]
fn parse_queue_unknown_value_leaves_queue_active_untouched() {
let (fm, _) = parse("---\nqueue: bogus\nqueue_active: true\n---\n\n").unwrap();
assert_eq!(fm.queue_active, Some(true));
let (fm, _) = parse("---\nqueue: bogus\n---\n\n").unwrap();
assert_eq!(fm.queue_active, None);
}
#[test]
fn write_emits_canonical_queue_for_direct_set_queue_active() {
let mut fm = Frontmatter {
queue_active: Some(true),
..Default::default()
};
let out = write(&fm, "body\n").unwrap();
assert!(out.contains("queue: start"), "{out}");
assert!(!out.contains("queue_active:"), "{out}");
fm.queue_active = Some(false);
let out = write(&fm, "body\n").unwrap();
assert!(out.contains("queue: stop"), "{out}");
assert!(!out.contains("queue_active:"), "{out}");
let (fm, body) =
parse("---\nagent_doc_format: template\nqueue: start\n---\n\nx\n").unwrap();
let out = write(&fm, &body).unwrap();
assert!(out.contains("queue: start"), "{out}");
assert!(!out.contains("queue_active:"), "{out}");
let fm = Frontmatter::default();
let out = write(&fm, "body\n").unwrap();
assert!(
!out.contains("queue_active:") && !out.contains("queue:"),
"{out}"
);
}
#[test]
fn merge_queue_state_writes_canonical_and_drops_legacy() {
let legacy = "---\nagent_doc_format: template\nqueue_active: true\n---\n\nbody\n";
let active = merge_queue_state(legacy, true).unwrap();
assert!(active.contains("queue: start"), "{active}");
assert!(!active.contains("queue_active:"), "{active}");
let stopped = merge_queue_state(legacy, false).unwrap();
assert!(stopped.contains("queue: stop"), "{stopped}");
assert!(!stopped.contains("queue_active:"), "{stopped}");
let (fm, _) = parse(&active).unwrap();
assert_eq!(fm.queue_active, Some(true));
let (fm, _) = parse(&stopped).unwrap();
assert_eq!(fm.queue_active, Some(false));
}
#[test]
fn strip_deprecated_queue_active_line_drops_legacy_when_canonical_present() {
let stuck = "---\nagent: claude\nqueue: start\nqueue_active: true\n---\n\nbody\n";
let out = strip_deprecated_queue_active_line(stuck);
assert_eq!(
out, "---\nagent: claude\nqueue: start\n---\n\nbody\n",
"legacy line dropped, canonical queue + other fields preserved"
);
assert_eq!(strip_deprecated_queue_active_line(&out), out);
}
#[test]
fn strip_deprecated_queue_active_line_keeps_legacy_without_canonical() {
let legacy_only = "---\nqueue_active: true\n---\n\nbody\n";
assert_eq!(strip_deprecated_queue_active_line(legacy_only), legacy_only);
assert_eq!(
strip_deprecated_queue_active_line("no frontmatter\nqueue_active: true\n"),
"no frontmatter\nqueue_active: true\n"
);
}
#[test]
fn parse_legacy_queue_active_still_honored() {
let (fm, _) = parse("---\nqueue_active: true\n---\n\n").unwrap();
assert_eq!(fm.queue.as_deref(), None);
assert_eq!(fm.queue_active, Some(true));
}
#[test]
fn parse_partial_fields() {
let content = "---\nsession: xyz\n---\n# Doc\n";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("xyz"));
assert!(fm.agent.is_none());
assert!(body.contains("# Doc"));
}
#[test]
fn parse_model_tier_high() {
let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model_tier, Some(Tier::High));
}
#[test]
fn parse_collaboration_mode_shared() {
let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
}
#[test]
fn parse_security_review_roundtrip() {
let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
assert!(fm.has_security_review());
let written = write(&fm, "Body\n").unwrap();
assert!(written.contains("agent_doc_collaboration: shared"));
assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
}
#[test]
fn parse_model_tier_low() {
let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model_tier, Some(Tier::Low));
}
#[test]
fn parse_model_tier_med() {
let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model_tier, Some(Tier::Med));
}
#[test]
fn parse_model_tier_auto() {
let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model_tier, Some(Tier::Auto));
}
#[test]
fn parse_model_tier_absent() {
let content = "---\nagent: claude\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model_tier, None);
}
#[test]
fn parse_pending_capture_guard_strict() {
let content = "---\npending_capture_guard: strict\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(
fm.pending_capture_guard,
Some(PendingCaptureGuardMode::Strict)
);
}
#[test]
fn parse_pending_capture_guard_invalid_rejected() {
let content = "---\npending_capture_guard: loudly\n---\nBody\n";
assert!(parse(content).is_err());
}
#[test]
fn parse_pending_done_guard_strict() {
let content = "---\npending_done_guard: strict\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
}
#[test]
fn parse_pending_done_guard_invalid_rejected() {
let content = "---\npending_done_guard: loudly\n---\nBody\n";
assert!(parse(content).is_err());
}
#[test]
fn parse_model_tier_invalid_rejected() {
let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
let result = parse(content);
assert!(result.is_err(), "invalid tier value should fail to parse");
}
#[test]
fn write_model_tier_roundtrip() {
let fm = Frontmatter {
model_tier: Some(Tier::High),
..Default::default()
};
let doc = write(&fm, "Body\n").unwrap();
let (parsed, _) = parse(&doc).unwrap();
assert_eq!(parsed.model_tier, Some(Tier::High));
assert!(doc.contains("agent_doc_model_tier: high"));
}
#[test]
fn parse_null_fields() {
let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert!(fm.session.is_none());
assert!(fm.agent.is_none());
assert!(fm.model.is_none());
assert!(fm.branch.is_none());
assert!(body.contains("Body"));
}
#[test]
fn parse_unterminated_frontmatter() {
let content = "---\nsession: abc\nno closing block";
let err = parse(content).unwrap_err();
assert!(err.to_string().contains("Unterminated frontmatter"));
}
#[test]
fn parse_closing_at_eof() {
let content = "---\nsession: abc\n---";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc"));
assert_eq!(body, "");
}
#[test]
fn parse_empty_body() {
let content = "---\nsession: abc\n---\n";
let (fm, _body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc"));
}
#[test]
fn write_roundtrip() {
let fm = Frontmatter {
session: Some("test-id".to_string()),
resume: Some("resume-id".to_string()),
agent: Some("claude".to_string()),
model: Some("opus".to_string()),
claude_model: None,
codex_model: None,
opencode_model: None,
branch: Some("dev".to_string()),
tmux_session: None,
mode: None,
format: None,
write_mode: None,
stream_config: None,
agent_args: None,
claude_args: None,
codex_args: None,
opencode_args: None,
codex_network_access: None,
required_ssh_targets: Vec::new(),
required_ssh_profile: None,
managed_proof_max_attempts: None,
managed_proof_retry_backoff_secs: None,
managed_proof_probe_timeout_secs: None,
no_mcp: None,
enable_tool_search: None,
debounce_ms: None,
links: vec![],
auto_compact: None,
queue_context_reset: None,
gate_autoverify: None,
clear_threshold: None,
supervisor_auto_recycle: None,
agent_change_restart: None,
supervisor_auto_install: None,
model_tier: None,
pending_capture_guard: None,
pending_done_guard: None,
review_done_guard: None,
auto_done: None,
agent_doc_lint_dialect: None,
hooks: std::collections::HashMap::new(),
env: indexmap::IndexMap::new(),
prompt_presets: indexmap::IndexMap::new(),
dispatch: None,
agent_doc_env_inherit: None,
cwd: None,
queue: None,
queue_active: None,
queue_prompt_echo_max_chars: None,
collaboration: None,
security_review: None,
pipeline: AgentDocPipeline::default(),
};
let body = "# Hello\n\nBody text.\n";
let written = write(&fm, body).unwrap();
let (fm2, body2) = parse(&written).unwrap();
assert_eq!(fm2.session, fm.session);
assert_eq!(fm2.agent, fm.agent);
assert_eq!(fm2.model, fm.model);
assert_eq!(fm2.claude_model, fm.claude_model);
assert_eq!(fm2.codex_model, fm.codex_model);
assert_eq!(fm2.opencode_model, fm.opencode_model);
assert_eq!(fm2.branch, fm.branch);
assert_eq!(fm2.collaboration, fm.collaboration);
assert_eq!(fm2.security_review, fm.security_review);
assert_eq!(body2, body);
}
#[test]
fn repeated_parse_write_roundtrip_preserves_body_prefix() {
let fm = Frontmatter {
session: Some("abc".to_string()),
agent: Some("codex".to_string()),
..Default::default()
};
let original = write(&fm, "## Status\n\nBody\n").unwrap();
let mut current = original.clone();
for _ in 0..3 {
let (fm, body) = parse(¤t).unwrap();
assert_eq!(body, "## Status\n\nBody\n");
current = write(&fm, body).unwrap();
}
let (fm, body) = parse(¤t).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc"));
assert_eq!(fm.agent.as_deref(), Some("codex"));
assert_eq!(body, "## Status\n\nBody\n");
assert_eq!(current, original);
}
#[test]
fn write_default_frontmatter() {
let fm = Frontmatter::default();
let result = write(&fm, "body\n").unwrap();
assert!(result.starts_with("---\n"));
assert!(result.ends_with("---\nbody\n"));
}
#[test]
fn write_preserves_body_content() {
let fm = Frontmatter::default();
let body = "# Title\n\nSome **markdown** with `code`.\n";
let result = write(&fm, body).unwrap();
assert!(result.contains("# Title"));
assert!(result.contains("Some **markdown** with `code`."));
}
#[test]
fn set_session_id_creates_frontmatter() {
let content = "# No frontmatter\n\nJust body.\n";
let result = set_session_id(content, "new-session").unwrap();
let (fm, body) = parse(&result).unwrap();
assert_eq!(fm.session.as_deref(), Some("new-session"));
assert!(body.contains("# No frontmatter"));
}
#[test]
fn set_session_id_updates_existing() {
let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
let result = set_session_id(content, "new-id").unwrap();
let (fm, body) = parse(&result).unwrap();
assert_eq!(fm.session.as_deref(), Some("new-id"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert!(body.contains("Body"));
}
#[test]
fn set_session_id_preserves_other_fields() {
let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
let result = set_session_id(content, "new").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.session.as_deref(), Some("new"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert_eq!(fm.model.as_deref(), Some("opus"));
assert_eq!(fm.branch.as_deref(), Some("dev"));
}
#[test]
fn ensure_session_no_frontmatter() {
let content = "# Hello\n\nBody.\n";
let (updated, sid) = ensure_session(content).unwrap();
assert_eq!(sid.len(), 36); let (fm, body) = parse(&updated).unwrap();
assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
assert!(body.contains("# Hello"));
}
#[test]
fn ensure_session_null_session() {
let content = "---\nsession:\nagent: claude\n---\nBody\n";
let (updated, sid) = ensure_session(content).unwrap();
assert_eq!(sid.len(), 36);
let (fm, body) = parse(&updated).unwrap();
assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert!(body.contains("Body"));
}
#[test]
fn ensure_session_existing_session() {
let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
let (updated, sid) = ensure_session(content).unwrap();
assert_eq!(sid, "existing-id");
assert_eq!(updated, content);
}
#[test]
fn parse_legacy_session_field() {
let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
let (fm, body) = parse(content).unwrap();
assert_eq!(fm.session.as_deref(), Some("legacy-id"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert!(body.contains("Body"));
}
#[test]
fn parse_agent_doc_mode_canonical() {
let content = "---\nagent_doc_mode: template\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.mode.as_deref(), Some("template"));
}
#[test]
fn parse_mode_shorthand_alias() {
let content = "---\nmode: template\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.mode.as_deref(), Some("template"));
}
#[test]
fn parse_response_mode_legacy_alias() {
let content = "---\nresponse_mode: template\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.mode.as_deref(), Some("template"));
}
#[test]
fn write_uses_agent_doc_mode_field() {
#[allow(deprecated)]
let fm = Frontmatter {
mode: Some("template".to_string()),
..Default::default()
};
let result = write(&fm, "body\n").unwrap();
assert!(result.contains("agent_doc_mode:"));
assert!(!result.contains("response_mode:"));
assert!(!result.contains("\nmode:"));
}
#[test]
fn write_uses_new_field_name() {
let fm = Frontmatter {
session: Some("test-id".to_string()),
..Default::default()
};
let result = write(&fm, "body\n").unwrap();
assert!(result.contains("agent_doc_session:"));
assert!(!result.contains("\nsession:"));
}
#[test]
fn write_pending_capture_guard_roundtrip() {
let fm = Frontmatter {
pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
assert!(written.contains("pending_capture_guard: strict"));
let (parsed, _) = parse(&written).unwrap();
assert_eq!(
parsed.pending_capture_guard,
Some(PendingCaptureGuardMode::Strict)
);
}
#[test]
fn write_pending_done_guard_roundtrip() {
let fm = Frontmatter {
pending_done_guard: Some(PendingCaptureGuardMode::Strict),
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
assert!(written.contains("pending_done_guard: strict"));
let (parsed, _) = parse(&written).unwrap();
assert_eq!(
parsed.pending_done_guard,
Some(PendingCaptureGuardMode::Strict)
);
}
#[test]
fn parse_review_done_guard_error_alias() {
let content = "---\nreview_done_guard: error\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
}
#[test]
fn write_review_done_guard_roundtrip() {
let fm = Frontmatter {
review_done_guard: Some(PendingCaptureGuardMode::Strict),
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
assert!(written.contains("review_done_guard: strict"));
let (parsed, _) = parse(&written).unwrap();
assert_eq!(
parsed.review_done_guard,
Some(PendingCaptureGuardMode::Strict)
);
}
#[test]
fn parse_auto_done_bool() {
let content = "---\nauto_done: true\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.auto_done, Some(true));
}
#[test]
fn write_auto_done_roundtrip() {
let fm = Frontmatter {
auto_done: Some(true),
..Default::default()
};
let written = write(&fm, "Body\n").unwrap();
assert!(written.contains("auto_done: true"));
let (parsed, _) = parse(&written).unwrap();
assert_eq!(parsed.auto_done, Some(true));
}
#[test]
fn parse_prompt_presets_roundtrip() {
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";
let (fm, body) = parse(content).unwrap();
assert_eq!(body, "Body\n");
assert_eq!(
fm.prompt_presets.get("#1").map(String::as_str),
Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
);
assert_eq!(
fm.prompt_presets.get("release-check").map(String::as_str),
Some("Run cargo test.")
);
let written = write(&fm, body).unwrap();
let (parsed, body2) = parse(&written).unwrap();
assert_eq!(body2, "Body\n");
assert_eq!(parsed.prompt_presets, fm.prompt_presets);
}
#[test]
fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
let mut prompt_presets = indexmap::IndexMap::new();
prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
assert_eq!(
resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
Some("#spec-test")
);
assert_eq!(
resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
Some("#spec-test")
);
assert_eq!(
resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
Some("release-check")
);
assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
}
#[test]
fn resolve_mode_defaults() {
let fm = Frontmatter::default();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Template);
assert_eq!(resolved.write, AgentDocWrite::Crdt);
}
#[test]
fn resolve_mode_from_deprecated_append() {
let content = "---\nagent_doc_mode: append\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Append);
assert_eq!(resolved.write, AgentDocWrite::Crdt);
}
#[test]
fn resolve_mode_from_deprecated_template() {
let content = "---\nagent_doc_mode: template\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Template);
assert_eq!(resolved.write, AgentDocWrite::Crdt);
}
#[test]
fn resolve_mode_from_deprecated_stream() {
let content = "---\nagent_doc_mode: stream\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Template);
assert_eq!(resolved.write, AgentDocWrite::Crdt);
}
#[test]
fn resolve_mode_new_fields_override_deprecated() {
let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Template);
assert_eq!(resolved.write, AgentDocWrite::Merge);
}
#[test]
fn resolve_mode_explicit_new_fields_only() {
let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Append);
assert_eq!(resolved.write, AgentDocWrite::Crdt);
}
#[test]
fn resolve_mode_partial_new_field_format_only() {
let content = "---\nagent_doc_format: append\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Append);
assert_eq!(resolved.write, AgentDocWrite::Crdt); }
#[test]
fn resolve_mode_partial_new_field_write_only() {
let content = "---\nagent_doc_write: merge\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
let resolved = fm.resolve_mode();
assert_eq!(resolved.format, AgentDocFormat::Template); assert_eq!(resolved.write, AgentDocWrite::Merge);
}
#[test]
fn resolve_mode_helper_methods() {
let fm = Frontmatter::default();
let resolved = fm.resolve_mode();
assert!(resolved.is_template());
assert!(!resolved.is_append());
assert!(resolved.is_crdt());
}
#[test]
fn parse_new_format_field() {
let content = "---\nagent_doc_format: template\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.format, Some(AgentDocFormat::Template));
}
#[test]
fn parse_new_write_field() {
let content = "---\nagent_doc_write: crdt\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
}
#[test]
fn write_uses_new_format_write_fields() {
let fm = Frontmatter {
format: Some(AgentDocFormat::Template),
write_mode: Some(AgentDocWrite::Crdt),
..Default::default()
};
let result = write(&fm, "body\n").unwrap();
assert!(result.contains("agent_doc_format:"));
assert!(result.contains("agent_doc_write:"));
assert!(!result.contains("agent_doc_mode:"));
}
#[test]
fn set_format_and_write_clears_deprecated_mode() {
let content = "---\nagent_doc_mode: stream\n---\nBody\n";
let result =
set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
let (fm, _) = parse(&result).unwrap();
assert!(fm.mode.is_none());
assert_eq!(fm.format, Some(AgentDocFormat::Template));
assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
}
#[test]
fn merge_fields_adds_new_field() {
let content = "---\nagent_doc_session: abc\n---\nBody\n";
let result = merge_fields(content, "model: opus").unwrap();
let (fm, body) = parse(&result).unwrap();
assert_eq!(fm.session.as_deref(), Some("abc"));
assert_eq!(fm.model.as_deref(), Some("opus"));
assert!(body.contains("Body"));
}
#[test]
fn merge_fields_updates_existing_field() {
let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
let result = merge_fields(content, "model: opus").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("opus"));
assert_eq!(fm.session.as_deref(), Some("abc"));
}
#[test]
fn merge_fields_multiple_fields() {
let content = "---\nagent_doc_session: abc\n---\nBody\n";
let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("opus"));
assert_eq!(fm.agent.as_deref(), Some("claude"));
assert_eq!(fm.branch.as_deref(), Some("main"));
}
#[test]
fn merge_fields_format_enum() {
let content = "---\nagent_doc_session: abc\n---\nBody\n";
let result = merge_fields(content, "agent_doc_format: append").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.format, Some(AgentDocFormat::Append));
}
#[test]
fn merge_fields_write_enum() {
let content = "---\nagent_doc_session: abc\n---\nBody\n";
let result = merge_fields(content, "agent_doc_write: merge").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
}
#[test]
fn merge_fields_ignores_unknown() {
let content = "---\nagent_doc_session: abc\n---\nBody\n";
let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("opus"));
}
#[test]
fn merge_fields_preserves_body() {
let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
let result = merge_fields(content, "model: opus").unwrap();
assert!(result.contains("# Title"));
assert!(result.contains("Some **markdown** content."));
}
#[test]
fn set_format_and_write_clears_deprecated() {
let content = "---\nagent_doc_mode: append\n---\nBody\n";
let result =
set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
let (fm, _) = parse(&result).unwrap();
assert!(fm.mode.is_none());
assert_eq!(fm.format, Some(AgentDocFormat::Template));
assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
}
#[test]
fn hooks_roundtrip() {
let content = "---\nhooks:\n session_start:\n - \"echo start {{session_id}}\"\n post_write:\n - \"notify {{file}}\"\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(
fm.hooks.get("session_start"),
Some(&vec!["echo start {{session_id}}".to_string()])
);
assert_eq!(
fm.hooks.get("post_write"),
Some(&vec!["notify {{file}}".to_string()])
);
}
#[test]
fn hooks_omitted_when_empty() {
let fm = Frontmatter::default();
let result = write(&fm, "body\n").unwrap();
assert!(!result.contains("hooks"));
}
#[test]
fn hooks_absent_parses_as_empty() {
let content = "---\nsession: abc\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert!(fm.hooks.is_empty());
}
#[test]
fn parse_no_mcp_field() {
let content = "---\nno_mcp: true\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.no_mcp, Some(true));
}
#[test]
fn parse_enable_tool_search_field() {
let content = "---\nenable_tool_search: true\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.enable_tool_search, Some(true));
}
#[test]
fn parse_missing_flags_default_none() {
let content = "---\nsession: abc\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert!(fm.no_mcp.is_none());
assert!(fm.enable_tool_search.is_none());
}
#[test]
fn parse_env_map() {
let content = "---\nenv:\n FOO: bar\n BAZ: \"$(echo hello)\"\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.env.len(), 2);
assert_eq!(fm.env["FOO"], Some("bar".to_string()));
assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
let keys: Vec<&String> = fm.env.keys().collect();
assert_eq!(keys, vec!["FOO", "BAZ"]);
}
#[test]
fn parse_env_empty_default() {
let content = "---\nsession: abc\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert!(fm.env.is_empty());
}
#[test]
fn parse_env_unset_via_null() {
let content = "---\nenv:\n SET_ME: value\n UNSET_ME: null\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.env.len(), 2);
assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
assert_eq!(fm.env["UNSET_ME"], None);
}
#[test]
fn write_roundtrip_with_env() {
let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
env.insert("KEY1".to_string(), Some("value1".to_string()));
env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
env.insert("KEY3".to_string(), None);
let fm = Frontmatter {
env,
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
let (fm2, _) = parse(&written).unwrap();
assert_eq!(fm2.env.len(), 3);
assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
assert_eq!(fm2.env["KEY3"], None);
}
#[test]
fn parse_agent_args_field() {
let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
assert!(fm.claude_args.is_none());
assert!(fm.codex_args.is_none());
assert!(fm.opencode_args.is_none());
}
#[test]
fn parse_agent_args_and_harness_aliases() {
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";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.agent_args.as_deref(), Some("--json"));
assert_eq!(
fm.claude_args.as_deref(),
Some("--dangerously-skip-permissions")
);
assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
assert_eq!(
fm.opencode_args.as_deref(),
Some("--dangerously-skip-permissions")
);
assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
assert_eq!(
fm.required_ssh_targets,
vec!["monsterrodholders-server".to_string()]
);
}
#[test]
fn write_roundtrip_agent_args() {
let fm = Frontmatter {
agent_args: Some("--json -s workspace-write".to_string()),
codex_args: Some("-s danger-full-access".to_string()),
opencode_args: Some("--dangerously-skip-permissions".to_string()),
codex_network_access: Some(CodexNetworkAccess::Enabled),
required_ssh_targets: vec!["monsterrodholders-server".to_string()],
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
let (fm2, _) = parse(&written).unwrap();
assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
assert_eq!(
fm2.opencode_args.as_deref(),
Some("--dangerously-skip-permissions")
);
assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
assert_eq!(
fm2.required_ssh_targets,
vec!["monsterrodholders-server".to_string()]
);
}
#[test]
fn merge_fields_agent_args() {
let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
let result = merge_fields(content, "agent_args: new").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.agent_args.as_deref(), Some("new"));
assert_eq!(fm.claude_args.as_deref(), Some("old"));
assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
}
#[test]
fn merge_fields_codex_args() {
let content = "---\nagent_args: old\n---\nBody\n";
let result = merge_fields(content, "codex_args: new").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.agent_args.as_deref(), Some("old"));
assert_eq!(fm.codex_args.as_deref(), Some("new"));
}
#[test]
fn merge_fields_opencode_args() {
let content = "---\nagent_args: old\n---\nBody\n";
let result = merge_fields(content, "opencode_args: new").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.agent_args.as_deref(), Some("old"));
assert_eq!(fm.opencode_args.as_deref(), Some("new"));
}
#[test]
fn merge_fields_codex_network_access() {
let content = "Body\n";
let result = merge_fields(content, "codex_network_access: disabled").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
}
#[test]
fn merge_fields_required_ssh_targets() {
let content = "Body\n";
let result = merge_fields(
content,
"required_ssh_targets:\n - monsterrodholders-server\n - root@50.28.2.199",
)
.unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(
fm.required_ssh_targets,
vec![
"monsterrodholders-server".to_string(),
"root@50.28.2.199".to_string(),
]
);
}
#[test]
fn merge_fields_required_ssh_profile() {
let content = "Body\n";
let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(
fm.required_ssh_profile.as_deref(),
Some("monsterrodholders")
);
}
#[test]
fn resolve_harness_model_claude_code_uses_claude_model() {
let fm = Frontmatter {
model: Some("gpt-5".to_string()),
claude_model: Some("opus".to_string()),
codex_model: Some("o3-pro".to_string()),
..Default::default()
};
assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
}
#[test]
fn resolve_harness_model_codex_uses_codex_model() {
let fm = Frontmatter {
model: Some("gpt-5".to_string()),
claude_model: Some("opus".to_string()),
codex_model: Some("o3-pro".to_string()),
opencode_model: Some("zai/glm-5".to_string()),
..Default::default()
};
assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
}
#[test]
fn resolve_harness_model_opencode_uses_opencode_model() {
let fm = Frontmatter {
model: Some("gpt-5".to_string()),
claude_model: Some("opus".to_string()),
codex_model: Some("o3-pro".to_string()),
opencode_model: Some("zai/glm-5".to_string()),
..Default::default()
};
assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
}
#[test]
fn resolve_harness_model_falls_back_to_generic() {
let fm = Frontmatter {
model: Some("gpt-5".to_string()),
..Default::default()
};
assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
}
#[test]
fn resolve_harness_model_none_when_no_model() {
let fm = Frontmatter::default();
assert_eq!(fm.resolve_harness_model("claude-code"), None);
}
#[test]
fn resolve_harness_model_unknown_harness_uses_generic() {
let fm = Frontmatter {
model: Some("sonnet".to_string()),
claude_model: Some("opus".to_string()),
..Default::default()
};
assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
}
#[test]
fn parse_harness_model_fields() {
let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
let (fm, _) = parse(content).unwrap();
assert_eq!(fm.model.as_deref(), Some("gpt-5"));
assert_eq!(fm.claude_model.as_deref(), Some("opus"));
assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
}
#[test]
fn write_roundtrip_harness_model_fields() {
let fm = Frontmatter {
model: Some("gpt-5".to_string()),
claude_model: Some("opus".to_string()),
codex_model: Some("o3-pro".to_string()),
opencode_model: Some("zai/glm-5".to_string()),
..Default::default()
};
let doc = write(&fm, "Body\n").unwrap();
let (fm2, body) = parse(&doc).unwrap();
assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
assert_eq!(body, "Body\n");
}
#[test]
fn merge_fields_claude_model() {
let content = "---\nmodel: gpt-5\n---\nBody\n";
let result = merge_fields(content, "claude_model: opus").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("gpt-5"));
assert_eq!(fm.claude_model.as_deref(), Some("opus"));
}
#[test]
fn merge_fields_codex_model() {
let content = "---\nmodel: gpt-5\n---\nBody\n";
let result = merge_fields(content, "codex_model: o3-pro").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("gpt-5"));
assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
}
#[test]
fn merge_fields_opencode_model() {
let content = "---\nmodel: gpt-5\n---\nBody\n";
let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
let (fm, _) = parse(&result).unwrap();
assert_eq!(fm.model.as_deref(), Some("gpt-5"));
assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
}
}