use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkflowConfig {
#[serde(default)]
pub submit: SubmitConfig,
#[serde(default)]
pub source: SourceConfig,
#[serde(default)]
pub diff: DiffConfig,
#[serde(default)]
pub display: DisplayConfig,
#[serde(default)]
pub build: BuildConfig,
#[serde(default)]
pub gc: GcConfig,
#[serde(default)]
pub follow_up: FollowUpConfig,
#[serde(default)]
pub verify: VerifyConfig,
#[serde(default)]
pub shell: ShellConfig,
#[serde(default)]
pub notify: NotifyConfig,
#[serde(default)]
pub staging: StagingConfig,
#[serde(default)]
pub constitution: ConstitutionConfig,
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub governance: GovernanceConfig,
#[serde(default)]
pub vcs: VcsConfig,
#[serde(default)]
pub plan: PlanConfig,
#[serde(default)]
pub supervisor: SupervisorConfig,
#[serde(default)]
pub draft: DraftReviewConfig,
#[serde(default)]
pub workflow: WorkflowSection,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_checks: Vec<String>,
#[serde(default)]
pub apply: ApplyConfig,
#[serde(default)]
pub ta: TaPathConfig,
#[serde(default)]
pub commit: CommitConfig,
#[serde(default)]
pub project: ProjectSection,
#[serde(default)]
pub analysis: HashMap<String, ta_goal::analysis::AnalysisConfig>,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub agent_profiles: HashMap<String, AgentProfile>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommitConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_stage: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectSection {
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ApplyConfig {
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub conflict_policy: std::collections::HashMap<String, String>,
}
impl ApplyConfig {
pub fn policy_for(&self, rel_path: &str) -> Option<&str> {
if self.conflict_policy.is_empty() {
return None;
}
if let Some(v) = self.conflict_policy.get(rel_path) {
return Some(v.as_str());
}
let mut best: Option<(&str, usize)> = None;
for (pattern, value) in &self.conflict_policy {
if pattern == "default" {
continue;
}
if glob_matches(pattern, rel_path) {
let specificity = pattern.len();
if best.is_none() || specificity > best.unwrap().1 {
best = Some((value.as_str(), specificity));
}
}
}
if let Some((v, _)) = best {
return Some(v);
}
self.conflict_policy.get("default").map(|s| s.as_str())
}
}
fn glob_matches(pattern: &str, path: &str) -> bool {
if pattern.ends_with("/**") || pattern.ends_with("/*") {
let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
return path.starts_with(&format!("{}/", prefix)) || path.starts_with(prefix);
}
if let Some(suffix) = pattern.strip_prefix("**/") {
return path.ends_with(suffix) || path.contains(&format!("/{}", suffix));
}
if pattern.contains('*') {
let re_pat = pattern.replace('.', "\\.").replace('*', "[^/]*");
return regex_lite_match(&re_pat, path);
}
false
}
fn regex_lite_match(pattern: &str, text: &str) -> bool {
let anchored = format!("^{}$", pattern);
let re = anchored;
let parts: Vec<&str> = re.split("[^/]*").collect();
if parts.len() == 1 {
return text == pattern;
}
let plain = pattern.replace("\\.", ".").replace("[^/]*", "*");
let plain_parts: Vec<&str> = plain.split('*').collect();
let mut pos = 0;
for (i, part) in plain_parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !text.starts_with(part) {
return false;
}
pos = part.len();
} else if i == plain_parts.len() - 1 {
return text[pos..].ends_with(part);
} else if let Some(idx) = text[pos..].find(part) {
pos += idx + part.len();
} else {
return false;
}
}
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaPathConfig {
#[serde(default)]
pub project: TaProjectPaths,
#[serde(default)]
pub local: TaLocalPaths,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaProjectPaths {
#[serde(default = "default_project_include_paths")]
pub include_paths: Vec<String>,
}
impl Default for TaProjectPaths {
fn default() -> Self {
Self {
include_paths: default_project_include_paths(),
}
}
}
fn default_project_include_paths() -> Vec<String> {
vec![
"workflow.toml".to_string(),
"policy.yaml".to_string(),
"constitution.toml".to_string(),
"memory.toml".to_string(),
"bmad.toml".to_string(),
"agents/".to_string(),
"constitutions/".to_string(),
"memory/".to_string(),
"templates/".to_string(),
"plan_history.jsonl".to_string(),
"release-history.json".to_string(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaLocalPaths {
#[serde(default = "default_local_exclude_paths")]
pub exclude_paths: Vec<String>,
}
impl Default for TaLocalPaths {
fn default() -> Self {
Self {
exclude_paths: default_local_exclude_paths(),
}
}
}
fn default_local_exclude_paths() -> Vec<String> {
vec![
"daemon.toml".to_string(),
"daemon.local.toml".to_string(),
"workflow.local.toml".to_string(),
"local.workflow.toml".to_string(), "memory.rvf".to_string(),
"staging/".to_string(),
"store/".to_string(),
"goals/".to_string(),
"events/".to_string(),
"sessions/".to_string(),
"release.lock".to_string(),
"velocity-stats.jsonl".to_string(),
"audit-ledger.jsonl".to_string(),
"taignore".to_string(),
"interactions/".to_string(),
]
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConstitutionConfig {
#[serde(default)]
pub s4_scan: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_sandbox_provider")]
pub provider: String,
#[serde(default)]
pub allow_read: Vec<String>,
#[serde(default)]
pub allow_write: Vec<String>,
#[serde(default)]
pub allow_network: Vec<String>,
}
fn default_sandbox_provider() -> String {
"native".to_string()
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: false,
provider: default_sandbox_provider(),
allow_read: Vec::new(),
allow_write: Vec::new(),
allow_network: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditConfig {
#[serde(default)]
pub attestation: bool,
#[serde(default = "default_keys_dir")]
pub keys_dir: String,
}
fn default_keys_dir() -> String {
".ta/keys".to_string()
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
attestation: false,
keys_dir: default_keys_dir(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GovernanceConfig {
#[serde(default = "default_require_approvals")]
pub require_approvals: usize,
#[serde(default)]
pub approvers: Vec<String>,
#[serde(default)]
pub override_identity: Option<String>,
}
fn default_require_approvals() -> usize {
1
}
impl Default for GovernanceConfig {
fn default() -> Self {
Self {
require_approvals: default_require_approvals(),
approvers: Vec::new(),
override_identity: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcsAgentConfig {
#[serde(default = "default_git_mode")]
pub git_mode: String,
#[serde(default = "default_p4_mode")]
pub p4_mode: String,
#[serde(default = "default_true")]
pub init_baseline_commit: bool,
#[serde(default = "default_true")]
pub ceiling_always: bool,
}
fn default_git_mode() -> String {
"isolated".to_string()
}
fn default_p4_mode() -> String {
"shelve".to_string()
}
fn default_true() -> bool {
true
}
impl Default for VcsAgentConfig {
fn default() -> Self {
Self {
git_mode: default_git_mode(),
p4_mode: default_p4_mode(),
init_baseline_commit: true,
ceiling_always: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VcsConfig {
#[serde(default)]
pub agent: VcsAgentConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanConfig {
#[serde(default = "default_plan_file")]
pub file: String,
}
impl Default for PlanConfig {
fn default() -> Self {
Self {
file: default_plan_file(),
}
}
}
fn default_plan_file() -> String {
"PLAN.md".to_string()
}
pub fn resolve_plan_path(
workspace_root: &std::path::Path,
config: &WorkflowConfig,
) -> std::path::PathBuf {
workspace_root.join(&config.plan.file)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupervisorConfig {
#[serde(default = "default_supervisor_enabled")]
pub enabled: bool,
#[serde(default = "default_supervisor_agent")]
pub agent: String,
#[serde(default = "default_verdict_on_block")]
pub verdict_on_block: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution_path: Option<std::path::PathBuf>,
#[serde(default = "default_supervisor_skip_no_constitution")]
pub skip_if_no_constitution: bool,
#[serde(default = "default_supervisor_heartbeat_stale_secs")]
pub heartbeat_stale_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_profile: Option<String>,
#[serde(default)]
pub enable_hooks: bool,
}
fn default_supervisor_enabled() -> bool {
true
}
fn default_supervisor_agent() -> String {
"builtin".to_string()
}
fn default_verdict_on_block() -> String {
"warn".to_string()
}
fn default_supervisor_heartbeat_stale_secs() -> u64 {
30
}
fn default_supervisor_skip_no_constitution() -> bool {
true
}
impl Default for SupervisorConfig {
fn default() -> Self {
Self {
enabled: default_supervisor_enabled(),
agent: default_supervisor_agent(),
verdict_on_block: default_verdict_on_block(),
constitution_path: None,
skip_if_no_constitution: default_supervisor_skip_no_constitution(),
heartbeat_stale_secs: default_supervisor_heartbeat_stale_secs(),
timeout_secs: None,
api_key_env: None,
agent_profile: None,
enable_hooks: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentProfile {
#[serde(default)]
pub framework: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetDiffConfig {
#[serde(default = "default_asset_diff_enabled")]
pub enabled: bool,
#[serde(default)]
pub visual_diff: bool,
#[serde(default = "default_visual_diff_threshold")]
pub visual_diff_threshold: f32,
#[serde(default = "default_asset_diff_supervisor")]
pub supervisor: bool,
#[serde(default = "default_asset_diff_agent")]
pub agent: String,
#[serde(default = "default_asset_diff_timeout")]
pub timeout_secs: u64,
}
fn default_asset_diff_enabled() -> bool {
true
}
fn default_visual_diff_threshold() -> f32 {
0.3
}
fn default_asset_diff_supervisor() -> bool {
true
}
fn default_asset_diff_agent() -> String {
"builtin".to_string()
}
fn default_asset_diff_timeout() -> u64 {
60
}
impl Default for AssetDiffConfig {
fn default() -> Self {
Self {
enabled: default_asset_diff_enabled(),
visual_diff: false,
visual_diff_threshold: default_visual_diff_threshold(),
supervisor: default_asset_diff_supervisor(),
agent: default_asset_diff_agent(),
timeout_secs: default_asset_diff_timeout(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DraftReviewConfig {
#[serde(default)]
pub asset_diff: AssetDiffConfig,
#[serde(default)]
pub approval_required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ContextMode {
#[default]
Inject,
Mcp,
Hybrid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowSection {
#[serde(default = "default_enforce_phase_order")]
pub enforce_phase_order: String,
#[serde(default = "default_context_budget_chars")]
pub context_budget_chars: usize,
#[serde(default = "default_plan_done_window")]
pub plan_done_window: usize,
#[serde(default = "default_plan_pending_window")]
pub plan_pending_window: usize,
#[serde(default)]
pub context_mode: ContextMode,
}
fn default_enforce_phase_order() -> String {
"warn".to_string()
}
fn default_context_budget_chars() -> usize {
40_000
}
fn default_plan_done_window() -> usize {
5
}
fn default_plan_pending_window() -> usize {
5
}
impl Default for WorkflowSection {
fn default() -> Self {
Self {
enforce_phase_order: default_enforce_phase_order(),
context_budget_chars: default_context_budget_chars(),
plan_done_window: default_plan_done_window(),
plan_pending_window: default_plan_pending_window(),
context_mode: ContextMode::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmitConfig {
#[serde(default = "default_adapter")]
pub adapter: String,
#[serde(default)]
pub auto_submit: Option<bool>,
#[serde(default)]
pub auto_review: Option<bool>,
#[serde(default = "default_co_author")]
pub co_author: String,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub perforce: PerforceConfig,
#[serde(default)]
pub svn: SvnConfig,
}
impl SubmitConfig {
pub fn effective_auto_submit(&self) -> bool {
self.auto_submit.unwrap_or(self.adapter != "none")
}
pub fn effective_auto_review(&self) -> bool {
self.auto_review.unwrap_or(self.adapter != "none")
}
}
impl Default for SubmitConfig {
fn default() -> Self {
Self {
adapter: default_adapter(),
auto_submit: None,
auto_review: None,
co_author: default_co_author(),
git: GitConfig::default(),
perforce: PerforceConfig::default(),
svn: SvnConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PerforceConfig {
pub workspace: Option<String>,
#[serde(default = "default_shelve")]
pub shelve_by_default: bool,
}
fn default_shelve() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SvnConfig {
pub repo_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourceConfig {
#[serde(default)]
pub sync: SyncConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig {
#[serde(default)]
pub auto_sync: bool,
#[serde(default = "default_sync_strategy")]
pub strategy: String,
#[serde(default = "default_remote")]
pub remote: String,
#[serde(default = "default_sync_branch")]
pub branch: String,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
auto_sync: false,
strategy: default_sync_strategy(),
remote: default_remote(),
branch: default_sync_branch(),
}
}
}
fn default_sync_strategy() -> String {
"merge".to_string()
}
fn default_sync_branch() -> String {
"main".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitConfig {
#[serde(default = "default_branch_prefix")]
pub branch_prefix: String,
#[serde(default = "default_target_branch")]
pub target_branch: String,
#[serde(default = "default_merge_strategy")]
pub merge_strategy: String,
pub pr_template: Option<PathBuf>,
#[serde(default = "default_remote")]
pub remote: String,
#[serde(default)]
pub auto_merge: bool,
#[serde(default)]
pub protected_branches: Vec<String>,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
branch_prefix: default_branch_prefix(),
target_branch: default_target_branch(),
merge_strategy: default_merge_strategy(),
pr_template: None,
remote: default_remote(),
auto_merge: false,
protected_branches: vec![],
}
}
}
fn default_adapter() -> String {
"none".to_string()
}
fn default_co_author() -> String {
"Trusted Autonomy <266386695+trustedautonomy-agent@users.noreply.github.com>".to_string()
}
fn default_branch_prefix() -> String {
"ta/".to_string()
}
fn default_target_branch() -> String {
"main".to_string()
}
fn default_merge_strategy() -> String {
"squash".to_string()
}
fn default_remote() -> String {
"origin".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffConfig {
#[serde(default = "default_open_external")]
pub open_external: bool,
pub handlers_file: Option<PathBuf>,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
open_external: default_open_external(),
handlers_file: None,
}
}
}
fn default_open_external() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildOnFail {
#[default]
Notify,
BlockRelease,
BlockNextPhase,
Agent,
}
impl std::fmt::Display for BuildOnFail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Notify => write!(f, "notify"),
Self::BlockRelease => write!(f, "block_release"),
Self::BlockNextPhase => write!(f, "block_next_phase"),
Self::Agent => write!(f, "agent"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildConfig {
#[serde(default = "default_summary_enforcement")]
pub summary_enforcement: String,
#[serde(default = "default_build_adapter")]
pub adapter: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub test_command: Option<String>,
#[serde(default)]
pub webhook_url: Option<String>,
#[serde(default)]
pub on_fail: BuildOnFail,
#[serde(default = "default_build_timeout")]
pub timeout_secs: u64,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
summary_enforcement: default_summary_enforcement(),
adapter: default_build_adapter(),
command: None,
test_command: None,
webhook_url: None,
on_fail: BuildOnFail::default(),
timeout_secs: default_build_timeout(),
}
}
}
fn default_summary_enforcement() -> String {
"warning".to_string()
}
fn default_build_adapter() -> String {
"auto".to_string()
}
fn default_build_timeout() -> u64 {
600
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DisplayConfig {
#[serde(default)]
pub color: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcConfig {
#[serde(default = "default_stale_threshold_days")]
pub stale_threshold_days: u64,
#[serde(default = "default_stale_hint_days")]
pub stale_hint_days: u64,
#[serde(default = "default_health_check")]
pub health_check: bool,
}
impl Default for GcConfig {
fn default() -> Self {
Self {
stale_threshold_days: default_stale_threshold_days(),
stale_hint_days: default_stale_hint_days(),
health_check: default_health_check(),
}
}
}
fn default_stale_threshold_days() -> u64 {
7
}
fn default_stale_hint_days() -> u64 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowUpConfig {
#[serde(default = "default_follow_up_mode")]
pub default_mode: String,
#[serde(default = "default_auto_supersede")]
pub auto_supersede: bool,
#[serde(default = "default_rebase_on_apply")]
pub rebase_on_apply: bool,
}
impl Default for FollowUpConfig {
fn default() -> Self {
Self {
default_mode: default_follow_up_mode(),
auto_supersede: default_auto_supersede(),
rebase_on_apply: default_rebase_on_apply(),
}
}
}
fn default_follow_up_mode() -> String {
"extend".to_string()
}
fn default_auto_supersede() -> bool {
true
}
fn default_rebase_on_apply() -> bool {
true
}
fn default_health_check() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerifyOnFailure {
#[default]
Block,
Warn,
Agent,
}
impl std::fmt::Display for VerifyOnFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Block => write!(f, "block"),
Self::Warn => write!(f, "warn"),
Self::Agent => write!(f, "agent"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyCommand {
pub run: String,
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyConfig {
#[serde(default, deserialize_with = "deserialize_verify_commands")]
pub commands: Vec<VerifyCommand>,
#[serde(default)]
pub on_failure: VerifyOnFailure,
#[serde(default = "default_verify_timeout")]
pub timeout: u64,
pub default_timeout_secs: Option<u64>,
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_secs: u64,
}
impl VerifyConfig {
pub fn effective_default_timeout(&self) -> u64 {
self.default_timeout_secs.unwrap_or(self.timeout)
}
pub fn command_timeout(&self, cmd: &VerifyCommand) -> u64 {
cmd.timeout_secs
.unwrap_or_else(|| self.effective_default_timeout())
}
}
impl Default for VerifyConfig {
fn default() -> Self {
Self {
commands: Vec::new(),
on_failure: VerifyOnFailure::default(),
timeout: default_verify_timeout(),
default_timeout_secs: None,
heartbeat_interval_secs: default_heartbeat_interval(),
}
}
}
fn default_verify_timeout() -> u64 {
300
}
fn default_heartbeat_interval() -> u64 {
30
}
fn deserialize_verify_commands<'de, D>(deserializer: D) -> Result<Vec<VerifyCommand>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum CommandItem {
Simple(String),
Structured(VerifyCommand),
}
let items: Vec<CommandItem> = Vec::deserialize(deserializer)?;
Ok(items
.into_iter()
.map(|item| match item {
CommandItem::Simple(s) => VerifyCommand {
run: s,
timeout_secs: None,
},
CommandItem::Structured(c) => c,
})
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellConfig {
#[serde(default = "default_tail_backfill_lines")]
pub tail_backfill_lines: usize,
#[serde(default = "default_output_buffer_lines")]
pub output_buffer_lines: usize,
#[serde(default)]
pub scrollback_lines: Option<usize>,
#[serde(default = "default_auto_tail")]
pub auto_tail: bool,
}
impl ShellConfig {
pub fn effective_scrollback(&self) -> usize {
let raw = self.scrollback_lines.unwrap_or(self.output_buffer_lines);
raw.max(10_000)
}
}
impl Default for ShellConfig {
fn default() -> Self {
Self {
tail_backfill_lines: default_tail_backfill_lines(),
output_buffer_lines: default_output_buffer_lines(),
scrollback_lines: None,
auto_tail: default_auto_tail(),
}
}
}
fn default_tail_backfill_lines() -> usize {
5
}
fn default_output_buffer_lines() -> usize {
50000
}
fn default_auto_tail() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default = "default_notify_enabled")]
pub enabled: bool,
#[serde(default = "default_notify_title")]
pub title: String,
}
impl Default for NotifyConfig {
fn default() -> Self {
Self {
enabled: default_notify_enabled(),
title: default_notify_title(),
}
}
}
fn default_notify_enabled() -> bool {
true
}
fn default_notify_title() -> String {
"TA".to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StagingStrategy {
#[default]
Full,
Smart,
RefsCow,
ProjFs,
}
impl StagingStrategy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Full => "full",
Self::Smart => "smart",
Self::RefsCow => "refs-cow",
Self::ProjFs => "projfs",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StagingConfig {
#[serde(default = "default_auto_clean")]
pub auto_clean: bool,
#[serde(default = "default_min_disk_mb")]
pub min_disk_mb: u64,
#[serde(default)]
pub strategy: StagingStrategy,
}
impl Default for StagingConfig {
fn default() -> Self {
Self {
auto_clean: default_auto_clean(),
min_disk_mb: default_min_disk_mb(),
strategy: StagingStrategy::Full,
}
}
}
fn default_auto_clean() -> bool {
true
}
fn default_min_disk_mb() -> u64 {
2048
}
pub fn check_disk_space_mb(path: &std::path::Path) -> Result<u64, String> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|e| format!("invalid path: {}", e))?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
if rc != 0 {
return Err(format!(
"statvfs failed for {}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
Ok((stat.f_bavail as u64) * (stat.f_frsize as u64) / (1024 * 1024))
}
#[cfg(not(unix))]
{
let _ = path;
Ok(u64::MAX)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub level: ta_goal::SecurityLevel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_scan: Option<ta_goal::SecretScanMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_forbidden_tools: Vec<String>,
}
impl SecurityConfig {
pub fn to_overrides(&self) -> ta_goal::SecurityOverrides {
ta_goal::SecurityOverrides {
secret_scan_mode: self.secret_scan,
extra_forbidden_tools: self.extra_forbidden_tools.clone(),
..Default::default()
}
}
}
impl WorkflowConfig {
pub fn load(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let mut base: toml::Value = toml::from_str(&content)?;
let dir = path.parent().unwrap_or(std::path::Path::new("."));
let canonical_local = dir.join("workflow.local.toml");
let deprecated_local = dir.join("local.workflow.toml");
let local_path = if canonical_local.exists() {
Some(canonical_local)
} else if deprecated_local.exists() {
tracing::warn!(
path = %deprecated_local.display(),
"'.ta/local.workflow.toml' is deprecated — rename to '.ta/workflow.local.toml'"
);
Some(deprecated_local)
} else {
None
};
if let Some(local) = local_path {
let local_content = std::fs::read_to_string(&local)?;
let local_val: toml::Value = toml::from_str(&local_content)?;
merge_toml_values(&mut base, local_val);
}
let config = base.try_into()?;
Ok(config)
}
pub fn load_or_default(path: &std::path::Path) -> Self {
Self::load(path).unwrap_or_default()
}
}
fn merge_toml_values(base: &mut toml::Value, overrides: toml::Value) {
match (base, overrides) {
(toml::Value::Table(base_map), toml::Value::Table(override_map)) => {
for (k, v) in override_map {
let entry = base_map
.entry(k)
.or_insert(toml::Value::Table(Default::default()));
merge_toml_values(entry, v);
}
}
(base, overrides) => *base = overrides,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_config_defaults_to_warning() {
let config = BuildConfig::default();
assert_eq!(config.summary_enforcement, "warning");
}
#[test]
fn build_config_defaults() {
let config = BuildConfig::default();
assert_eq!(config.adapter, "auto");
assert!(config.command.is_none());
assert!(config.test_command.is_none());
assert!(config.webhook_url.is_none());
assert_eq!(config.on_fail, BuildOnFail::Notify);
assert_eq!(config.timeout_secs, 600);
}
#[test]
fn workflow_config_default_has_build_section() {
let config = WorkflowConfig::default();
assert_eq!(config.build.summary_enforcement, "warning");
assert_eq!(config.build.adapter, "auto");
}
#[test]
fn parse_toml_with_build_section() {
let toml = r#"
[build]
summary_enforcement = "error"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.build.summary_enforcement, "error");
}
#[test]
fn parse_toml_with_build_adapter_config() {
let toml = r#"
[build]
adapter = "cargo"
command = "cargo build --release"
test_command = "cargo test --release"
on_fail = "block_release"
timeout_secs = 1200
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.build.adapter, "cargo");
assert_eq!(
config.build.command.as_deref(),
Some("cargo build --release")
);
assert_eq!(
config.build.test_command.as_deref(),
Some("cargo test --release")
);
assert_eq!(config.build.on_fail, BuildOnFail::BlockRelease);
assert_eq!(config.build.timeout_secs, 1200);
}
#[test]
fn parse_toml_with_build_script_adapter() {
let toml = r#"
[build]
adapter = "script"
command = "make all"
test_command = "make test"
on_fail = "agent"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.build.adapter, "script");
assert_eq!(config.build.command.as_deref(), Some("make all"));
assert_eq!(config.build.on_fail, BuildOnFail::Agent);
}
#[test]
fn parse_toml_without_build_section_uses_default() {
let toml = r#"
[submit]
adapter = "git"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.build.summary_enforcement, "warning");
assert_eq!(config.build.adapter, "auto");
}
#[test]
fn build_on_fail_display() {
assert_eq!(BuildOnFail::Notify.to_string(), "notify");
assert_eq!(BuildOnFail::BlockRelease.to_string(), "block_release");
assert_eq!(BuildOnFail::BlockNextPhase.to_string(), "block_next_phase");
assert_eq!(BuildOnFail::Agent.to_string(), "agent");
}
#[test]
fn gc_config_defaults() {
let config = GcConfig::default();
assert_eq!(config.stale_threshold_days, 7);
assert_eq!(config.stale_hint_days, 3);
assert!(config.health_check);
}
#[test]
fn workflow_config_default_has_gc_section() {
let config = WorkflowConfig::default();
assert_eq!(config.gc.stale_threshold_days, 7);
assert_eq!(config.gc.stale_hint_days, 3);
assert!(config.gc.health_check);
}
#[test]
fn parse_toml_with_gc_section() {
let toml = r#"
[gc]
stale_threshold_days = 14
stale_hint_days = 5
health_check = false
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.gc.stale_threshold_days, 14);
assert_eq!(config.gc.stale_hint_days, 5);
assert!(!config.gc.health_check);
}
#[test]
fn load_or_default_returns_default_for_missing_file() {
let config = WorkflowConfig::load_or_default(std::path::Path::new("/nonexistent/path"));
assert_eq!(config.build.summary_enforcement, "warning");
assert_eq!(config.submit.adapter, "none");
}
#[test]
fn follow_up_config_defaults() {
let config = FollowUpConfig::default();
assert_eq!(config.default_mode, "extend");
assert!(config.auto_supersede);
assert!(config.rebase_on_apply);
}
#[test]
fn workflow_config_default_has_follow_up_section() {
let config = WorkflowConfig::default();
assert_eq!(config.follow_up.default_mode, "extend");
assert!(config.follow_up.auto_supersede);
assert!(config.follow_up.rebase_on_apply);
}
#[test]
fn parse_toml_with_follow_up_section() {
let toml = r#"
[follow_up]
default_mode = "standalone"
auto_supersede = false
rebase_on_apply = false
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.follow_up.default_mode, "standalone");
assert!(!config.follow_up.auto_supersede);
assert!(!config.follow_up.rebase_on_apply);
}
#[test]
fn verify_config_defaults() {
let config = VerifyConfig::default();
assert!(config.commands.is_empty());
assert_eq!(config.on_failure, VerifyOnFailure::Block);
assert_eq!(config.timeout, 300);
assert_eq!(config.heartbeat_interval_secs, 30);
assert!(config.default_timeout_secs.is_none());
assert_eq!(config.effective_default_timeout(), 300);
}
#[test]
fn workflow_config_default_has_verify_section() {
let config = WorkflowConfig::default();
assert!(config.verify.commands.is_empty());
assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
assert_eq!(config.verify.timeout, 300);
}
#[test]
fn parse_toml_with_verify_section() {
let toml = r#"
[verify]
commands = [
"cargo build --workspace",
"cargo test --workspace",
]
on_failure = "warn"
timeout = 600
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.verify.commands.len(), 2);
assert_eq!(config.verify.commands[0].run, "cargo build --workspace");
assert_eq!(config.verify.on_failure, VerifyOnFailure::Warn);
assert_eq!(config.verify.timeout, 600);
}
#[test]
fn parse_toml_with_verify_agent_mode() {
let toml = r#"
[verify]
commands = ["make test"]
on_failure = "agent"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.verify.on_failure, VerifyOnFailure::Agent);
assert_eq!(config.verify.timeout, 300); }
#[test]
fn parse_toml_without_verify_section_uses_default() {
let toml = r#"
[submit]
adapter = "git"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(config.verify.commands.is_empty());
assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
}
#[test]
fn parse_toml_with_per_command_timeout() {
let toml = r#"
[verify]
default_timeout_secs = 300
heartbeat_interval_secs = 15
[[verify.commands]]
run = "cargo fmt --all -- --check"
timeout_secs = 60
[[verify.commands]]
run = "cargo test --workspace"
timeout_secs = 900
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.verify.commands.len(), 2);
assert_eq!(config.verify.commands[0].run, "cargo fmt --all -- --check");
assert_eq!(config.verify.commands[0].timeout_secs, Some(60));
assert_eq!(config.verify.commands[1].run, "cargo test --workspace");
assert_eq!(config.verify.commands[1].timeout_secs, Some(900));
assert_eq!(config.verify.default_timeout_secs, Some(300));
assert_eq!(config.verify.heartbeat_interval_secs, 15);
assert_eq!(config.verify.effective_default_timeout(), 300);
assert_eq!(
config.verify.command_timeout(&config.verify.commands[0]),
60
);
assert_eq!(
config.verify.command_timeout(&config.verify.commands[1]),
900
);
}
#[test]
fn per_command_timeout_falls_back_to_default() {
let config = VerifyConfig {
commands: vec![VerifyCommand {
run: "test".to_string(),
timeout_secs: None,
}],
default_timeout_secs: Some(600),
..Default::default()
};
assert_eq!(config.command_timeout(&config.commands[0]), 600);
}
#[test]
fn effective_timeout_falls_back_to_legacy() {
let config = VerifyConfig {
timeout: 900,
default_timeout_secs: None,
..Default::default()
};
assert_eq!(config.effective_default_timeout(), 900);
}
#[test]
fn verify_on_failure_display() {
assert_eq!(VerifyOnFailure::Block.to_string(), "block");
assert_eq!(VerifyOnFailure::Warn.to_string(), "warn");
assert_eq!(VerifyOnFailure::Agent.to_string(), "agent");
}
#[test]
fn shell_config_defaults() {
let config = ShellConfig::default();
assert_eq!(config.tail_backfill_lines, 5);
assert_eq!(config.output_buffer_lines, 50000);
assert!(config.scrollback_lines.is_none());
assert!(config.auto_tail);
assert_eq!(config.effective_scrollback(), 50000);
}
#[test]
fn workflow_config_default_has_shell_section() {
let config = WorkflowConfig::default();
assert_eq!(config.shell.tail_backfill_lines, 5);
assert_eq!(config.shell.output_buffer_lines, 50000);
assert!(config.shell.auto_tail);
}
#[test]
fn parse_toml_with_shell_section() {
let toml = r#"
[shell]
tail_backfill_lines = 20
output_buffer_lines = 5000
auto_tail = false
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.shell.tail_backfill_lines, 20);
assert_eq!(config.shell.output_buffer_lines, 5000);
assert!(!config.shell.auto_tail);
}
#[test]
fn parse_toml_without_shell_section_uses_default() {
let toml = r#"
[submit]
adapter = "git"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.shell.tail_backfill_lines, 5);
assert_eq!(config.shell.output_buffer_lines, 50000);
assert!(config.shell.auto_tail);
}
#[test]
fn effective_auto_submit_defaults_true_when_adapter_set() {
let config = SubmitConfig {
adapter: "git".to_string(),
..Default::default()
};
assert!(config.effective_auto_submit());
}
#[test]
fn effective_auto_submit_defaults_false_when_no_adapter() {
let config = SubmitConfig::default(); assert!(!config.effective_auto_submit());
}
#[test]
fn effective_auto_submit_explicit_override() {
let config = SubmitConfig {
adapter: "git".to_string(),
auto_submit: Some(false),
..Default::default()
};
assert!(!config.effective_auto_submit());
}
#[test]
fn effective_auto_review_defaults_true_when_adapter_set() {
let config = SubmitConfig {
adapter: "git".to_string(),
..Default::default()
};
assert!(config.effective_auto_review());
}
#[test]
fn effective_auto_review_defaults_false_when_no_adapter() {
let config = SubmitConfig::default();
assert!(!config.effective_auto_review());
}
#[test]
fn effective_auto_review_explicit_override() {
let config = SubmitConfig {
adapter: "git".to_string(),
auto_review: Some(false),
..Default::default()
};
assert!(!config.effective_auto_review());
}
#[test]
fn parse_toml_with_auto_submit() {
let toml = r#"
[submit]
adapter = "git"
auto_submit = true
auto_review = false
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(config.submit.effective_auto_submit());
assert!(!config.submit.effective_auto_review());
}
#[test]
fn parse_toml_with_deprecated_auto_commit_auto_push() {
let toml = r#"
[submit]
adapter = "none"
auto_review = true
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(!config.submit.effective_auto_submit());
assert!(config.submit.effective_auto_review());
}
#[test]
fn sync_config_defaults() {
let config = SyncConfig::default();
assert!(!config.auto_sync);
assert_eq!(config.strategy, "merge");
assert_eq!(config.remote, "origin");
assert_eq!(config.branch, "main");
}
#[test]
fn parse_toml_with_source_sync_section() {
let toml = r#"
[source.sync]
auto_sync = true
strategy = "rebase"
remote = "upstream"
branch = "develop"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(config.source.sync.auto_sync);
assert_eq!(config.source.sync.strategy, "rebase");
assert_eq!(config.source.sync.remote, "upstream");
assert_eq!(config.source.sync.branch, "develop");
}
#[test]
fn parse_toml_without_source_section_uses_default() {
let toml = r#"
[submit]
adapter = "git"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(!config.source.sync.auto_sync);
assert_eq!(config.source.sync.strategy, "merge");
}
#[test]
fn parse_toml_with_adapter_specific_sections() {
let toml = r#"
[submit]
adapter = "git"
[submit.git]
branch_prefix = "feature/"
target_branch = "develop"
remote = "upstream"
[submit.perforce]
workspace = "my-ws"
shelve_by_default = false
[submit.svn]
repo_url = "svn://example.com/trunk"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.submit.git.branch_prefix, "feature/");
assert_eq!(config.submit.git.target_branch, "develop");
assert_eq!(config.submit.git.remote, "upstream");
assert_eq!(config.submit.perforce.workspace.as_deref(), Some("my-ws"));
assert!(!config.submit.perforce.shelve_by_default);
assert_eq!(
config.submit.svn.repo_url.as_deref(),
Some("svn://example.com/trunk")
);
}
#[test]
fn git_config_auto_merge_default_false() {
let config = GitConfig::default();
assert!(!config.auto_merge);
}
#[test]
fn git_config_auto_merge_from_toml() {
let toml = r#"
[submit.git]
auto_merge = true
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(config.submit.git.auto_merge);
}
#[test]
fn sandbox_config_defaults() {
let config = SandboxConfig::default();
assert!(!config.enabled);
assert_eq!(config.provider, "native");
assert!(config.allow_read.is_empty());
assert!(config.allow_write.is_empty());
assert!(config.allow_network.is_empty());
}
#[test]
fn sandbox_config_from_toml() {
let toml = r#"
[sandbox]
enabled = true
provider = "native"
allow_read = ["/usr/lib"]
allow_write = ["/tmp/scratch"]
allow_network = ["api.anthropic.com"]
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert!(config.sandbox.enabled);
assert_eq!(config.sandbox.provider, "native");
assert_eq!(config.sandbox.allow_read, vec!["/usr/lib"]);
assert_eq!(config.sandbox.allow_write, vec!["/tmp/scratch"]);
assert_eq!(config.sandbox.allow_network, vec!["api.anthropic.com"]);
}
#[test]
fn workflow_config_default_has_sandbox_section() {
let config = WorkflowConfig::default();
assert!(!config.sandbox.enabled, "sandbox disabled by default");
}
#[test]
fn workflow_section_defaults_to_warn() {
let config = WorkflowConfig::default();
assert_eq!(
config.workflow.enforce_phase_order, "warn",
"enforce_phase_order should default to 'warn'"
);
}
#[test]
fn workflow_section_parse_toml() {
let toml = r#"
[workflow]
enforce_phase_order = "block"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.workflow.enforce_phase_order, "block");
}
#[test]
fn workflow_section_parse_toml_off() {
let toml = r#"
[workflow]
enforce_phase_order = "off"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.workflow.enforce_phase_order, "off");
}
#[test]
fn apply_config_empty_returns_none() {
let cfg = ApplyConfig::default();
assert!(cfg.policy_for("PLAN.md").is_none());
assert!(cfg.policy_for("src/main.rs").is_none());
}
#[test]
fn apply_config_exact_match() {
let mut cfg = ApplyConfig::default();
cfg.conflict_policy
.insert("PLAN.md".to_string(), "keep-source".to_string());
cfg.conflict_policy
.insert("Cargo.lock".to_string(), "keep-source".to_string());
assert_eq!(cfg.policy_for("PLAN.md"), Some("keep-source"));
assert_eq!(cfg.policy_for("Cargo.lock"), Some("keep-source"));
assert_eq!(cfg.policy_for("src/main.rs"), None);
}
#[test]
fn apply_config_default_key_fallback() {
let mut cfg = ApplyConfig::default();
cfg.conflict_policy
.insert("default".to_string(), "merge".to_string());
assert_eq!(cfg.policy_for("src/anything.rs"), Some("merge"));
assert_eq!(cfg.policy_for("PLAN.md"), Some("merge"));
}
#[test]
fn apply_config_glob_override_wins_over_default() {
let mut cfg = ApplyConfig::default();
cfg.conflict_policy
.insert("default".to_string(), "merge".to_string());
cfg.conflict_policy
.insert("src/**".to_string(), "abort".to_string());
assert_eq!(cfg.policy_for("src/main.rs"), Some("abort"));
assert_eq!(cfg.policy_for("src/nested/lib.rs"), Some("abort"));
assert_eq!(cfg.policy_for("docs/USAGE.md"), Some("merge"));
}
#[test]
fn apply_config_parse_from_toml() {
let toml = r#"
[apply.conflict_policy]
default = "merge"
"PLAN.md" = "keep-source"
"Cargo.lock" = "keep-source"
"src/**" = "abort"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.apply.policy_for("PLAN.md"), Some("keep-source"));
assert_eq!(config.apply.policy_for("Cargo.lock"), Some("keep-source"));
assert_eq!(config.apply.policy_for("src/lib.rs"), Some("abort"));
assert_eq!(config.apply.policy_for("docs/USAGE.md"), Some("merge"));
}
#[test]
fn ta_path_config_defaults_are_populated() {
let cfg = TaPathConfig::default();
assert!(!cfg.project.include_paths.is_empty());
assert!(cfg
.project
.include_paths
.contains(&"workflow.toml".to_string()));
assert!(!cfg.local.exclude_paths.is_empty());
assert!(cfg.local.exclude_paths.contains(&"staging/".to_string()));
}
#[test]
fn ta_path_config_parse_from_toml() {
let toml = r#"
[ta.project]
include_paths = ["workflow.toml", "agents/"]
[ta.local]
exclude_paths = ["staging/", "goals/"]
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.ta.project.include_paths,
vec!["workflow.toml", "agents/"]
);
assert_eq!(config.ta.local.exclude_paths, vec!["staging/", "goals/"]);
}
#[test]
fn plan_config_defaults_to_plan_md() {
let config = PlanConfig::default();
assert_eq!(config.file, "PLAN.md");
}
#[test]
fn plan_config_custom_file_resolves_path() {
let config = PlanConfig {
file: "ROADMAP.md".to_string(),
};
let workflow = WorkflowConfig {
plan: config,
..Default::default()
};
let root = std::path::Path::new("/workspace");
let path = resolve_plan_path(root, &workflow);
assert_eq!(path, std::path::Path::new("/workspace/ROADMAP.md"));
}
#[test]
fn plan_config_default_resolves_to_plan_md() {
let workflow = WorkflowConfig::default();
let root = std::path::Path::new("/project");
let path = resolve_plan_path(root, &workflow);
assert_eq!(path, std::path::Path::new("/project/PLAN.md"));
}
#[test]
fn plan_config_parses_from_toml() {
let toml = r#"
[plan]
file = "ROADMAP.md"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.plan.file, "ROADMAP.md");
}
#[test]
fn project_section_parses_name() {
let toml = r#"
[project]
name = "My Pipeline Project"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.project.name.as_deref(), Some("My Pipeline Project"));
}
#[test]
fn project_section_defaults_to_none_name() {
let config = WorkflowConfig::default();
assert!(config.project.name.is_none());
}
#[test]
fn analysis_config_parses_python() {
let toml = r#"
[analysis.python]
tool = "mypy"
args = ["--strict"]
on_failure = "agent"
max_iterations = 3
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
let py = config
.analysis
.get("python")
.expect("python analysis config");
assert_eq!(py.tool, "mypy");
assert_eq!(py.args, vec!["--strict"]);
assert_eq!(py.on_failure, ta_goal::analysis::OnFailure::Agent);
assert_eq!(py.max_iterations, 3);
}
#[test]
fn analysis_config_parses_multiple_languages() {
let toml = r#"
[analysis.rust]
tool = "cargo-clippy"
args = ["-D", "warnings"]
on_failure = "warn"
[analysis.go]
tool = "golangci-lint"
args = ["run"]
on_failure = "agent"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
assert_eq!(config.analysis.len(), 2);
let rust = config.analysis.get("rust").unwrap();
assert_eq!(rust.tool, "cargo-clippy");
assert_eq!(rust.on_failure, ta_goal::analysis::OnFailure::Warn);
let go = config.analysis.get("go").unwrap();
assert_eq!(go.on_failure, ta_goal::analysis::OnFailure::Agent);
}
#[test]
fn analysis_config_defaults_to_empty_map() {
let config = WorkflowConfig::default();
assert!(config.analysis.is_empty());
}
#[test]
fn analysis_config_parses_on_max_iterations() {
let toml = r#"
[analysis.typescript]
tool = "pyright"
on_max_iterations = "fail"
"#;
let config: WorkflowConfig = toml::from_str(toml).unwrap();
let ts = config.analysis.get("typescript").unwrap();
assert_eq!(
ts.on_max_iterations,
ta_goal::analysis::OnMaxIterations::Fail
);
}
}