use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GuardConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_capture: Option<crate::frontmatter::PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_done: Option<crate::frontmatter::PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_done: Option<crate::frontmatter::PendingCaptureGuardMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_done: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LintConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dialect: Option<crate::frontmatter::LintDialectMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshProfileConfig {
#[serde(default)]
pub targets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshDocConfig {
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub targets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshConfig {
#[serde(default)]
pub profiles: BTreeMap<String, SshProfileConfig>,
#[serde(default)]
pub docs: BTreeMap<String, SshDocConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ComponentConfig {
#[serde(default = "default_patch_mode", alias = "mode")]
pub patch: String,
#[serde(default = "default_merge_strategy")]
#[allow(dead_code)]
pub merge_strategy: String,
#[serde(default)]
pub timestamp: bool,
#[serde(default)]
pub max_entries: usize,
#[serde(default)]
#[allow(dead_code)]
pub max_lines: usize,
#[serde(default)]
pub pre_patch: Option<String>,
#[serde(default)]
pub post_patch: Option<String>,
}
fn default_patch_mode() -> String {
"replace".to_string()
}
fn default_merge_strategy() -> String {
"append-friendly".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DocumentsConfig {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub auto_session_for_all_md: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VisionConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default)]
pub agents: BTreeMap<String, AgentVisionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentVisionConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl VisionConfig {
pub fn agent_config(&self, agent_name: &str) -> Option<&AgentVisionConfig> {
self.agents.get(agent_name)
}
pub fn effective_provider(&self, agent_name: Option<&str>) -> Option<&str> {
if let Some(name) = agent_name
&& let Some(ac) = self.agents.get(name)
&& let Some(ref p) = ac.provider
{
return Some(p.as_str());
}
self.provider.as_deref()
}
pub fn effective_model(&self, agent_name: Option<&str>) -> Option<&str> {
if let Some(name) = agent_name
&& let Some(ac) = self.agents.get(name)
&& let Some(ref m) = ac.model
{
return Some(m.as_str());
}
self.model.as_deref()
}
pub fn effective_api_key(&self, agent_name: Option<&str>) -> Option<&str> {
if let Some(name) = agent_name
&& let Some(ac) = self.agents.get(name)
&& let Some(ref k) = ac.api_key
{
return Some(k.as_str());
}
self.api_key.as_deref()
}
pub fn agent_mode(&self, agent_name: &str) -> Option<&str> {
self.agents
.get(agent_name)
.and_then(|ac| ac.mode.as_deref())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ProjectConfig {
#[serde(default)]
pub tmux_session: Option<String>,
#[serde(default, alias = "auto_compact")]
pub agent_doc_auto_compact: Option<usize>,
#[serde(default, alias = "queue_context_reset")]
pub agent_doc_queue_context_reset: Option<bool>,
#[serde(default, alias = "gate_autoverify")]
pub agent_doc_gate_autoverify: Option<bool>,
#[serde(default, alias = "clear_threshold")]
pub agent_doc_clear_threshold: Option<u8>,
#[serde(default, alias = "supervisor_auto_recycle")]
pub agent_doc_supervisor_auto_recycle: Option<bool>,
#[serde(default, alias = "agent_change_restart")]
pub agent_doc_agent_change_restart: Option<bool>,
#[serde(default, alias = "supervisor_auto_install")]
pub agent_doc_supervisor_auto_install: Option<bool>,
#[serde(
default,
alias = "bug_target_document",
alias = "agent_doc_bug_backlog_document"
)]
pub agent_doc_bug_target_document: Option<String>,
#[serde(default)]
pub guards: GuardConfig,
#[serde(default)]
pub lint: LintConfig,
#[serde(default)]
pub ssh: SshConfig,
#[serde(default)]
pub components: BTreeMap<String, ComponentConfig>,
#[serde(default)]
pub documents: DocumentsConfig,
#[serde(default)]
pub vision: VisionConfig,
#[serde(default)]
pub stash_session_ttl_secs: u64,
#[serde(default)]
pub stash_session_ttl_prune_enabled: bool,
}
pub fn parse_project_toml(content: &str) -> Result<ProjectConfig> {
toml::from_str(content).map_err(anyhow::Error::from)
}
pub fn parse_legacy_components_toml(content: &str) -> Result<BTreeMap<String, ComponentConfig>> {
toml::from_str(content).map_err(anyhow::Error::from)
}
pub fn is_agent_doc_document(rel_path: &str, content: &str, config: &ProjectConfig) -> bool {
if config.documents.auto_session_for_all_md {
return true;
}
if let Ok((fm, _)) = crate::frontmatter::parse(content)
&& fm.has_agent_doc_marker()
{
return true;
}
let norm = rel_path.trim_start_matches("./");
config
.documents
.include
.iter()
.any(|pattern| glob_match(pattern.trim_start_matches("./"), norm))
}
pub fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').collect();
let seg: Vec<&str> = path.split('/').collect();
glob_match_segments(&pat, &seg)
}
fn glob_match_segments(pat: &[&str], seg: &[&str]) -> bool {
match pat.first() {
None => seg.is_empty(),
Some(&"**") => {
if pat.len() == 1 {
return true;
}
(0..=seg.len()).any(|skip| glob_match_segments(&pat[1..], &seg[skip..]))
}
Some(first) => match seg.first() {
Some(s) if glob_match_segment(first, s) => glob_match_segments(&pat[1..], &seg[1..]),
_ => false,
},
}
}
fn glob_match_segment(pattern: &str, text: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let txt: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star_pi, mut star_ti): (Option<usize>, usize) = (None, 0);
while ti < txt.len() {
if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
pi += 1;
ti += 1;
} else if pi < pat.len() && pat[pi] == '*' {
star_pi = Some(pi);
star_ti = ti;
pi += 1;
} else if let Some(sp) = star_pi {
pi = sp + 1;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
while pi < pat.len() && pat[pi] == '*' {
pi += 1;
}
pi == pat.len()
}
#[cfg(test)]
mod documents_gate_tests {
use super::*;
fn cfg(include: &[&str], all_md: bool) -> ProjectConfig {
ProjectConfig {
documents: DocumentsConfig {
include: include.iter().map(|s| s.to_string()).collect(),
auto_session_for_all_md: all_md,
},
..Default::default()
}
}
#[test]
fn parses_agent_doc_bug_target_document() {
let cfg = parse_project_toml(
r#"
agent_doc_bug_target_document = "tasks/agent-doc/agent-doc-bugs2.md"
"#,
)
.unwrap();
assert_eq!(
cfg.agent_doc_bug_target_document.as_deref(),
Some("tasks/agent-doc/agent-doc-bugs2.md")
);
}
#[test]
fn glob_match_literal_and_star() {
assert!(glob_match("plan.md", "plan.md"));
assert!(!glob_match("plan.md", "other.md"));
assert!(glob_match("*.md", "notes.md"));
assert!(!glob_match("*.md", "notes.txt"));
assert!(!glob_match("*.md", "tasks/notes.md"));
}
#[test]
fn glob_match_doublestar_spans_dirs() {
assert!(glob_match("tasks/**/*.md", "tasks/agent-doc/bugs.md"));
assert!(glob_match("tasks/**/*.md", "tasks/a/b/c/deep.md"));
assert!(glob_match("tasks/**/*.md", "tasks/flat.md"));
assert!(!glob_match("tasks/**/*.md", "other/flat.md"));
assert!(glob_match("**/*.md", "anything/here.md"));
assert!(glob_match("**", "anything/at/all.md"));
}
#[test]
fn glob_match_question_mark() {
assert!(glob_match("plan?.md", "plan1.md"));
assert!(!glob_match("plan?.md", "plan.md"));
assert!(!glob_match("plan?.md", "plan12.md"));
}
#[test]
fn plain_md_is_not_a_session_by_default() {
let c = cfg(&[], false);
assert!(!is_agent_doc_document("notes.md", "# Just notes\n", &c));
assert!(!is_agent_doc_document(
"tasks/foo.md",
"---\ntitle: foo\n---\nbody\n",
&c
));
}
#[test]
fn frontmatter_agent_doc_field_opts_in() {
let c = cfg(&[], false);
assert!(is_agent_doc_document(
"notes.md",
"---\nagent_doc_format: template\n---\nbody\n",
&c
));
assert!(is_agent_doc_document(
"notes.md",
"---\nagent_doc_session: 123\n---\nbody\n",
&c
));
assert!(is_agent_doc_document(
"notes.md",
"---\nsession: legacy-id\n---\nbody\n",
&c
));
assert!(is_agent_doc_document(
"notes.md",
"---\nagent_doc_write: crdt\n---\nbody\n",
&c
));
}
#[test]
fn include_glob_opts_in() {
let c = cfg(&["tasks/**/*.md", "plan.md"], false);
assert!(is_agent_doc_document("tasks/agent-doc/x.md", "plain\n", &c));
assert!(is_agent_doc_document("plan.md", "plain\n", &c));
assert!(is_agent_doc_document("./plan.md", "plain\n", &c));
assert!(!is_agent_doc_document("README.md", "plain\n", &c));
}
#[test]
fn escape_hatch_opts_in_everything() {
let c = cfg(&[], true);
assert!(is_agent_doc_document("README.md", "plain notes\n", &c));
assert!(is_agent_doc_document("anywhere/file.md", "plain\n", &c));
}
}