use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
use crate::model_tier::Tier;
#[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, 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 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 claude_args: Option<String>,
#[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_model_tier"
)]
pub model_tier: Option<Tier>,
#[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, String>,
}
impl Frontmatter {
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 parse(content: &str) -> Result<(Frontmatter, &str)> {
if !content.starts_with("---\n") {
return Ok((Frontmatter::default(), content));
}
let rest = &content[4..]; let end = rest
.find("\n---\n")
.or_else(|| rest.find("\n---"))
.ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
let yaml = &rest[..end];
let fm: Frontmatter = serde_yaml::from_str(yaml)?;
let body_start = 4 + end + 4; let body = if body_start <= content.len() {
&content[body_start..]
} else {
""
};
Ok((fm, body))
}
pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
let yaml = serde_yaml::to_string(fm)?;
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_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(),
"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);
}
}
"claude_args" => fm.claude_args = val_str(),
_ => {
eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
}
}
}
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))
}
pub fn read_session_id(file: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(file).ok()?;
let (fm, _) = parse(&content).ok()?;
fm.session
}
#[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_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 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_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_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()),
branch: Some("dev".to_string()),
tmux_session: None,
mode: None,
format: None,
write_mode: None,
stream_config: None,
claude_args: None,
no_mcp: None,
enable_tool_search: None,
debounce_ms: None,
links: vec![],
auto_compact: None,
model_tier: None,
hooks: std::collections::HashMap::new(),
env: indexmap::IndexMap::new(),
};
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.branch, fm.branch);
assert!(body2.contains("# Hello"));
assert!(body2.contains("Body text."));
}
#[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 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"], "bar");
assert_eq!(fm.env["BAZ"], "$(echo hello)");
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 write_roundtrip_with_env() {
let mut env = indexmap::IndexMap::new();
env.insert("KEY1".to_string(), "value1".to_string());
env.insert("KEY2".to_string(), "$KEY1".to_string());
let fm = Frontmatter {
env,
..Default::default()
};
let written = write(&fm, "body\n").unwrap();
let (fm2, _) = parse(&written).unwrap();
assert_eq!(fm2.env.len(), 2);
assert_eq!(fm2.env["KEY1"], "value1");
assert_eq!(fm2.env["KEY2"], "$KEY1");
}
}