1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct WorkflowConfig {
10 #[serde(default)]
12 pub submit: SubmitConfig,
13
14 #[serde(default)]
16 pub source: SourceConfig,
17
18 #[serde(default)]
20 pub diff: DiffConfig,
21
22 #[serde(default)]
24 pub display: DisplayConfig,
25
26 #[serde(default)]
28 pub build: BuildConfig,
29
30 #[serde(default)]
32 pub gc: GcConfig,
33
34 #[serde(default)]
36 pub follow_up: FollowUpConfig,
37
38 #[serde(default)]
40 pub verify: VerifyConfig,
41
42 #[serde(default)]
44 pub shell: ShellConfig,
45
46 #[serde(default)]
48 pub notify: NotifyConfig,
49
50 #[serde(default)]
52 pub staging: StagingConfig,
53
54 #[serde(default)]
56 pub constitution: ConstitutionConfig,
57
58 #[serde(default)]
60 pub sandbox: SandboxConfig,
61
62 #[serde(default)]
64 pub audit: AuditConfig,
65
66 #[serde(default)]
68 pub governance: GovernanceConfig,
69
70 #[serde(default)]
72 pub vcs: VcsConfig,
73
74 #[serde(default)]
76 pub plan: PlanConfig,
77
78 #[serde(default)]
80 pub supervisor: SupervisorConfig,
81
82 #[serde(default)]
84 pub draft: DraftReviewConfig,
85
86 #[serde(default)]
88 pub workflow: WorkflowSection,
89
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub required_checks: Vec<String>,
102
103 #[serde(default)]
120 pub apply: ApplyConfig,
121
122 #[serde(default)]
137 pub ta: TaPathConfig,
138
139 #[serde(default)]
154 pub commit: CommitConfig,
155
156 #[serde(default)]
163 pub project: ProjectSection,
164
165 #[serde(default)]
182 pub analysis: HashMap<String, ta_goal::analysis::AnalysisConfig>,
183
184 #[serde(default)]
194 pub security: SecurityConfig,
195
196 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
204 pub agent_profiles: HashMap<String, AgentProfile>,
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct CommitConfig {
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub auto_stage: Vec<String>,
223}
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232#[serde(default)]
233pub struct ProjectSection {
234 pub name: Option<String>,
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct ApplyConfig {
241 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
248 pub conflict_policy: std::collections::HashMap<String, String>,
249}
250
251impl ApplyConfig {
252 pub fn policy_for(&self, rel_path: &str) -> Option<&str> {
257 if self.conflict_policy.is_empty() {
258 return None;
259 }
260 if let Some(v) = self.conflict_policy.get(rel_path) {
262 return Some(v.as_str());
263 }
264 let mut best: Option<(&str, usize)> = None;
266 for (pattern, value) in &self.conflict_policy {
267 if pattern == "default" {
268 continue;
269 }
270 if glob_matches(pattern, rel_path) {
271 let specificity = pattern.len();
272 if best.is_none() || specificity > best.unwrap().1 {
273 best = Some((value.as_str(), specificity));
274 }
275 }
276 }
277 if let Some((v, _)) = best {
278 return Some(v);
279 }
280 self.conflict_policy.get("default").map(|s| s.as_str())
282 }
283}
284
285fn glob_matches(pattern: &str, path: &str) -> bool {
287 if pattern.ends_with("/**") || pattern.ends_with("/*") {
288 let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
289 return path.starts_with(&format!("{}/", prefix)) || path.starts_with(prefix);
290 }
291 if let Some(suffix) = pattern.strip_prefix("**/") {
292 return path.ends_with(suffix) || path.contains(&format!("/{}", suffix));
293 }
294 if pattern.contains('*') {
295 let re_pat = pattern.replace('.', "\\.").replace('*', "[^/]*");
297 return regex_lite_match(&re_pat, path);
298 }
299 false
300}
301
302fn regex_lite_match(pattern: &str, text: &str) -> bool {
303 let anchored = format!("^{}$", pattern);
307 let re = anchored;
308 let parts: Vec<&str> = re.split("[^/]*").collect();
310 if parts.len() == 1 {
311 return text == pattern;
312 }
313 let plain = pattern.replace("\\.", ".").replace("[^/]*", "*");
315 let plain_parts: Vec<&str> = plain.split('*').collect();
316 let mut pos = 0;
317 for (i, part) in plain_parts.iter().enumerate() {
318 if part.is_empty() {
319 continue;
320 }
321 if i == 0 {
322 if !text.starts_with(part) {
323 return false;
324 }
325 pos = part.len();
326 } else if i == plain_parts.len() - 1 {
327 return text[pos..].ends_with(part);
328 } else if let Some(idx) = text[pos..].find(part) {
329 pos += idx + part.len();
330 } else {
331 return false;
332 }
333 }
334 true
335}
336
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct TaPathConfig {
343 #[serde(default)]
345 pub project: TaProjectPaths,
346 #[serde(default)]
348 pub local: TaLocalPaths,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct TaProjectPaths {
354 #[serde(default = "default_project_include_paths")]
356 pub include_paths: Vec<String>,
357}
358
359impl Default for TaProjectPaths {
360 fn default() -> Self {
361 Self {
362 include_paths: default_project_include_paths(),
363 }
364 }
365}
366
367fn default_project_include_paths() -> Vec<String> {
368 vec![
371 "workflow.toml".to_string(),
372 "policy.yaml".to_string(),
373 "constitution.toml".to_string(),
374 "memory.toml".to_string(),
375 "bmad.toml".to_string(),
376 "agents/".to_string(),
377 "constitutions/".to_string(),
378 "memory/".to_string(),
379 "templates/".to_string(),
380 "plan_history.jsonl".to_string(),
381 "release-history.json".to_string(),
382 ]
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct TaLocalPaths {
388 #[serde(default = "default_local_exclude_paths")]
390 pub exclude_paths: Vec<String>,
391}
392
393impl Default for TaLocalPaths {
394 fn default() -> Self {
395 Self {
396 exclude_paths: default_local_exclude_paths(),
397 }
398 }
399}
400
401fn default_local_exclude_paths() -> Vec<String> {
402 vec![
405 "daemon.toml".to_string(),
406 "daemon.local.toml".to_string(),
407 "workflow.local.toml".to_string(),
408 "local.workflow.toml".to_string(), "memory.rvf".to_string(),
410 "staging/".to_string(),
411 "store/".to_string(),
412 "goals/".to_string(),
413 "events/".to_string(),
414 "sessions/".to_string(),
415 "release.lock".to_string(),
416 "velocity-stats.jsonl".to_string(),
417 "audit-ledger.jsonl".to_string(),
418 "taignore".to_string(),
419 "interactions/".to_string(),
420 ]
421}
422
423#[derive(Debug, Clone, Default, Serialize, Deserialize)]
434pub struct ConstitutionConfig {
435 #[serde(default)]
440 pub s4_scan: bool,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct SandboxConfig {
464 #[serde(default)]
466 pub enabled: bool,
467
468 #[serde(default = "default_sandbox_provider")]
470 pub provider: String,
471
472 #[serde(default)]
474 pub allow_read: Vec<String>,
475
476 #[serde(default)]
478 pub allow_write: Vec<String>,
479
480 #[serde(default)]
483 pub allow_network: Vec<String>,
484}
485
486fn default_sandbox_provider() -> String {
487 "native".to_string()
488}
489
490impl Default for SandboxConfig {
491 fn default() -> Self {
492 Self {
493 enabled: false,
494 provider: default_sandbox_provider(),
495 allow_read: Vec::new(),
496 allow_write: Vec::new(),
497 allow_network: Vec::new(),
498 }
499 }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct AuditConfig {
512 #[serde(default)]
515 pub attestation: bool,
516
517 #[serde(default = "default_keys_dir")]
520 pub keys_dir: String,
521}
522
523fn default_keys_dir() -> String {
524 ".ta/keys".to_string()
525}
526
527impl Default for AuditConfig {
528 fn default() -> Self {
529 Self {
530 attestation: false,
531 keys_dir: default_keys_dir(),
532 }
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct GovernanceConfig {
550 #[serde(default = "default_require_approvals")]
553 pub require_approvals: usize,
554
555 #[serde(default)]
558 pub approvers: Vec<String>,
559
560 #[serde(default)]
563 pub override_identity: Option<String>,
564}
565
566fn default_require_approvals() -> usize {
567 1
568}
569
570impl Default for GovernanceConfig {
571 fn default() -> Self {
572 Self {
573 require_approvals: default_require_approvals(),
574 approvers: Vec::new(),
575 override_identity: None,
576 }
577 }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct VcsAgentConfig {
594 #[serde(default = "default_git_mode")]
603 pub git_mode: String,
604
605 #[serde(default = "default_p4_mode")]
613 pub p4_mode: String,
614
615 #[serde(default = "default_true")]
621 pub init_baseline_commit: bool,
622
623 #[serde(default = "default_true")]
629 pub ceiling_always: bool,
630}
631
632fn default_git_mode() -> String {
633 "isolated".to_string()
634}
635fn default_p4_mode() -> String {
636 "shelve".to_string()
637}
638fn default_true() -> bool {
639 true
640}
641
642impl Default for VcsAgentConfig {
643 fn default() -> Self {
644 Self {
645 git_mode: default_git_mode(),
646 p4_mode: default_p4_mode(),
647 init_baseline_commit: true,
648 ceiling_always: true,
649 }
650 }
651}
652
653#[derive(Debug, Clone, Default, Serialize, Deserialize)]
655pub struct VcsConfig {
656 #[serde(default)]
658 pub agent: VcsAgentConfig,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct PlanConfig {
671 #[serde(default = "default_plan_file")]
673 pub file: String,
674}
675
676impl Default for PlanConfig {
677 fn default() -> Self {
678 Self {
679 file: default_plan_file(),
680 }
681 }
682}
683
684fn default_plan_file() -> String {
685 "PLAN.md".to_string()
686}
687
688pub fn resolve_plan_path(
693 workspace_root: &std::path::Path,
694 config: &WorkflowConfig,
695) -> std::path::PathBuf {
696 workspace_root.join(&config.plan.file)
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct SupervisorConfig {
718 #[serde(default = "default_supervisor_enabled")]
720 pub enabled: bool,
721
722 #[serde(default = "default_supervisor_agent")]
728 pub agent: String,
729
730 #[serde(default = "default_verdict_on_block")]
733 pub verdict_on_block: String,
734
735 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub constitution_path: Option<std::path::PathBuf>,
739
740 #[serde(default = "default_supervisor_skip_no_constitution")]
742 pub skip_if_no_constitution: bool,
743
744 #[serde(default = "default_supervisor_heartbeat_stale_secs")]
750 pub heartbeat_stale_secs: u64,
751
752 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub timeout_secs: Option<u64>,
757
758 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub api_key_env: Option<String>,
764
765 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub agent_profile: Option<String>,
770
771 #[serde(default)]
778 pub enable_hooks: bool,
779}
780
781fn default_supervisor_enabled() -> bool {
782 true
783}
784fn default_supervisor_agent() -> String {
785 "builtin".to_string()
786}
787fn default_verdict_on_block() -> String {
788 "warn".to_string()
789}
790fn default_supervisor_heartbeat_stale_secs() -> u64 {
791 30
792}
793fn default_supervisor_skip_no_constitution() -> bool {
794 true
795}
796
797impl Default for SupervisorConfig {
798 fn default() -> Self {
799 Self {
800 enabled: default_supervisor_enabled(),
801 agent: default_supervisor_agent(),
802 verdict_on_block: default_verdict_on_block(),
803 constitution_path: None,
804 skip_if_no_constitution: default_supervisor_skip_no_constitution(),
805 heartbeat_stale_secs: default_supervisor_heartbeat_stale_secs(),
806 timeout_secs: None,
807 api_key_env: None,
808 agent_profile: None,
809 enable_hooks: false,
810 }
811 }
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, Default)]
823pub struct AgentProfile {
824 #[serde(default)]
826 pub framework: String,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub model: Option<String>,
830}
831
832#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct AssetDiffConfig {
848 #[serde(default = "default_asset_diff_enabled")]
850 pub enabled: bool,
851
852 #[serde(default)]
854 pub visual_diff: bool,
855
856 #[serde(default = "default_visual_diff_threshold")]
858 pub visual_diff_threshold: f32,
859
860 #[serde(default = "default_asset_diff_supervisor")]
862 pub supervisor: bool,
863
864 #[serde(default = "default_asset_diff_agent")]
866 pub agent: String,
867
868 #[serde(default = "default_asset_diff_timeout")]
870 pub timeout_secs: u64,
871}
872
873fn default_asset_diff_enabled() -> bool {
874 true
875}
876fn default_visual_diff_threshold() -> f32 {
877 0.3
878}
879fn default_asset_diff_supervisor() -> bool {
880 true
881}
882fn default_asset_diff_agent() -> String {
883 "builtin".to_string()
884}
885fn default_asset_diff_timeout() -> u64 {
886 60
887}
888
889impl Default for AssetDiffConfig {
890 fn default() -> Self {
891 Self {
892 enabled: default_asset_diff_enabled(),
893 visual_diff: false,
894 visual_diff_threshold: default_visual_diff_threshold(),
895 supervisor: default_asset_diff_supervisor(),
896 agent: default_asset_diff_agent(),
897 timeout_secs: default_asset_diff_timeout(),
898 }
899 }
900}
901
902#[derive(Debug, Clone, Default, Serialize, Deserialize)]
904pub struct DraftReviewConfig {
905 #[serde(default)]
907 pub asset_diff: AssetDiffConfig,
908
909 #[serde(default)]
921 pub approval_required: bool,
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
941#[serde(rename_all = "lowercase")]
942pub enum ContextMode {
943 #[default]
945 Inject,
946 Mcp,
948 Hybrid,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
961pub struct WorkflowSection {
962 #[serde(default = "default_enforce_phase_order")]
970 pub enforce_phase_order: String,
971
972 #[serde(default = "default_context_budget_chars")]
985 pub context_budget_chars: usize,
986
987 #[serde(default = "default_plan_done_window")]
992 pub plan_done_window: usize,
993
994 #[serde(default = "default_plan_pending_window")]
999 pub plan_pending_window: usize,
1000
1001 #[serde(default)]
1013 pub context_mode: ContextMode,
1014}
1015
1016fn default_enforce_phase_order() -> String {
1017 "warn".to_string()
1018}
1019
1020fn default_context_budget_chars() -> usize {
1021 40_000
1022}
1023
1024fn default_plan_done_window() -> usize {
1025 5
1026}
1027
1028fn default_plan_pending_window() -> usize {
1029 5
1030}
1031
1032impl Default for WorkflowSection {
1033 fn default() -> Self {
1034 Self {
1035 enforce_phase_order: default_enforce_phase_order(),
1036 context_budget_chars: default_context_budget_chars(),
1037 plan_done_window: default_plan_done_window(),
1038 plan_pending_window: default_plan_pending_window(),
1039 context_mode: ContextMode::default(),
1040 }
1041 }
1042}
1043
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1046pub struct SubmitConfig {
1047 #[serde(default = "default_adapter")]
1049 pub adapter: String,
1050
1051 #[serde(default)]
1055 pub auto_submit: Option<bool>,
1056
1057 #[serde(default)]
1060 pub auto_review: Option<bool>,
1061
1062 #[serde(default = "default_co_author")]
1067 pub co_author: String,
1068
1069 #[serde(default)]
1071 pub git: GitConfig,
1072
1073 #[serde(default)]
1075 pub perforce: PerforceConfig,
1076
1077 #[serde(default)]
1079 pub svn: SvnConfig,
1080}
1081
1082impl SubmitConfig {
1083 pub fn effective_auto_submit(&self) -> bool {
1089 self.auto_submit.unwrap_or(self.adapter != "none")
1090 }
1091
1092 pub fn effective_auto_review(&self) -> bool {
1096 self.auto_review.unwrap_or(self.adapter != "none")
1097 }
1098}
1099
1100impl Default for SubmitConfig {
1101 fn default() -> Self {
1102 Self {
1103 adapter: default_adapter(),
1104 auto_submit: None,
1105 auto_review: None,
1106 co_author: default_co_author(),
1107 git: GitConfig::default(),
1108 perforce: PerforceConfig::default(),
1109 svn: SvnConfig::default(),
1110 }
1111 }
1112}
1113
1114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1116pub struct PerforceConfig {
1117 pub workspace: Option<String>,
1119
1120 #[serde(default = "default_shelve")]
1122 pub shelve_by_default: bool,
1123}
1124
1125fn default_shelve() -> bool {
1126 true
1127}
1128
1129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1131pub struct SvnConfig {
1132 pub repo_url: Option<String>,
1134}
1135
1136#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1141pub struct SourceConfig {
1142 #[serde(default)]
1144 pub sync: SyncConfig,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct SyncConfig {
1150 #[serde(default)]
1153 pub auto_sync: bool,
1154
1155 #[serde(default = "default_sync_strategy")]
1158 pub strategy: String,
1159
1160 #[serde(default = "default_remote")]
1162 pub remote: String,
1163
1164 #[serde(default = "default_sync_branch")]
1166 pub branch: String,
1167}
1168
1169impl Default for SyncConfig {
1170 fn default() -> Self {
1171 Self {
1172 auto_sync: false,
1173 strategy: default_sync_strategy(),
1174 remote: default_remote(),
1175 branch: default_sync_branch(),
1176 }
1177 }
1178}
1179
1180fn default_sync_strategy() -> String {
1181 "merge".to_string()
1182}
1183
1184fn default_sync_branch() -> String {
1185 "main".to_string()
1186}
1187
1188#[derive(Debug, Clone, Serialize, Deserialize)]
1190pub struct GitConfig {
1191 #[serde(default = "default_branch_prefix")]
1193 pub branch_prefix: String,
1194
1195 #[serde(default = "default_target_branch")]
1197 pub target_branch: String,
1198
1199 #[serde(default = "default_merge_strategy")]
1201 pub merge_strategy: String,
1202
1203 pub pr_template: Option<PathBuf>,
1205
1206 #[serde(default = "default_remote")]
1208 pub remote: String,
1209
1210 #[serde(default)]
1213 pub auto_merge: bool,
1214
1215 #[serde(default)]
1218 pub protected_branches: Vec<String>,
1219}
1220
1221impl Default for GitConfig {
1222 fn default() -> Self {
1223 Self {
1224 branch_prefix: default_branch_prefix(),
1225 target_branch: default_target_branch(),
1226 merge_strategy: default_merge_strategy(),
1227 pr_template: None,
1228 remote: default_remote(),
1229 auto_merge: false,
1230 protected_branches: vec![],
1231 }
1232 }
1233}
1234
1235fn default_adapter() -> String {
1237 "none".to_string()
1238}
1239
1240fn default_co_author() -> String {
1241 "Trusted Autonomy <266386695+trustedautonomy-agent@users.noreply.github.com>".to_string()
1242}
1243
1244fn default_branch_prefix() -> String {
1245 "ta/".to_string()
1246}
1247
1248fn default_target_branch() -> String {
1249 "main".to_string()
1250}
1251
1252fn default_merge_strategy() -> String {
1253 "squash".to_string()
1254}
1255
1256fn default_remote() -> String {
1257 "origin".to_string()
1258}
1259
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1262pub struct DiffConfig {
1263 #[serde(default = "default_open_external")]
1265 pub open_external: bool,
1266
1267 pub handlers_file: Option<PathBuf>,
1269}
1270
1271impl Default for DiffConfig {
1272 fn default() -> Self {
1273 Self {
1274 open_external: default_open_external(),
1275 handlers_file: None,
1276 }
1277 }
1278}
1279
1280fn default_open_external() -> bool {
1281 true
1282}
1283
1284#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum BuildOnFail {
1288 #[default]
1290 Notify,
1291 BlockRelease,
1293 BlockNextPhase,
1295 Agent,
1297}
1298
1299impl std::fmt::Display for BuildOnFail {
1300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1301 match self {
1302 Self::Notify => write!(f, "notify"),
1303 Self::BlockRelease => write!(f, "block_release"),
1304 Self::BlockNextPhase => write!(f, "block_next_phase"),
1305 Self::Agent => write!(f, "agent"),
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1312pub struct BuildConfig {
1313 #[serde(default = "default_summary_enforcement")]
1320 pub summary_enforcement: String,
1321
1322 #[serde(default = "default_build_adapter")]
1324 pub adapter: String,
1325
1326 #[serde(default)]
1328 pub command: Option<String>,
1329
1330 #[serde(default)]
1332 pub test_command: Option<String>,
1333
1334 #[serde(default)]
1336 pub webhook_url: Option<String>,
1337
1338 #[serde(default)]
1340 pub on_fail: BuildOnFail,
1341
1342 #[serde(default = "default_build_timeout")]
1344 pub timeout_secs: u64,
1345}
1346
1347impl Default for BuildConfig {
1348 fn default() -> Self {
1349 Self {
1350 summary_enforcement: default_summary_enforcement(),
1351 adapter: default_build_adapter(),
1352 command: None,
1353 test_command: None,
1354 webhook_url: None,
1355 on_fail: BuildOnFail::default(),
1356 timeout_secs: default_build_timeout(),
1357 }
1358 }
1359}
1360
1361fn default_summary_enforcement() -> String {
1362 "warning".to_string()
1363}
1364
1365fn default_build_adapter() -> String {
1366 "auto".to_string()
1367}
1368
1369fn default_build_timeout() -> u64 {
1370 600
1371}
1372
1373#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1375pub struct DisplayConfig {
1376 #[serde(default)]
1379 pub color: bool,
1380}
1381
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct GcConfig {
1385 #[serde(default = "default_stale_threshold_days")]
1388 pub stale_threshold_days: u64,
1389
1390 #[serde(default = "default_stale_hint_days")]
1394 pub stale_hint_days: u64,
1395
1396 #[serde(default = "default_health_check")]
1398 pub health_check: bool,
1399}
1400
1401impl Default for GcConfig {
1402 fn default() -> Self {
1403 Self {
1404 stale_threshold_days: default_stale_threshold_days(),
1405 stale_hint_days: default_stale_hint_days(),
1406 health_check: default_health_check(),
1407 }
1408 }
1409}
1410
1411fn default_stale_threshold_days() -> u64 {
1412 7
1413}
1414
1415fn default_stale_hint_days() -> u64 {
1416 3
1417}
1418
1419#[derive(Debug, Clone, Serialize, Deserialize)]
1421pub struct FollowUpConfig {
1422 #[serde(default = "default_follow_up_mode")]
1424 pub default_mode: String,
1425
1426 #[serde(default = "default_auto_supersede")]
1428 pub auto_supersede: bool,
1429
1430 #[serde(default = "default_rebase_on_apply")]
1432 pub rebase_on_apply: bool,
1433}
1434
1435impl Default for FollowUpConfig {
1436 fn default() -> Self {
1437 Self {
1438 default_mode: default_follow_up_mode(),
1439 auto_supersede: default_auto_supersede(),
1440 rebase_on_apply: default_rebase_on_apply(),
1441 }
1442 }
1443}
1444
1445fn default_follow_up_mode() -> String {
1446 "extend".to_string()
1447}
1448
1449fn default_auto_supersede() -> bool {
1450 true
1451}
1452
1453fn default_rebase_on_apply() -> bool {
1454 true
1455}
1456
1457fn default_health_check() -> bool {
1458 true
1459}
1460
1461#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1463#[serde(rename_all = "lowercase")]
1464pub enum VerifyOnFailure {
1465 #[default]
1467 Block,
1468 Warn,
1470 Agent,
1472}
1473
1474impl std::fmt::Display for VerifyOnFailure {
1475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1476 match self {
1477 Self::Block => write!(f, "block"),
1478 Self::Warn => write!(f, "warn"),
1479 Self::Agent => write!(f, "agent"),
1480 }
1481 }
1482}
1483
1484#[derive(Debug, Clone, Serialize, Deserialize)]
1489pub struct VerifyCommand {
1490 pub run: String,
1492
1493 pub timeout_secs: Option<u64>,
1495}
1496
1497#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct VerifyConfig {
1507 #[serde(default, deserialize_with = "deserialize_verify_commands")]
1510 pub commands: Vec<VerifyCommand>,
1511
1512 #[serde(default)]
1514 pub on_failure: VerifyOnFailure,
1515
1516 #[serde(default = "default_verify_timeout")]
1519 pub timeout: u64,
1520
1521 pub default_timeout_secs: Option<u64>,
1524
1525 #[serde(default = "default_heartbeat_interval")]
1528 pub heartbeat_interval_secs: u64,
1529}
1530
1531impl VerifyConfig {
1532 pub fn effective_default_timeout(&self) -> u64 {
1534 self.default_timeout_secs.unwrap_or(self.timeout)
1535 }
1536
1537 pub fn command_timeout(&self, cmd: &VerifyCommand) -> u64 {
1539 cmd.timeout_secs
1540 .unwrap_or_else(|| self.effective_default_timeout())
1541 }
1542}
1543
1544impl Default for VerifyConfig {
1545 fn default() -> Self {
1546 Self {
1547 commands: Vec::new(),
1548 on_failure: VerifyOnFailure::default(),
1549 timeout: default_verify_timeout(),
1550 default_timeout_secs: None,
1551 heartbeat_interval_secs: default_heartbeat_interval(),
1552 }
1553 }
1554}
1555
1556fn default_verify_timeout() -> u64 {
1557 300
1558}
1559
1560fn default_heartbeat_interval() -> u64 {
1561 30
1562}
1563
1564fn deserialize_verify_commands<'de, D>(deserializer: D) -> Result<Vec<VerifyCommand>, D::Error>
1566where
1567 D: serde::Deserializer<'de>,
1568{
1569 #[derive(Deserialize)]
1570 #[serde(untagged)]
1571 enum CommandItem {
1572 Simple(String),
1573 Structured(VerifyCommand),
1574 }
1575
1576 let items: Vec<CommandItem> = Vec::deserialize(deserializer)?;
1577 Ok(items
1578 .into_iter()
1579 .map(|item| match item {
1580 CommandItem::Simple(s) => VerifyCommand {
1581 run: s,
1582 timeout_secs: None,
1583 },
1584 CommandItem::Structured(c) => c,
1585 })
1586 .collect())
1587}
1588
1589#[derive(Debug, Clone, Serialize, Deserialize)]
1591pub struct ShellConfig {
1592 #[serde(default = "default_tail_backfill_lines")]
1594 pub tail_backfill_lines: usize,
1595
1596 #[serde(default = "default_output_buffer_lines")]
1599 pub output_buffer_lines: usize,
1600
1601 #[serde(default)]
1604 pub scrollback_lines: Option<usize>,
1605
1606 #[serde(default = "default_auto_tail")]
1608 pub auto_tail: bool,
1609}
1610
1611impl ShellConfig {
1612 pub fn effective_scrollback(&self) -> usize {
1615 let raw = self.scrollback_lines.unwrap_or(self.output_buffer_lines);
1616 raw.max(10_000)
1617 }
1618}
1619
1620impl Default for ShellConfig {
1621 fn default() -> Self {
1622 Self {
1623 tail_backfill_lines: default_tail_backfill_lines(),
1624 output_buffer_lines: default_output_buffer_lines(),
1625 scrollback_lines: None,
1626 auto_tail: default_auto_tail(),
1627 }
1628 }
1629}
1630
1631fn default_tail_backfill_lines() -> usize {
1632 5
1633}
1634
1635fn default_output_buffer_lines() -> usize {
1636 50000
1637}
1638
1639fn default_auto_tail() -> bool {
1640 true
1641}
1642
1643#[derive(Debug, Clone, Serialize, Deserialize)]
1648pub struct NotifyConfig {
1649 #[serde(default = "default_notify_enabled")]
1651 pub enabled: bool,
1652
1653 #[serde(default = "default_notify_title")]
1655 pub title: String,
1656}
1657
1658impl Default for NotifyConfig {
1659 fn default() -> Self {
1660 Self {
1661 enabled: default_notify_enabled(),
1662 title: default_notify_title(),
1663 }
1664 }
1665}
1666
1667fn default_notify_enabled() -> bool {
1668 true
1669}
1670
1671fn default_notify_title() -> String {
1672 "TA".to_string()
1673}
1674
1675#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1689#[serde(rename_all = "kebab-case")]
1690pub enum StagingStrategy {
1691 #[default]
1693 Full,
1694 Smart,
1696 RefsCow,
1698 ProjFs,
1704}
1705
1706impl StagingStrategy {
1707 pub fn as_str(&self) -> &'static str {
1708 match self {
1709 Self::Full => "full",
1710 Self::Smart => "smart",
1711 Self::RefsCow => "refs-cow",
1712 Self::ProjFs => "projfs",
1713 }
1714 }
1715}
1716
1717#[derive(Debug, Clone, Serialize, Deserialize)]
1719pub struct StagingConfig {
1720 #[serde(default = "default_auto_clean")]
1722 pub auto_clean: bool,
1723 #[serde(default = "default_min_disk_mb")]
1725 pub min_disk_mb: u64,
1726 #[serde(default)]
1728 pub strategy: StagingStrategy,
1729}
1730
1731impl Default for StagingConfig {
1732 fn default() -> Self {
1733 Self {
1734 auto_clean: default_auto_clean(),
1735 min_disk_mb: default_min_disk_mb(),
1736 strategy: StagingStrategy::Full,
1737 }
1738 }
1739}
1740
1741fn default_auto_clean() -> bool {
1742 true
1743}
1744fn default_min_disk_mb() -> u64 {
1745 2048
1746}
1747
1748pub fn check_disk_space_mb(path: &std::path::Path) -> Result<u64, String> {
1750 #[cfg(unix)]
1751 {
1752 use std::os::unix::ffi::OsStrExt;
1753 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
1754 .map_err(|e| format!("invalid path: {}", e))?;
1755 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1756 let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
1757 if rc != 0 {
1758 return Err(format!(
1759 "statvfs failed for {}: {}",
1760 path.display(),
1761 std::io::Error::last_os_error()
1762 ));
1763 }
1764 Ok((stat.f_bavail as u64) * (stat.f_frsize as u64) / (1024 * 1024))
1765 }
1766 #[cfg(not(unix))]
1767 {
1768 let _ = path;
1769 Ok(u64::MAX)
1770 }
1771}
1772
1773#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1791pub struct SecurityConfig {
1792 #[serde(default)]
1798 pub level: ta_goal::SecurityLevel,
1799
1800 #[serde(default, skip_serializing_if = "Option::is_none")]
1802 pub secret_scan: Option<ta_goal::SecretScanMode>,
1803
1804 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1806 pub extra_forbidden_tools: Vec<String>,
1807}
1808
1809impl SecurityConfig {
1810 pub fn to_overrides(&self) -> ta_goal::SecurityOverrides {
1812 ta_goal::SecurityOverrides {
1813 secret_scan_mode: self.secret_scan,
1814 extra_forbidden_tools: self.extra_forbidden_tools.clone(),
1815 ..Default::default()
1816 }
1817 }
1818}
1819
1820impl WorkflowConfig {
1821 pub fn load(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
1827 let content = std::fs::read_to_string(path)?;
1828 let mut base: toml::Value = toml::from_str(&content)?;
1829
1830 let dir = path.parent().unwrap_or(std::path::Path::new("."));
1831 let canonical_local = dir.join("workflow.local.toml");
1832 let deprecated_local = dir.join("local.workflow.toml");
1833
1834 let local_path = if canonical_local.exists() {
1835 Some(canonical_local)
1836 } else if deprecated_local.exists() {
1837 tracing::warn!(
1838 path = %deprecated_local.display(),
1839 "'.ta/local.workflow.toml' is deprecated — rename to '.ta/workflow.local.toml'"
1840 );
1841 Some(deprecated_local)
1842 } else {
1843 None
1844 };
1845
1846 if let Some(local) = local_path {
1847 let local_content = std::fs::read_to_string(&local)?;
1848 let local_val: toml::Value = toml::from_str(&local_content)?;
1849 merge_toml_values(&mut base, local_val);
1850 }
1851
1852 let config = base.try_into()?;
1853 Ok(config)
1854 }
1855
1856 pub fn load_or_default(path: &std::path::Path) -> Self {
1858 Self::load(path).unwrap_or_default()
1859 }
1860}
1861
1862fn merge_toml_values(base: &mut toml::Value, overrides: toml::Value) {
1865 match (base, overrides) {
1866 (toml::Value::Table(base_map), toml::Value::Table(override_map)) => {
1867 for (k, v) in override_map {
1868 let entry = base_map
1869 .entry(k)
1870 .or_insert(toml::Value::Table(Default::default()));
1871 merge_toml_values(entry, v);
1872 }
1873 }
1874 (base, overrides) => *base = overrides,
1875 }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880 use super::*;
1881
1882 #[test]
1883 fn build_config_defaults_to_warning() {
1884 let config = BuildConfig::default();
1885 assert_eq!(config.summary_enforcement, "warning");
1886 }
1887
1888 #[test]
1889 fn build_config_defaults() {
1890 let config = BuildConfig::default();
1891 assert_eq!(config.adapter, "auto");
1892 assert!(config.command.is_none());
1893 assert!(config.test_command.is_none());
1894 assert!(config.webhook_url.is_none());
1895 assert_eq!(config.on_fail, BuildOnFail::Notify);
1896 assert_eq!(config.timeout_secs, 600);
1897 }
1898
1899 #[test]
1900 fn workflow_config_default_has_build_section() {
1901 let config = WorkflowConfig::default();
1902 assert_eq!(config.build.summary_enforcement, "warning");
1903 assert_eq!(config.build.adapter, "auto");
1904 }
1905
1906 #[test]
1907 fn parse_toml_with_build_section() {
1908 let toml = r#"
1909[build]
1910summary_enforcement = "error"
1911"#;
1912 let config: WorkflowConfig = toml::from_str(toml).unwrap();
1913 assert_eq!(config.build.summary_enforcement, "error");
1914 }
1915
1916 #[test]
1917 fn parse_toml_with_build_adapter_config() {
1918 let toml = r#"
1919[build]
1920adapter = "cargo"
1921command = "cargo build --release"
1922test_command = "cargo test --release"
1923on_fail = "block_release"
1924timeout_secs = 1200
1925"#;
1926 let config: WorkflowConfig = toml::from_str(toml).unwrap();
1927 assert_eq!(config.build.adapter, "cargo");
1928 assert_eq!(
1929 config.build.command.as_deref(),
1930 Some("cargo build --release")
1931 );
1932 assert_eq!(
1933 config.build.test_command.as_deref(),
1934 Some("cargo test --release")
1935 );
1936 assert_eq!(config.build.on_fail, BuildOnFail::BlockRelease);
1937 assert_eq!(config.build.timeout_secs, 1200);
1938 }
1939
1940 #[test]
1941 fn parse_toml_with_build_script_adapter() {
1942 let toml = r#"
1943[build]
1944adapter = "script"
1945command = "make all"
1946test_command = "make test"
1947on_fail = "agent"
1948"#;
1949 let config: WorkflowConfig = toml::from_str(toml).unwrap();
1950 assert_eq!(config.build.adapter, "script");
1951 assert_eq!(config.build.command.as_deref(), Some("make all"));
1952 assert_eq!(config.build.on_fail, BuildOnFail::Agent);
1953 }
1954
1955 #[test]
1956 fn parse_toml_without_build_section_uses_default() {
1957 let toml = r#"
1958[submit]
1959adapter = "git"
1960"#;
1961 let config: WorkflowConfig = toml::from_str(toml).unwrap();
1962 assert_eq!(config.build.summary_enforcement, "warning");
1963 assert_eq!(config.build.adapter, "auto");
1964 }
1965
1966 #[test]
1967 fn build_on_fail_display() {
1968 assert_eq!(BuildOnFail::Notify.to_string(), "notify");
1969 assert_eq!(BuildOnFail::BlockRelease.to_string(), "block_release");
1970 assert_eq!(BuildOnFail::BlockNextPhase.to_string(), "block_next_phase");
1971 assert_eq!(BuildOnFail::Agent.to_string(), "agent");
1972 }
1973
1974 #[test]
1975 fn gc_config_defaults() {
1976 let config = GcConfig::default();
1977 assert_eq!(config.stale_threshold_days, 7);
1978 assert_eq!(config.stale_hint_days, 3);
1979 assert!(config.health_check);
1980 }
1981
1982 #[test]
1983 fn workflow_config_default_has_gc_section() {
1984 let config = WorkflowConfig::default();
1985 assert_eq!(config.gc.stale_threshold_days, 7);
1986 assert_eq!(config.gc.stale_hint_days, 3);
1987 assert!(config.gc.health_check);
1988 }
1989
1990 #[test]
1991 fn parse_toml_with_gc_section() {
1992 let toml = r#"
1993[gc]
1994stale_threshold_days = 14
1995stale_hint_days = 5
1996health_check = false
1997"#;
1998 let config: WorkflowConfig = toml::from_str(toml).unwrap();
1999 assert_eq!(config.gc.stale_threshold_days, 14);
2000 assert_eq!(config.gc.stale_hint_days, 5);
2001 assert!(!config.gc.health_check);
2002 }
2003
2004 #[test]
2005 fn load_or_default_returns_default_for_missing_file() {
2006 let config = WorkflowConfig::load_or_default(std::path::Path::new("/nonexistent/path"));
2007 assert_eq!(config.build.summary_enforcement, "warning");
2008 assert_eq!(config.submit.adapter, "none");
2009 }
2010
2011 #[test]
2012 fn follow_up_config_defaults() {
2013 let config = FollowUpConfig::default();
2014 assert_eq!(config.default_mode, "extend");
2015 assert!(config.auto_supersede);
2016 assert!(config.rebase_on_apply);
2017 }
2018
2019 #[test]
2020 fn workflow_config_default_has_follow_up_section() {
2021 let config = WorkflowConfig::default();
2022 assert_eq!(config.follow_up.default_mode, "extend");
2023 assert!(config.follow_up.auto_supersede);
2024 assert!(config.follow_up.rebase_on_apply);
2025 }
2026
2027 #[test]
2028 fn parse_toml_with_follow_up_section() {
2029 let toml = r#"
2030[follow_up]
2031default_mode = "standalone"
2032auto_supersede = false
2033rebase_on_apply = false
2034"#;
2035 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2036 assert_eq!(config.follow_up.default_mode, "standalone");
2037 assert!(!config.follow_up.auto_supersede);
2038 assert!(!config.follow_up.rebase_on_apply);
2039 }
2040
2041 #[test]
2042 fn verify_config_defaults() {
2043 let config = VerifyConfig::default();
2044 assert!(config.commands.is_empty());
2045 assert_eq!(config.on_failure, VerifyOnFailure::Block);
2046 assert_eq!(config.timeout, 300);
2047 assert_eq!(config.heartbeat_interval_secs, 30);
2048 assert!(config.default_timeout_secs.is_none());
2049 assert_eq!(config.effective_default_timeout(), 300);
2050 }
2051
2052 #[test]
2053 fn workflow_config_default_has_verify_section() {
2054 let config = WorkflowConfig::default();
2055 assert!(config.verify.commands.is_empty());
2056 assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
2057 assert_eq!(config.verify.timeout, 300);
2058 }
2059
2060 #[test]
2061 fn parse_toml_with_verify_section() {
2062 let toml = r#"
2063[verify]
2064commands = [
2065 "cargo build --workspace",
2066 "cargo test --workspace",
2067]
2068on_failure = "warn"
2069timeout = 600
2070"#;
2071 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2072 assert_eq!(config.verify.commands.len(), 2);
2073 assert_eq!(config.verify.commands[0].run, "cargo build --workspace");
2074 assert_eq!(config.verify.on_failure, VerifyOnFailure::Warn);
2075 assert_eq!(config.verify.timeout, 600);
2076 }
2077
2078 #[test]
2079 fn parse_toml_with_verify_agent_mode() {
2080 let toml = r#"
2081[verify]
2082commands = ["make test"]
2083on_failure = "agent"
2084"#;
2085 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2086 assert_eq!(config.verify.on_failure, VerifyOnFailure::Agent);
2087 assert_eq!(config.verify.timeout, 300); }
2089
2090 #[test]
2091 fn parse_toml_without_verify_section_uses_default() {
2092 let toml = r#"
2093[submit]
2094adapter = "git"
2095"#;
2096 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2097 assert!(config.verify.commands.is_empty());
2098 assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
2099 }
2100
2101 #[test]
2102 fn parse_toml_with_per_command_timeout() {
2103 let toml = r#"
2104[verify]
2105default_timeout_secs = 300
2106heartbeat_interval_secs = 15
2107
2108[[verify.commands]]
2109run = "cargo fmt --all -- --check"
2110timeout_secs = 60
2111
2112[[verify.commands]]
2113run = "cargo test --workspace"
2114timeout_secs = 900
2115"#;
2116 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2117 assert_eq!(config.verify.commands.len(), 2);
2118 assert_eq!(config.verify.commands[0].run, "cargo fmt --all -- --check");
2119 assert_eq!(config.verify.commands[0].timeout_secs, Some(60));
2120 assert_eq!(config.verify.commands[1].run, "cargo test --workspace");
2121 assert_eq!(config.verify.commands[1].timeout_secs, Some(900));
2122 assert_eq!(config.verify.default_timeout_secs, Some(300));
2123 assert_eq!(config.verify.heartbeat_interval_secs, 15);
2124 assert_eq!(config.verify.effective_default_timeout(), 300);
2125 assert_eq!(
2126 config.verify.command_timeout(&config.verify.commands[0]),
2127 60
2128 );
2129 assert_eq!(
2130 config.verify.command_timeout(&config.verify.commands[1]),
2131 900
2132 );
2133 }
2134
2135 #[test]
2136 fn per_command_timeout_falls_back_to_default() {
2137 let config = VerifyConfig {
2138 commands: vec![VerifyCommand {
2139 run: "test".to_string(),
2140 timeout_secs: None,
2141 }],
2142 default_timeout_secs: Some(600),
2143 ..Default::default()
2144 };
2145 assert_eq!(config.command_timeout(&config.commands[0]), 600);
2146 }
2147
2148 #[test]
2149 fn effective_timeout_falls_back_to_legacy() {
2150 let config = VerifyConfig {
2151 timeout: 900,
2152 default_timeout_secs: None,
2153 ..Default::default()
2154 };
2155 assert_eq!(config.effective_default_timeout(), 900);
2156 }
2157
2158 #[test]
2159 fn verify_on_failure_display() {
2160 assert_eq!(VerifyOnFailure::Block.to_string(), "block");
2161 assert_eq!(VerifyOnFailure::Warn.to_string(), "warn");
2162 assert_eq!(VerifyOnFailure::Agent.to_string(), "agent");
2163 }
2164
2165 #[test]
2166 fn shell_config_defaults() {
2167 let config = ShellConfig::default();
2168 assert_eq!(config.tail_backfill_lines, 5);
2169 assert_eq!(config.output_buffer_lines, 50000);
2170 assert!(config.scrollback_lines.is_none());
2171 assert!(config.auto_tail);
2172 assert_eq!(config.effective_scrollback(), 50000);
2173 }
2174
2175 #[test]
2176 fn workflow_config_default_has_shell_section() {
2177 let config = WorkflowConfig::default();
2178 assert_eq!(config.shell.tail_backfill_lines, 5);
2179 assert_eq!(config.shell.output_buffer_lines, 50000);
2180 assert!(config.shell.auto_tail);
2181 }
2182
2183 #[test]
2184 fn parse_toml_with_shell_section() {
2185 let toml = r#"
2186[shell]
2187tail_backfill_lines = 20
2188output_buffer_lines = 5000
2189auto_tail = false
2190"#;
2191 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2192 assert_eq!(config.shell.tail_backfill_lines, 20);
2193 assert_eq!(config.shell.output_buffer_lines, 5000);
2194 assert!(!config.shell.auto_tail);
2195 }
2196
2197 #[test]
2198 fn parse_toml_without_shell_section_uses_default() {
2199 let toml = r#"
2200[submit]
2201adapter = "git"
2202"#;
2203 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2204 assert_eq!(config.shell.tail_backfill_lines, 5);
2205 assert_eq!(config.shell.output_buffer_lines, 50000);
2206 assert!(config.shell.auto_tail);
2207 }
2208
2209 #[test]
2212 fn effective_auto_submit_defaults_true_when_adapter_set() {
2213 let config = SubmitConfig {
2214 adapter: "git".to_string(),
2215 ..Default::default()
2216 };
2217 assert!(config.effective_auto_submit());
2218 }
2219
2220 #[test]
2221 fn effective_auto_submit_defaults_false_when_no_adapter() {
2222 let config = SubmitConfig::default(); assert!(!config.effective_auto_submit());
2224 }
2225
2226 #[test]
2227 fn effective_auto_submit_explicit_override() {
2228 let config = SubmitConfig {
2229 adapter: "git".to_string(),
2230 auto_submit: Some(false),
2231 ..Default::default()
2232 };
2233 assert!(!config.effective_auto_submit());
2234 }
2235
2236 #[test]
2237 fn effective_auto_review_defaults_true_when_adapter_set() {
2238 let config = SubmitConfig {
2239 adapter: "git".to_string(),
2240 ..Default::default()
2241 };
2242 assert!(config.effective_auto_review());
2243 }
2244
2245 #[test]
2246 fn effective_auto_review_defaults_false_when_no_adapter() {
2247 let config = SubmitConfig::default();
2248 assert!(!config.effective_auto_review());
2249 }
2250
2251 #[test]
2252 fn effective_auto_review_explicit_override() {
2253 let config = SubmitConfig {
2254 adapter: "git".to_string(),
2255 auto_review: Some(false),
2256 ..Default::default()
2257 };
2258 assert!(!config.effective_auto_review());
2259 }
2260
2261 #[test]
2262 fn parse_toml_with_auto_submit() {
2263 let toml = r#"
2264[submit]
2265adapter = "git"
2266auto_submit = true
2267auto_review = false
2268"#;
2269 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2270 assert!(config.submit.effective_auto_submit());
2271 assert!(!config.submit.effective_auto_review());
2272 }
2273
2274 #[test]
2275 fn parse_toml_with_deprecated_auto_commit_auto_push() {
2276 let toml = r#"
2279[submit]
2280adapter = "none"
2281auto_review = true
2282"#;
2283 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2284 assert!(!config.submit.effective_auto_submit());
2285 assert!(config.submit.effective_auto_review());
2286 }
2287
2288 #[test]
2289 fn sync_config_defaults() {
2290 let config = SyncConfig::default();
2291 assert!(!config.auto_sync);
2292 assert_eq!(config.strategy, "merge");
2293 assert_eq!(config.remote, "origin");
2294 assert_eq!(config.branch, "main");
2295 }
2296
2297 #[test]
2298 fn parse_toml_with_source_sync_section() {
2299 let toml = r#"
2300[source.sync]
2301auto_sync = true
2302strategy = "rebase"
2303remote = "upstream"
2304branch = "develop"
2305"#;
2306 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2307 assert!(config.source.sync.auto_sync);
2308 assert_eq!(config.source.sync.strategy, "rebase");
2309 assert_eq!(config.source.sync.remote, "upstream");
2310 assert_eq!(config.source.sync.branch, "develop");
2311 }
2312
2313 #[test]
2314 fn parse_toml_without_source_section_uses_default() {
2315 let toml = r#"
2316[submit]
2317adapter = "git"
2318"#;
2319 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2320 assert!(!config.source.sync.auto_sync);
2321 assert_eq!(config.source.sync.strategy, "merge");
2322 }
2323
2324 #[test]
2325 fn parse_toml_with_adapter_specific_sections() {
2326 let toml = r#"
2327[submit]
2328adapter = "git"
2329
2330[submit.git]
2331branch_prefix = "feature/"
2332target_branch = "develop"
2333remote = "upstream"
2334
2335[submit.perforce]
2336workspace = "my-ws"
2337shelve_by_default = false
2338
2339[submit.svn]
2340repo_url = "svn://example.com/trunk"
2341"#;
2342 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2343 assert_eq!(config.submit.git.branch_prefix, "feature/");
2344 assert_eq!(config.submit.git.target_branch, "develop");
2345 assert_eq!(config.submit.git.remote, "upstream");
2346 assert_eq!(config.submit.perforce.workspace.as_deref(), Some("my-ws"));
2347 assert!(!config.submit.perforce.shelve_by_default);
2348 assert_eq!(
2349 config.submit.svn.repo_url.as_deref(),
2350 Some("svn://example.com/trunk")
2351 );
2352 }
2353
2354 #[test]
2355 fn git_config_auto_merge_default_false() {
2356 let config = GitConfig::default();
2357 assert!(!config.auto_merge);
2358 }
2359
2360 #[test]
2361 fn git_config_auto_merge_from_toml() {
2362 let toml = r#"
2363[submit.git]
2364auto_merge = true
2365"#;
2366 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2367 assert!(config.submit.git.auto_merge);
2368 }
2369
2370 #[test]
2371 fn sandbox_config_defaults() {
2372 let config = SandboxConfig::default();
2373 assert!(!config.enabled);
2374 assert_eq!(config.provider, "native");
2375 assert!(config.allow_read.is_empty());
2376 assert!(config.allow_write.is_empty());
2377 assert!(config.allow_network.is_empty());
2378 }
2379
2380 #[test]
2381 fn sandbox_config_from_toml() {
2382 let toml = r#"
2383[sandbox]
2384enabled = true
2385provider = "native"
2386allow_read = ["/usr/lib"]
2387allow_write = ["/tmp/scratch"]
2388allow_network = ["api.anthropic.com"]
2389"#;
2390 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2391 assert!(config.sandbox.enabled);
2392 assert_eq!(config.sandbox.provider, "native");
2393 assert_eq!(config.sandbox.allow_read, vec!["/usr/lib"]);
2394 assert_eq!(config.sandbox.allow_write, vec!["/tmp/scratch"]);
2395 assert_eq!(config.sandbox.allow_network, vec!["api.anthropic.com"]);
2396 }
2397
2398 #[test]
2399 fn workflow_config_default_has_sandbox_section() {
2400 let config = WorkflowConfig::default();
2401 assert!(!config.sandbox.enabled, "sandbox disabled by default");
2402 }
2403
2404 #[test]
2405 fn workflow_section_defaults_to_warn() {
2406 let config = WorkflowConfig::default();
2407 assert_eq!(
2408 config.workflow.enforce_phase_order, "warn",
2409 "enforce_phase_order should default to 'warn'"
2410 );
2411 }
2412
2413 #[test]
2414 fn workflow_section_parse_toml() {
2415 let toml = r#"
2416[workflow]
2417enforce_phase_order = "block"
2418"#;
2419 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2420 assert_eq!(config.workflow.enforce_phase_order, "block");
2421 }
2422
2423 #[test]
2424 fn workflow_section_parse_toml_off() {
2425 let toml = r#"
2426[workflow]
2427enforce_phase_order = "off"
2428"#;
2429 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2430 assert_eq!(config.workflow.enforce_phase_order, "off");
2431 }
2432
2433 #[test]
2436 fn apply_config_empty_returns_none() {
2437 let cfg = ApplyConfig::default();
2438 assert!(cfg.policy_for("PLAN.md").is_none());
2439 assert!(cfg.policy_for("src/main.rs").is_none());
2440 }
2441
2442 #[test]
2443 fn apply_config_exact_match() {
2444 let mut cfg = ApplyConfig::default();
2445 cfg.conflict_policy
2446 .insert("PLAN.md".to_string(), "keep-source".to_string());
2447 cfg.conflict_policy
2448 .insert("Cargo.lock".to_string(), "keep-source".to_string());
2449
2450 assert_eq!(cfg.policy_for("PLAN.md"), Some("keep-source"));
2451 assert_eq!(cfg.policy_for("Cargo.lock"), Some("keep-source"));
2452 assert_eq!(cfg.policy_for("src/main.rs"), None);
2453 }
2454
2455 #[test]
2456 fn apply_config_default_key_fallback() {
2457 let mut cfg = ApplyConfig::default();
2458 cfg.conflict_policy
2459 .insert("default".to_string(), "merge".to_string());
2460
2461 assert_eq!(cfg.policy_for("src/anything.rs"), Some("merge"));
2463 assert_eq!(cfg.policy_for("PLAN.md"), Some("merge"));
2464 }
2465
2466 #[test]
2467 fn apply_config_glob_override_wins_over_default() {
2468 let mut cfg = ApplyConfig::default();
2469 cfg.conflict_policy
2470 .insert("default".to_string(), "merge".to_string());
2471 cfg.conflict_policy
2472 .insert("src/**".to_string(), "abort".to_string());
2473
2474 assert_eq!(cfg.policy_for("src/main.rs"), Some("abort"));
2475 assert_eq!(cfg.policy_for("src/nested/lib.rs"), Some("abort"));
2476 assert_eq!(cfg.policy_for("docs/USAGE.md"), Some("merge"));
2478 }
2479
2480 #[test]
2481 fn apply_config_parse_from_toml() {
2482 let toml = r#"
2483[apply.conflict_policy]
2484default = "merge"
2485"PLAN.md" = "keep-source"
2486"Cargo.lock" = "keep-source"
2487"src/**" = "abort"
2488"#;
2489 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2490 assert_eq!(config.apply.policy_for("PLAN.md"), Some("keep-source"));
2491 assert_eq!(config.apply.policy_for("Cargo.lock"), Some("keep-source"));
2492 assert_eq!(config.apply.policy_for("src/lib.rs"), Some("abort"));
2493 assert_eq!(config.apply.policy_for("docs/USAGE.md"), Some("merge"));
2494 }
2495
2496 #[test]
2497 fn ta_path_config_defaults_are_populated() {
2498 let cfg = TaPathConfig::default();
2499 assert!(!cfg.project.include_paths.is_empty());
2500 assert!(cfg
2501 .project
2502 .include_paths
2503 .contains(&"workflow.toml".to_string()));
2504 assert!(!cfg.local.exclude_paths.is_empty());
2505 assert!(cfg.local.exclude_paths.contains(&"staging/".to_string()));
2506 }
2507
2508 #[test]
2509 fn ta_path_config_parse_from_toml() {
2510 let toml = r#"
2511[ta.project]
2512include_paths = ["workflow.toml", "agents/"]
2513
2514[ta.local]
2515exclude_paths = ["staging/", "goals/"]
2516"#;
2517 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2518 assert_eq!(
2519 config.ta.project.include_paths,
2520 vec!["workflow.toml", "agents/"]
2521 );
2522 assert_eq!(config.ta.local.exclude_paths, vec!["staging/", "goals/"]);
2523 }
2524
2525 #[test]
2526 fn plan_config_defaults_to_plan_md() {
2527 let config = PlanConfig::default();
2528 assert_eq!(config.file, "PLAN.md");
2529 }
2530
2531 #[test]
2532 fn plan_config_custom_file_resolves_path() {
2533 let config = PlanConfig {
2534 file: "ROADMAP.md".to_string(),
2535 };
2536 let workflow = WorkflowConfig {
2537 plan: config,
2538 ..Default::default()
2539 };
2540 let root = std::path::Path::new("/workspace");
2541 let path = resolve_plan_path(root, &workflow);
2542 assert_eq!(path, std::path::Path::new("/workspace/ROADMAP.md"));
2543 }
2544
2545 #[test]
2546 fn plan_config_default_resolves_to_plan_md() {
2547 let workflow = WorkflowConfig::default();
2548 let root = std::path::Path::new("/project");
2549 let path = resolve_plan_path(root, &workflow);
2550 assert_eq!(path, std::path::Path::new("/project/PLAN.md"));
2551 }
2552
2553 #[test]
2554 fn plan_config_parses_from_toml() {
2555 let toml = r#"
2556[plan]
2557file = "ROADMAP.md"
2558"#;
2559 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2560 assert_eq!(config.plan.file, "ROADMAP.md");
2561 }
2562
2563 #[test]
2564 fn project_section_parses_name() {
2565 let toml = r#"
2566[project]
2567name = "My Pipeline Project"
2568"#;
2569 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2570 assert_eq!(config.project.name.as_deref(), Some("My Pipeline Project"));
2571 }
2572
2573 #[test]
2574 fn project_section_defaults_to_none_name() {
2575 let config = WorkflowConfig::default();
2576 assert!(config.project.name.is_none());
2577 }
2578
2579 #[test]
2582 fn analysis_config_parses_python() {
2583 let toml = r#"
2584[analysis.python]
2585tool = "mypy"
2586args = ["--strict"]
2587on_failure = "agent"
2588max_iterations = 3
2589"#;
2590 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2591 let py = config
2592 .analysis
2593 .get("python")
2594 .expect("python analysis config");
2595 assert_eq!(py.tool, "mypy");
2596 assert_eq!(py.args, vec!["--strict"]);
2597 assert_eq!(py.on_failure, ta_goal::analysis::OnFailure::Agent);
2598 assert_eq!(py.max_iterations, 3);
2599 }
2600
2601 #[test]
2602 fn analysis_config_parses_multiple_languages() {
2603 let toml = r#"
2604[analysis.rust]
2605tool = "cargo-clippy"
2606args = ["-D", "warnings"]
2607on_failure = "warn"
2608
2609[analysis.go]
2610tool = "golangci-lint"
2611args = ["run"]
2612on_failure = "agent"
2613"#;
2614 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2615 assert_eq!(config.analysis.len(), 2);
2616 let rust = config.analysis.get("rust").unwrap();
2617 assert_eq!(rust.tool, "cargo-clippy");
2618 assert_eq!(rust.on_failure, ta_goal::analysis::OnFailure::Warn);
2619 let go = config.analysis.get("go").unwrap();
2620 assert_eq!(go.on_failure, ta_goal::analysis::OnFailure::Agent);
2621 }
2622
2623 #[test]
2624 fn analysis_config_defaults_to_empty_map() {
2625 let config = WorkflowConfig::default();
2626 assert!(config.analysis.is_empty());
2627 }
2628
2629 #[test]
2630 fn analysis_config_parses_on_max_iterations() {
2631 let toml = r#"
2632[analysis.typescript]
2633tool = "pyright"
2634on_max_iterations = "fail"
2635"#;
2636 let config: WorkflowConfig = toml::from_str(toml).unwrap();
2637 let ts = config.analysis.get("typescript").unwrap();
2638 assert_eq!(
2639 ts.on_max_iterations,
2640 ta_goal::analysis::OnMaxIterations::Fail
2641 );
2642 }
2643}