1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::Deserialize;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
8#[serde(rename_all = "lowercase")]
9pub enum SectionType {
10 Free,
11 Tasks,
12 Qa,
13}
14
15#[derive(Debug, Clone, Deserialize, JsonSchema)]
17pub struct TicketSection {
18 pub name: String,
20 #[serde(rename = "type")]
22 pub type_: SectionType,
23 #[serde(default)]
25 pub required: bool,
26 #[serde(default)]
28 pub placeholder: Option<String>,
29 #[serde(default)]
33 pub validate_from_state: Option<String>,
34}
35
36#[derive(Debug, Deserialize, Default, JsonSchema)]
39pub struct TicketConfig {
40 #[serde(default)]
41 pub sections: Vec<TicketSection>,
42}
43
44#[derive(Debug, Clone, PartialEq, Deserialize, Default, JsonSchema)]
49#[serde(rename_all = "lowercase")]
50pub enum CompletionStrategy {
51 Pr,
52 Merge,
53 Pull,
54 #[serde(rename = "pr_or_epic_merge")]
55 PrOrEpicMerge,
56 #[default]
57 None,
58}
59
60#[derive(Debug, Clone, Deserialize, JsonSchema)]
61pub struct IsolationConfig {
62 #[serde(default = "default_read_allow")]
66 pub read_allow: Vec<String>,
67 #[serde(default)]
70 pub enforce_worktree_isolation: bool,
71}
72
73fn default_read_allow() -> Vec<String> {
74 vec!["/etc/resolv.conf".to_string(), "~/.gitconfig".to_string()]
75}
76
77impl Default for IsolationConfig {
78 fn default() -> Self {
79 Self {
80 read_allow: default_read_allow(),
81 enforce_worktree_isolation: false,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
87pub struct LoggingConfig {
88 #[serde(default)]
90 pub enabled: bool,
91 pub file: Option<std::path::PathBuf>,
93}
94
95#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
96#[serde(default)]
97pub struct GitHostConfig {
98 pub provider: Option<String>,
100 pub repo: Option<String>,
102 pub token_env: Option<String>,
104}
105
106#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
107pub struct WorkersConfig {
108 pub container: Option<String>,
110 #[serde(default)]
112 pub keychain: std::collections::HashMap<String, String>,
113 #[serde(default)]
115 pub env: std::collections::HashMap<String, String>,
116 pub default: String,
119 pub model: Option<String>,
122}
123
124
125#[derive(Debug, Deserialize, Default, JsonSchema)]
126pub struct WorkConfig {
127 #[serde(default)]
129 pub epic: Option<String>,
130}
131
132#[derive(Debug, Clone, Deserialize, JsonSchema)]
133pub struct ServerConfig {
134 #[serde(default = "default_server_origin")]
136 pub origin: String,
137 #[serde(default = "default_server_url")]
139 pub url: String,
140}
141
142fn default_server_origin() -> String {
143 "http://localhost:3000".to_string()
144}
145
146fn default_server_url() -> String {
147 "http://127.0.0.1:3000".to_string()
148}
149
150impl Default for ServerConfig {
151 fn default() -> Self {
152 Self { origin: default_server_origin(), url: default_server_url() }
153 }
154}
155
156#[derive(Debug, Deserialize, JsonSchema)]
157pub struct ContextConfig {
158 #[serde(default = "default_epic_sibling_cap")]
160 pub epic_sibling_cap: usize,
161 #[serde(default = "default_epic_byte_cap")]
163 pub epic_byte_cap: usize,
164}
165
166fn default_epic_sibling_cap() -> usize { 20 }
167fn default_epic_byte_cap() -> usize { 8192 }
168
169impl Default for ContextConfig {
170 fn default() -> Self {
171 Self {
172 epic_sibling_cap: default_epic_sibling_cap(),
173 epic_byte_cap: default_epic_byte_cap(),
174 }
175 }
176}
177
178#[derive(Debug, Deserialize, JsonSchema)]
179pub struct Config {
180 pub project: ProjectConfig,
181 #[serde(default)]
182 pub ticket: TicketConfig,
183 #[serde(default)]
184 pub tickets: TicketsConfig,
185 #[serde(default)]
186 pub workflow: WorkflowConfig,
187 #[serde(default)]
188 pub agents: AgentsConfig,
189 #[serde(default)]
190 pub worktrees: WorktreesConfig,
191 #[serde(default)]
192 pub sync: SyncConfig,
193 #[serde(default)]
194 pub logging: LoggingConfig,
195 #[serde(default)]
196 pub workers: WorkersConfig,
197 #[serde(default)]
198 pub work: WorkConfig,
199 #[serde(default)]
200 pub server: ServerConfig,
201 #[serde(default)]
202 pub git_host: GitHostConfig,
203 #[serde(default)]
204 pub context: ContextConfig,
205 #[serde(default)]
206 pub isolation: IsolationConfig,
207 #[serde(skip)]
209 pub load_warnings: Vec<String>,
210}
211
212#[derive(Deserialize)]
213pub(crate) struct WorkflowFile {
214 pub(crate) workflow: WorkflowConfig,
215}
216
217#[derive(Deserialize)]
218pub(crate) struct TicketFile {
219 pub(crate) ticket: TicketConfig,
220}
221
222#[derive(Debug, Clone, Deserialize, JsonSchema)]
223pub struct SyncConfig {
224 #[serde(default = "default_true")]
226 pub aggressive: bool,
227}
228
229impl Default for SyncConfig {
230 fn default() -> Self {
231 Self { aggressive: true }
232 }
233}
234
235#[derive(Debug, Deserialize, JsonSchema)]
236pub struct ProjectConfig {
237 pub name: String,
239 #[serde(default)]
241 pub description: String,
242 #[serde(default = "default_branch_main")]
244 pub default_branch: String,
245 #[serde(default)]
247 pub collaborators: Vec<String>,
248}
249
250fn default_branch_main() -> String {
251 "main".to_string()
252}
253
254#[derive(Debug, Deserialize, JsonSchema)]
255pub struct TicketsConfig {
256 pub dir: PathBuf,
258 #[serde(default)]
259 pub sections: Vec<String>,
260 #[serde(default)]
262 pub archive_dir: Option<PathBuf>,
263}
264
265impl Default for TicketsConfig {
266 fn default() -> Self {
267 Self {
268 dir: PathBuf::from("tickets"),
269 sections: Vec::new(),
270 archive_dir: None,
271 }
272 }
273}
274
275#[derive(Debug, Deserialize, Default, JsonSchema)]
277pub struct WorkflowConfig {
278 #[serde(default)]
280 pub states: Vec<StateConfig>,
281 #[serde(default)]
283 pub prioritization: PrioritizationConfig,
284}
285
286#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
288#[serde(untagged)]
289pub enum SatisfiesDeps {
290 Bool(bool),
292 Tag(String),
294}
295
296impl Default for SatisfiesDeps {
297 fn default() -> Self { SatisfiesDeps::Bool(false) }
298}
299
300#[derive(Debug, Clone, Deserialize, JsonSchema)]
302#[serde(deny_unknown_fields)]
303pub struct StateConfig {
304 pub id: String,
306 pub label: String,
308 #[serde(default)]
310 pub description: String,
311 #[serde(default)]
313 pub terminal: bool,
314 #[serde(default)]
316 pub worker_end: bool,
317 #[serde(default)]
319 pub satisfies_deps: SatisfiesDeps,
320 #[serde(default)]
322 pub dep_requires: Option<String>,
323 #[serde(default)]
327 pub worker_profile: Option<String>,
328 #[serde(default)]
330 pub transitions: Vec<TransitionConfig>,
331}
332
333#[derive(Debug, Clone, Deserialize, JsonSchema)]
335#[serde(deny_unknown_fields)]
336pub struct TransitionConfig {
337 pub to: String,
339 #[serde(default)]
341 pub trigger: String,
342 #[serde(default)]
344 pub label: String,
345 #[serde(default)]
347 pub hint: String,
348 #[serde(default)]
350 pub completion: CompletionStrategy,
351 #[serde(default)]
353 pub focus_section: Option<String>,
354 #[serde(default)]
356 pub context_section: Option<String>,
357 #[serde(default)]
359 pub warning: Option<String>,
360 #[serde(default)]
361 pub on_failure: Option<String>,
362 #[serde(default)]
367 pub outcome: Option<String>,
368 #[serde(default)]
369 pub worker_hint: Option<String>,
370 #[serde(default)]
371 pub worker_pre: Option<String>,
372}
373
374#[derive(Debug, Deserialize, Default, JsonSchema)]
376pub struct PrioritizationConfig {
377 #[serde(default = "default_priority_weight")]
379 pub priority_weight: f64,
380 #[serde(default = "default_effort_weight")]
382 pub effort_weight: f64,
383 #[serde(default = "default_risk_weight")]
385 pub risk_weight: f64,
386}
387
388fn default_priority_weight() -> f64 { 10.0 }
389fn default_effort_weight() -> f64 { -2.0 }
390fn default_risk_weight() -> f64 { -1.0 }
391
392pub fn resolve_outcome<'a>(
399 transition: &'a TransitionConfig,
400 target_state: &StateConfig,
401) -> &'a str {
402 if let Some(ref o) = transition.outcome {
403 return o.as_str();
404 }
405 if transition.completion != CompletionStrategy::None {
406 return "success";
407 }
408 if target_state.terminal {
409 return "cancelled";
410 }
411 "needs_input"
412}
413
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct AgentsConfig {
416 #[serde(default = "default_max_concurrent")]
418 pub max_concurrent: usize,
419 #[serde(default = "default_max_workers_per_epic")]
421 pub max_workers_per_epic: usize,
422 #[serde(default = "default_max_workers_on_default")]
424 pub max_workers_on_default: usize,
425 #[serde(default)]
427 pub project: Option<PathBuf>,
428 #[serde(default = "default_true")]
430 pub side_tickets: bool,
431 #[serde(default)]
433 pub skip_permissions: bool,
434}
435
436fn default_max_concurrent() -> usize { 3 }
437fn default_max_workers_per_epic() -> usize { 1 }
438fn default_max_workers_on_default() -> usize { 1 }
439fn default_true() -> bool { true }
440
441#[derive(Debug, Deserialize, JsonSchema)]
442pub struct WorktreesConfig {
443 pub dir: PathBuf,
445 #[serde(default)]
447 pub agent_dirs: Vec<String>,
448}
449
450impl Default for WorktreesConfig {
451 fn default() -> Self {
452 Self {
453 dir: PathBuf::from("../worktrees"),
454 agent_dirs: Vec::new(),
455 }
456 }
457}
458
459impl Default for AgentsConfig {
460 fn default() -> Self {
461 Self {
462 max_concurrent: default_max_concurrent(),
463 max_workers_per_epic: default_max_workers_per_epic(),
464 max_workers_on_default: default_max_workers_on_default(),
465 project: None,
466 side_tickets: true,
467 skip_permissions: false,
468 }
469 }
470}
471
472#[derive(Debug, Deserialize, Default)]
473pub struct LocalConfig {
474 #[serde(default)]
475 pub workers: LocalWorkersOverride,
476 #[serde(default)]
477 pub username: Option<String>,
478 #[serde(default)]
479 pub github_token: Option<String>,
480}
481
482#[derive(Debug, Deserialize, Default)]
483pub struct LocalWorkersOverride {
484 pub command: Option<String>,
485 pub args: Option<Vec<String>>,
486 pub model: Option<String>,
487 #[serde(default)]
488 pub env: std::collections::HashMap<String, String>,
489}
490
491impl LocalConfig {
492 pub fn load(root: &Path) -> Self {
493 let local_path = root.join(".apm").join("local.toml");
494 std::fs::read_to_string(&local_path)
495 .ok()
496 .and_then(|s| toml::from_str(&s).ok())
497 .unwrap_or_default()
498 }
499}
500
501fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
502 if let Some(ref t) = local.github_token {
503 if !t.is_empty() {
504 return Some(t.clone());
505 }
506 }
507 if let Some(ref env_var) = git_host.token_env {
508 if let Ok(t) = std::env::var(env_var) {
509 if !t.is_empty() {
510 return Some(t);
511 }
512 }
513 }
514 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
515}
516
517pub fn resolve_identity(repo_root: &Path) -> String {
518 let local_path = repo_root.join(".apm").join("local.toml");
519 let local: LocalConfig = std::fs::read_to_string(&local_path)
520 .ok()
521 .and_then(|s| toml::from_str(&s).ok())
522 .unwrap_or_default();
523
524 let config_path = repo_root.join(".apm").join("config.toml");
525 let config: Option<Config> = std::fs::read_to_string(&config_path)
526 .ok()
527 .and_then(|s| toml::from_str(&s).ok());
528
529 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
530 if git_host.provider.is_some() {
531 if git_host.provider.as_deref() == Some("github") {
533 if let Some(login) = crate::github::gh_username() {
534 return login;
535 }
536 if let Some(token) = effective_github_token(&local, &git_host) {
537 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
538 return login;
539 }
540 }
541 }
542 return "unassigned".to_string();
543 }
544
545 if let Some(ref u) = local.username {
547 if !u.is_empty() {
548 return u.clone();
549 }
550 }
551 "unassigned".to_string()
552}
553
554pub fn resolve_caller_name() -> String {
571 std::env::var("APM_AGENT_TYPE")
572 .or_else(|_| std::env::var("APM_AGENT_NAME"))
573 .or_else(|_| std::env::var("USER"))
574 .or_else(|_| std::env::var("USERNAME"))
575 .unwrap_or_else(|_| "apm".to_string())
576}
577
578pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
579 if git_host.provider.as_deref() != Some("github") {
580 return None;
581 }
582 if let Some(login) = crate::github::gh_username() {
583 return Some(login);
584 }
585 let local = LocalConfig::default();
586 let token = effective_github_token(&local, git_host)?;
587 crate::github::fetch_authenticated_user(&token).ok()
588}
589
590pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
591 let mut warnings = Vec::new();
592 if config.git_host.provider.as_deref() == Some("github") {
593 if let Some(ref repo) = config.git_host.repo {
594 if let Some(token) = effective_github_token(local, &config.git_host) {
595 match crate::github::fetch_repo_collaborators(&token, repo) {
596 Ok(logins) => return (logins, warnings),
597 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
598 }
599 }
600 }
601 }
602 (config.project.collaborators.clone(), warnings)
603}
604
605impl WorkersConfig {
606 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
607 for (k, v) in &local.env {
608 self.env.insert(k.clone(), v.clone());
609 }
610 if let Some(ref m) = local.model {
611 if !m.is_empty() {
612 self.model = Some(m.clone());
613 }
614 }
615 }
616}
617
618impl Config {
619 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
622 let limit = self.agents.max_workers_per_epic;
623 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
624 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
625 *counts.entry(eid).or_insert(0) += 1;
626 }
627 counts.into_iter()
628 .filter(|(_, count)| *count >= limit)
629 .map(|(eid, _)| eid.to_string())
630 .collect()
631 }
632
633 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
636 if self.agents.max_workers_on_default == 0 {
637 return false;
638 }
639 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
640 count >= self.agents.max_workers_on_default
641 }
642
643 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
649 match actor {
650 "agent" => self.workflow.states.iter()
651 .filter(|s| s.transitions.iter().any(|t| t.trigger == "command:start"))
652 .map(|s| s.id.clone())
653 .collect(),
654 "supervisor" => self.workflow.states.iter()
655 .filter(|s| !s.terminal
656 && !s.transitions.iter().any(|t| t.trigger == "command:start"))
657 .map(|s| s.id.clone())
658 .collect(),
659 _ => vec![],
660 }
661 }
662
663 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
664 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
665 .filter(|s| s.terminal)
666 .map(|s| s.id.clone())
667 .collect();
668 ids.insert("closed".to_string());
669 ids
670 }
671
672 pub fn implementation_state_ids(&self) -> std::collections::HashSet<String> {
673 let mut ids: std::collections::HashSet<String> = std::collections::HashSet::new();
674 for state in &self.workflow.states {
676 if let Some(ref wp) = state.worker_profile {
677 if !wp.ends_with("/spec-writer") {
678 ids.insert(state.id.clone());
679 }
680 }
681 }
682 for state in &self.workflow.states {
684 for t in &state.transitions {
685 let dest_is_spec_writer = self.workflow.states.iter()
686 .find(|s| s.id == t.to)
687 .and_then(|s| s.worker_profile.as_deref())
688 .map(|wp| wp.ends_with("/spec-writer"))
689 .unwrap_or(false);
690 let is_coder_start = t.trigger == "command:start" && !dest_is_spec_writer;
691 let is_merge_completion = matches!(t.completion,
692 CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge);
693 if is_coder_start || is_merge_completion {
694 ids.insert(t.to.clone());
695 }
696 }
697 }
698 ids
699 }
700
701 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
702 self.ticket.sections.iter()
703 .find(|s| s.name.eq_ignore_ascii_case(name))
704 }
705
706 pub fn has_section(&self, name: &str) -> bool {
707 self.find_section(name).is_some()
708 }
709
710 pub fn load(repo_root: &Path) -> Result<Self> {
711 let apm_dir = repo_root.join(".apm");
712 let apm_dir_config = apm_dir.join("config.toml");
713 let path = apm_dir_config;
714 let contents = std::fs::read_to_string(&path)
715 .with_context(|| format!(
716 "cannot read {} -- run 'apm init' to initialise this repository",
717 path.display()
718 ))?;
719 let mut config: Config = toml::from_str(&contents)
720 .map_err(|e| {
721 if e.to_string().contains("worker_profile") {
722 anyhow::anyhow!(
723 "{}: `worker_profile` under a transition block is no longer supported — \
724 move `worker_profile` to the state block instead",
725 path.display()
726 )
727 } else {
728 anyhow::anyhow!("cannot parse {}: {}", path.display(), e)
729 }
730 })?;
731
732 let workflow_path = apm_dir.join("workflow.toml");
733 if workflow_path.exists() {
734 let wf_contents = std::fs::read_to_string(&workflow_path)
735 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
736 let wf: WorkflowFile = toml::from_str(&wf_contents)
737 .map_err(|e| {
738 if e.to_string().contains("worker_profile") {
739 anyhow::anyhow!(
740 "{}: `worker_profile` under a transition block is no longer supported — \
741 move `worker_profile` to the state block instead",
742 workflow_path.display()
743 )
744 } else {
745 anyhow::anyhow!("cannot parse {}: {}", workflow_path.display(), e)
746 }
747 })?;
748 if !config.workflow.states.is_empty() {
749 config.load_warnings.push(
750 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
751 );
752 }
753 config.workflow = wf.workflow;
754 }
755
756 let ticket_path = apm_dir.join("ticket.toml");
757 if ticket_path.exists() {
758 let tk_contents = std::fs::read_to_string(&ticket_path)
759 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
760 let tk: TicketFile = toml::from_str(&tk_contents)
761 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
762 if !config.ticket.sections.is_empty() {
763 config.load_warnings.push(
764 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
765 );
766 }
767 config.ticket = tk.ticket;
768 }
769
770 let local_path = apm_dir.join("local.toml");
771 if local_path.exists() {
772 let local_contents = std::fs::read_to_string(&local_path)
773 .with_context(|| format!("cannot read {}", local_path.display()))?;
774 let local: LocalConfig = toml::from_str(&local_contents)
775 .with_context(|| format!("cannot parse {}", local_path.display()))?;
776 config.workers.merge_local(&local.workers);
777 }
778
779 Ok(config)
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use std::sync::Mutex;
787
788 static ENV_LOCK: Mutex<()> = Mutex::new(());
789
790 #[test]
791 fn ticket_section_full_parse() {
792 let toml = r#"
793name = "Problem"
794type = "free"
795required = true
796placeholder = "What is broken or missing?"
797"#;
798 let s: TicketSection = toml::from_str(toml).unwrap();
799 assert_eq!(s.name, "Problem");
800 assert_eq!(s.type_, SectionType::Free);
801 assert!(s.required);
802 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
803 }
804
805 #[test]
806 fn ticket_section_minimal_parse() {
807 let toml = r#"
808name = "Open questions"
809type = "qa"
810"#;
811 let s: TicketSection = toml::from_str(toml).unwrap();
812 assert_eq!(s.name, "Open questions");
813 assert_eq!(s.type_, SectionType::Qa);
814 assert!(!s.required);
815 assert!(s.placeholder.is_none());
816 }
817
818 #[test]
819 fn section_type_all_variants() {
820 #[derive(Deserialize)]
821 struct W { t: SectionType }
822 let free: W = toml::from_str("t = \"free\"").unwrap();
823 assert_eq!(free.t, SectionType::Free);
824 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
825 assert_eq!(tasks.t, SectionType::Tasks);
826 let qa: W = toml::from_str("t = \"qa\"").unwrap();
827 assert_eq!(qa.t, SectionType::Qa);
828 }
829
830 #[test]
831 fn completion_strategy_all_variants() {
832 #[derive(Deserialize)]
833 struct W { c: CompletionStrategy }
834 let pr: W = toml::from_str("c = \"pr\"").unwrap();
835 assert_eq!(pr.c, CompletionStrategy::Pr);
836 let merge: W = toml::from_str("c = \"merge\"").unwrap();
837 assert_eq!(merge.c, CompletionStrategy::Merge);
838 let pull: W = toml::from_str("c = \"pull\"").unwrap();
839 assert_eq!(pull.c, CompletionStrategy::Pull);
840 let none: W = toml::from_str("c = \"none\"").unwrap();
841 assert_eq!(none.c, CompletionStrategy::None);
842 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
843 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
844 }
845
846 #[test]
847 fn completion_strategy_default() {
848 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
849 }
850
851 #[test]
852 fn transition_config_new_fields() {
853 let toml = r#"
854to = "implemented"
855trigger = "manual"
856completion = "pr"
857focus_section = "Code review"
858context_section = "Problem"
859"#;
860 let t: TransitionConfig = toml::from_str(toml).unwrap();
861 assert_eq!(t.completion, CompletionStrategy::Pr);
862 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
863 assert_eq!(t.context_section.as_deref(), Some("Problem"));
864 }
865
866 #[test]
867 fn transition_config_new_fields_default() {
868 let toml = r#"
869to = "ready"
870trigger = "manual"
871"#;
872 let t: TransitionConfig = toml::from_str(toml).unwrap();
873 assert_eq!(t.completion, CompletionStrategy::None);
874 assert!(t.focus_section.is_none());
875 assert!(t.context_section.is_none());
876 assert!(t.outcome.is_none());
877 }
878
879 #[test]
880 fn transition_worker_profile_rejected() {
881 let toml = r#"
882to = "in_progress"
883trigger = "command:start"
884worker_profile = "claude/coder"
885"#;
886 let result = toml::from_str::<TransitionConfig>(toml);
887 assert!(result.is_err(), "worker_profile on transition must be rejected");
888 let msg = result.unwrap_err().to_string();
889 assert!(
890 msg.contains("worker_profile"),
891 "error must name the field; got: {msg}"
892 );
893 }
894
895 #[test]
896 fn state_worker_profile_accepted() {
897 let toml = r#"
898[project]
899name = "test"
900
901[tickets]
902dir = "tickets"
903
904[[workflow.states]]
905id = "in_progress"
906label = "In Progress"
907worker_profile = "claude/coder"
908"#;
909 let result = toml::from_str::<Config>(toml);
910 assert!(result.is_ok(), "worker_profile at state level must be accepted; err: {:?}", result.err());
911 let config = result.unwrap();
912 let state = config.workflow.states.iter().find(|s| s.id == "in_progress").unwrap();
913 assert_eq!(state.worker_profile.as_deref(), Some("claude/coder"));
914 }
915
916 #[test]
917 fn resolve_outcome_explicit_override() {
918 let t: TransitionConfig = toml::from_str(r#"
919to = "ammend"
920outcome = "rejected"
921"#).unwrap();
922 let s: StateConfig = toml::from_str(r#"
923id = "ammend"
924label = "Ammend"
925"#).unwrap();
926 assert_eq!(super::resolve_outcome(&t, &s), "rejected");
927 }
928
929 #[test]
930 fn resolve_outcome_implicit_success() {
931 let t: TransitionConfig = toml::from_str(r#"
932to = "implemented"
933completion = "merge"
934"#).unwrap();
935 let s: StateConfig = toml::from_str(r#"
936id = "implemented"
937label = "Implemented"
938"#).unwrap();
939 assert_eq!(super::resolve_outcome(&t, &s), "success");
940 }
941
942 #[test]
943 fn resolve_outcome_implicit_cancelled() {
944 let t: TransitionConfig = toml::from_str(r#"
945to = "closed"
946"#).unwrap();
947 let s: StateConfig = toml::from_str(r#"
948id = "closed"
949label = "Closed"
950terminal = true
951"#).unwrap();
952 assert_eq!(super::resolve_outcome(&t, &s), "cancelled");
953 }
954
955 #[test]
956 fn resolve_outcome_implicit_needs_input() {
957 let t: TransitionConfig = toml::from_str(r#"
958to = "blocked"
959"#).unwrap();
960 let s: StateConfig = toml::from_str(r#"
961id = "blocked"
962label = "Blocked"
963"#).unwrap();
964 assert_eq!(super::resolve_outcome(&t, &s), "needs_input");
965 }
966
967 #[test]
968 fn workers_config_parses() {
969 let toml = r#"
970[project]
971name = "test"
972
973[tickets]
974dir = "tickets"
975
976[workers]
977container = "apm-worker:latest"
978default = "claude/coder"
979
980[workers.keychain]
981ANTHROPIC_API_KEY = "anthropic-api-key"
982"#;
983 let config: Config = toml::from_str(toml).unwrap();
984 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
985 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
986 }
987
988 #[test]
989 fn workers_config_default() {
990 let toml = r#"
991[project]
992name = "test"
993
994[tickets]
995dir = "tickets"
996"#;
997 let config: Config = toml::from_str(toml).unwrap();
998 assert!(config.workers.container.is_none());
999 assert!(config.workers.keychain.is_empty());
1000 assert!(config.workers.default.is_empty());
1001 assert!(config.workers.model.is_none());
1002 assert!(config.workers.env.is_empty());
1003 }
1004
1005 #[test]
1006 fn workers_config_default_field() {
1007 let toml = r#"
1008[project]
1009name = "test"
1010
1011[tickets]
1012dir = "tickets"
1013
1014[workers]
1015default = "claude/coder"
1016"#;
1017 let config: Config = toml::from_str(toml).unwrap();
1018 assert_eq!(config.workers.default, "claude/coder");
1019 }
1020
1021 #[test]
1022 fn workers_default_missing_fails_parse() {
1023 let toml = r#"
1024[project]
1025name = "test"
1026
1027[tickets]
1028dir = "tickets"
1029
1030[workers]
1031container = "apm-worker:latest"
1032"#;
1033 let result = toml::from_str::<Config>(toml);
1034 assert!(result.is_err(), "expected parse error when [workers] has no default key");
1035 }
1036
1037 #[test]
1038 fn workers_config_env_field() {
1039 let toml = r#"
1040[project]
1041name = "test"
1042
1043[tickets]
1044dir = "tickets"
1045
1046[workers]
1047default = "claude/coder"
1048
1049[workers.env]
1050CUSTOM_VAR = "value"
1051"#;
1052 let config: Config = toml::from_str(toml).unwrap();
1053 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
1054 }
1055
1056 #[test]
1057 fn local_config_parses() {
1058 let toml = r#"
1059[workers]
1060command = "aider"
1061model = "gpt-4"
1062
1063[workers.env]
1064OPENAI_API_KEY = "sk-test"
1065"#;
1066 let local: LocalConfig = toml::from_str(toml).unwrap();
1067 assert_eq!(local.workers.command.as_deref(), Some("aider"));
1068 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
1069 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
1070 assert!(local.workers.args.is_none());
1071 }
1072
1073 #[test]
1074 fn merge_local_extends_env() {
1075 let mut wc = WorkersConfig::default();
1076 let local = LocalWorkersOverride {
1077 command: None,
1078 args: None,
1079 model: None,
1080 env: [("KEY".to_string(), "val".to_string())].into(),
1081 };
1082 wc.merge_local(&local);
1083 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
1084 }
1085
1086 #[test]
1087 fn agents_skip_permissions_parses_and_defaults() {
1088 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1089
1090 let config: Config = toml::from_str(base).unwrap();
1092 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
1093
1094 let with_agents = format!("{base}[agents]\n");
1096 let config: Config = toml::from_str(&with_agents).unwrap();
1097 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
1098
1099 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
1101 let config: Config = toml::from_str(&explicit_true).unwrap();
1102 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
1103
1104 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
1106 let config: Config = toml::from_str(&explicit_false).unwrap();
1107 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
1108 }
1109
1110 #[test]
1111 fn actionable_states_for_agent_includes_ready() {
1112 let toml = r#"
1113[project]
1114name = "test"
1115
1116[tickets]
1117dir = "tickets"
1118
1119[[workflow.states]]
1120id = "ready"
1121label = "Ready"
1122
1123 [[workflow.states.transitions]]
1124 to = "in_progress"
1125 trigger = "command:start"
1126
1127[[workflow.states]]
1128id = "in_progress"
1129label = "In Progress"
1130
1131[[workflow.states]]
1132id = "specd"
1133label = "Specd"
1134
1135 [[workflow.states.transitions]]
1136 to = "ready"
1137 trigger = "manual"
1138"#;
1139 let config: Config = toml::from_str(toml).unwrap();
1140 let states = config.actionable_states_for("agent");
1141 assert!(states.contains(&"ready".to_string()));
1142 assert!(!states.contains(&"specd".to_string()));
1143 assert!(!states.contains(&"in_progress".to_string()));
1144 }
1145
1146 #[test]
1147 fn actionable_states_for_supervisor_includes_in_design() {
1148 let toml = r#"
1149[project]
1150name = "test"
1151
1152[tickets]
1153dir = "tickets"
1154
1155[[workflow.states]]
1156id = "in_design"
1157label = "In Design"
1158
1159 [[workflow.states.transitions]]
1160 to = "specd"
1161 trigger = "manual"
1162
1163[[workflow.states]]
1164id = "ready"
1165label = "Ready"
1166
1167 [[workflow.states.transitions]]
1168 to = "in_progress"
1169 trigger = "command:start"
1170
1171[[workflow.states]]
1172id = "in_progress"
1173label = "In Progress"
1174terminal = true
1175"#;
1176 let config: Config = toml::from_str(toml).unwrap();
1177 let states = config.actionable_states_for("supervisor");
1178 assert!(states.contains(&"in_design".to_string()),
1179 "in_design has no command:start outgoing; must be supervisor-actionable");
1180 assert!(!states.contains(&"ready".to_string()),
1181 "ready has command:start outgoing; must not be supervisor-actionable");
1182 assert!(!states.contains(&"in_progress".to_string()),
1183 "terminal states must not be supervisor-actionable");
1184 }
1185
1186 #[test]
1187 fn actionable_states_for_unknown_actor_returns_empty() {
1188 let toml = r#"
1189[project]
1190name = "test"
1191
1192[tickets]
1193dir = "tickets"
1194
1195[[workflow.states]]
1196id = "ready"
1197label = "Ready"
1198
1199 [[workflow.states.transitions]]
1200 to = "in_progress"
1201 trigger = "command:start"
1202"#;
1203 let config: Config = toml::from_str(toml).unwrap();
1204 assert!(config.actionable_states_for("engineer").is_empty());
1205 }
1206
1207 #[test]
1208 fn state_config_deny_unknown_fields_rejects_actionable() {
1209 let toml = r#"
1210[project]
1211name = "test"
1212
1213[tickets]
1214dir = "tickets"
1215
1216[[workflow.states]]
1217id = "ready"
1218label = "Ready"
1219actionable = ["agent"]
1220"#;
1221 let result: Result<Config, _> = toml::from_str(toml);
1222 assert!(result.is_err(), "actionable field must be rejected by deny_unknown_fields");
1223 }
1224
1225 #[test]
1226 fn work_epic_parses() {
1227 let toml = r#"
1228[project]
1229name = "test"
1230
1231[tickets]
1232dir = "tickets"
1233
1234[work]
1235epic = "ab12cd34"
1236"#;
1237 let config: Config = toml::from_str(toml).unwrap();
1238 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
1239 }
1240
1241 #[test]
1242 fn work_config_defaults_to_none() {
1243 let toml = r#"
1244[project]
1245name = "test"
1246
1247[tickets]
1248dir = "tickets"
1249"#;
1250 let config: Config = toml::from_str(toml).unwrap();
1251 assert!(config.work.epic.is_none());
1252 }
1253
1254 #[test]
1255 fn implementation_state_ids_coder_start_and_merge_completion() {
1256 let toml = r#"
1259[project]
1260name = "test"
1261
1262[tickets]
1263dir = "tickets"
1264
1265[[workflow.states]]
1266id = "ready"
1267label = "Ready"
1268
1269 [[workflow.states.transitions]]
1270 to = "in_progress"
1271 trigger = "command:start"
1272
1273[[workflow.states]]
1274id = "in_progress"
1275label = "In Progress"
1276worker_profile = "claude/coder"
1277
1278 [[workflow.states.transitions]]
1279 to = "implemented"
1280 trigger = "manual"
1281 completion = "pr_or_epic_merge"
1282
1283[[workflow.states]]
1284id = "implemented"
1285label = "Implemented"
1286"#;
1287 let config: Config = toml::from_str(toml).unwrap();
1288 let ids = config.implementation_state_ids();
1289 let expected: std::collections::HashSet<String> =
1290 ["in_progress", "implemented"].iter().map(|s| s.to_string()).collect();
1291 assert_eq!(ids, expected);
1292 }
1293
1294 #[test]
1295 fn implementation_state_ids_none_completion_still_nonempty_via_coder_start() {
1296 let toml = r#"
1299[project]
1300name = "test"
1301
1302[tickets]
1303dir = "tickets"
1304
1305[[workflow.states]]
1306id = "ready"
1307label = "Ready"
1308
1309 [[workflow.states.transitions]]
1310 to = "in_progress"
1311 trigger = "command:start"
1312
1313[[workflow.states]]
1314id = "in_progress"
1315label = "In Progress"
1316worker_profile = "claude/coder"
1317
1318 [[workflow.states.transitions]]
1319 to = "implemented"
1320 trigger = "manual"
1321 completion = "none"
1322
1323[[workflow.states]]
1324id = "implemented"
1325label = "Implemented"
1326"#;
1327 let config: Config = toml::from_str(toml).unwrap();
1328 let ids = config.implementation_state_ids();
1329 assert_eq!(ids, ["in_progress".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1330 }
1331
1332 #[test]
1333 fn implementation_state_ids_no_coder_start_uses_merge_completion() {
1334 let toml = r#"
1337[project]
1338name = "test"
1339
1340[tickets]
1341dir = "tickets"
1342
1343[[workflow.states]]
1344id = "in_progress"
1345label = "In Progress"
1346
1347 [[workflow.states.transitions]]
1348 to = "shipped"
1349 trigger = "manual"
1350 completion = "merge"
1351
1352[[workflow.states]]
1353id = "shipped"
1354label = "Shipped"
1355"#;
1356 let config: Config = toml::from_str(toml).unwrap();
1357 let ids = config.implementation_state_ids();
1358 assert_eq!(ids, ["shipped".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1359 }
1360
1361 #[test]
1362 fn implementation_state_ids_command_start_no_profile_treated_as_coder() {
1363 let toml = r#"
1366[project]
1367name = "test"
1368
1369[tickets]
1370dir = "tickets"
1371
1372[[workflow.states]]
1373id = "ready"
1374label = "Ready"
1375
1376 [[workflow.states.transitions]]
1377 to = "in_progress"
1378 trigger = "command:start"
1379
1380[[workflow.states]]
1381id = "in_progress"
1382label = "In Progress"
1383"#;
1384 let config: Config = toml::from_str(toml).unwrap();
1385 let ids = config.implementation_state_ids();
1386 assert_eq!(ids, ["in_progress".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1387 }
1388
1389 #[test]
1390 fn implementation_state_ids_spec_writer_start_excluded() {
1391 let toml = r#"
1394[project]
1395name = "test"
1396
1397[tickets]
1398dir = "tickets"
1399
1400[[workflow.states]]
1401id = "ready"
1402label = "Ready"
1403
1404 [[workflow.states.transitions]]
1405 to = "in_design"
1406 trigger = "command:start"
1407
1408[[workflow.states]]
1409id = "in_design"
1410label = "In Design"
1411worker_profile = "claude/spec-writer"
1412"#;
1413 let config: Config = toml::from_str(toml).unwrap();
1414 let ids = config.implementation_state_ids();
1415 assert!(ids.is_empty(), "spec-writer start must not count as an implementation state");
1416 }
1417
1418 #[test]
1419 fn implementation_state_ids_order_invariant() {
1420 let toml_v1 = r#"
1423[project]
1424name = "test"
1425
1426[tickets]
1427dir = "tickets"
1428
1429[[workflow.states]]
1430id = "ready"
1431label = "Ready"
1432
1433 [[workflow.states.transitions]]
1434 to = "in_progress"
1435 trigger = "command:start"
1436
1437[[workflow.states]]
1438id = "in_progress"
1439label = "In Progress"
1440worker_profile = "claude/coder"
1441
1442 [[workflow.states.transitions]]
1443 to = "implemented"
1444 trigger = "manual"
1445 completion = "pr_or_epic_merge"
1446
1447[[workflow.states]]
1448id = "implemented"
1449label = "Implemented"
1450"#;
1451 let toml_v2 = r#"
1453[project]
1454name = "test"
1455
1456[tickets]
1457dir = "tickets"
1458
1459[[workflow.states]]
1460id = "implemented"
1461label = "Implemented"
1462
1463[[workflow.states]]
1464id = "in_progress"
1465label = "In Progress"
1466worker_profile = "claude/coder"
1467
1468 [[workflow.states.transitions]]
1469 to = "implemented"
1470 trigger = "manual"
1471 completion = "pr_or_epic_merge"
1472
1473[[workflow.states]]
1474id = "ready"
1475label = "Ready"
1476
1477 [[workflow.states.transitions]]
1478 to = "in_progress"
1479 trigger = "command:start"
1480"#;
1481 let c1: Config = toml::from_str(toml_v1).unwrap();
1482 let c2: Config = toml::from_str(toml_v2).unwrap();
1483 assert_eq!(
1484 c1.implementation_state_ids(),
1485 c2.implementation_state_ids(),
1486 "implementation_state_ids must be invariant to state list order"
1487 );
1488 }
1489
1490 #[test]
1491 fn state_worker_profile_parses() {
1492 let toml = r#"
1493[project]
1494name = "test"
1495
1496[tickets]
1497dir = "tickets"
1498
1499[[workflow.states]]
1500id = "in_progress"
1501label = "In Progress"
1502worker_profile = "claude/coder"
1503"#;
1504 let config: Config = toml::from_str(toml).unwrap();
1505 let state = config.workflow.states.iter().find(|s| s.id == "in_progress").unwrap();
1506 assert_eq!(state.worker_profile.as_deref(), Some("claude/coder"));
1507 }
1508
1509 #[test]
1510 fn implementation_state_ids_state_worker_profile_preferred() {
1511 let toml = r#"
1514[project]
1515name = "test"
1516
1517[tickets]
1518dir = "tickets"
1519
1520[[workflow.states]]
1521id = "in_progress"
1522label = "In Progress"
1523worker_profile = "claude/coder"
1524
1525 [[workflow.states.transitions]]
1526 to = "implemented"
1527 trigger = "manual"
1528
1529[[workflow.states]]
1530id = "implemented"
1531label = "Implemented"
1532"#;
1533 let config: Config = toml::from_str(toml).unwrap();
1534 let ids = config.implementation_state_ids();
1535 assert!(ids.contains("in_progress"),
1536 "in_progress must appear when state has worker_profile = claude/coder; got: {:?}", ids);
1537 }
1538
1539 #[test]
1540 fn sync_aggressive_defaults_to_true() {
1541 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1542
1543 let config: Config = toml::from_str(base).unwrap();
1545 assert!(config.sync.aggressive, "no [sync] section should default to true");
1546
1547 let with_sync = format!("{base}[sync]\n");
1549 let config: Config = toml::from_str(&with_sync).unwrap();
1550 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1551
1552 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1554 let config: Config = toml::from_str(&explicit_false).unwrap();
1555 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1556
1557 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1559 let config: Config = toml::from_str(&explicit_true).unwrap();
1560 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1561 }
1562
1563 #[test]
1564 fn collaborators_parses() {
1565 let toml = r#"
1566[project]
1567name = "test"
1568collaborators = ["alice", "bob"]
1569
1570[tickets]
1571dir = "tickets"
1572"#;
1573 let config: Config = toml::from_str(toml).unwrap();
1574 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1575 }
1576
1577 #[test]
1578 fn collaborators_defaults_empty() {
1579 let toml = r#"
1580[project]
1581name = "test"
1582
1583[tickets]
1584dir = "tickets"
1585"#;
1586 let config: Config = toml::from_str(toml).unwrap();
1587 assert!(config.project.collaborators.is_empty());
1588 }
1589
1590 #[test]
1591 fn resolve_identity_returns_username_when_present() {
1592 let tmp = tempfile::tempdir().unwrap();
1593 let apm_dir = tmp.path().join(".apm");
1594 std::fs::create_dir_all(&apm_dir).unwrap();
1595 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1596 assert_eq!(resolve_identity(tmp.path()), "alice");
1597 }
1598
1599 #[test]
1600 fn resolve_identity_returns_unassigned_when_absent() {
1601 let tmp = tempfile::tempdir().unwrap();
1602 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1603 }
1604
1605 #[test]
1606 fn resolve_identity_returns_unassigned_when_empty() {
1607 let tmp = tempfile::tempdir().unwrap();
1608 let apm_dir = tmp.path().join(".apm");
1609 std::fs::create_dir_all(&apm_dir).unwrap();
1610 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1611 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1612 }
1613
1614 #[test]
1615 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let apm_dir = tmp.path().join(".apm");
1618 std::fs::create_dir_all(&apm_dir).unwrap();
1619 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1620 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1621 }
1622
1623 #[test]
1624 fn local_config_username_parses() {
1625 let toml = r#"
1626username = "bob"
1627"#;
1628 let local: LocalConfig = toml::from_str(toml).unwrap();
1629 assert_eq!(local.username.as_deref(), Some("bob"));
1630 }
1631
1632 #[test]
1633 fn local_config_username_defaults_none() {
1634 let local: LocalConfig = toml::from_str("").unwrap();
1635 assert!(local.username.is_none());
1636 }
1637
1638 #[test]
1639 fn server_config_defaults() {
1640 let toml = r#"
1641[project]
1642name = "test"
1643
1644[tickets]
1645dir = "tickets"
1646"#;
1647 let config: Config = toml::from_str(toml).unwrap();
1648 assert_eq!(config.server.origin, "http://localhost:3000");
1649 }
1650
1651 #[test]
1652 fn server_config_custom_origin() {
1653 let toml = r#"
1654[project]
1655name = "test"
1656
1657[tickets]
1658dir = "tickets"
1659
1660[server]
1661origin = "https://apm.example.com"
1662"#;
1663 let config: Config = toml::from_str(toml).unwrap();
1664 assert_eq!(config.server.origin, "https://apm.example.com");
1665 }
1666
1667 #[test]
1668 fn git_host_config_parses() {
1669 let toml = r#"
1670[project]
1671name = "test"
1672
1673[tickets]
1674dir = "tickets"
1675
1676[git_host]
1677provider = "github"
1678repo = "owner/name"
1679"#;
1680 let config: Config = toml::from_str(toml).unwrap();
1681 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1682 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1683 }
1684
1685 #[test]
1686 fn git_host_config_absent_defaults_none() {
1687 let toml = r#"
1688[project]
1689name = "test"
1690
1691[tickets]
1692dir = "tickets"
1693"#;
1694 let config: Config = toml::from_str(toml).unwrap();
1695 assert!(config.git_host.provider.is_none());
1696 assert!(config.git_host.repo.is_none());
1697 }
1698
1699 #[test]
1700 fn local_config_github_token_parses() {
1701 let toml = r#"github_token = "ghp_abc123""#;
1702 let local: LocalConfig = toml::from_str(toml).unwrap();
1703 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1704 }
1705
1706 #[test]
1707 fn local_config_github_token_absent_defaults_none() {
1708 let local: LocalConfig = toml::from_str("").unwrap();
1709 assert!(local.github_token.is_none());
1710 }
1711
1712 #[test]
1713 fn tickets_archive_dir_parses() {
1714 let toml = r#"
1715[project]
1716name = "test"
1717
1718[tickets]
1719dir = "tickets"
1720archive_dir = "archive/tickets"
1721"#;
1722 let config: Config = toml::from_str(toml).unwrap();
1723 assert_eq!(
1724 config.tickets.archive_dir.as_deref(),
1725 Some(std::path::Path::new("archive/tickets"))
1726 );
1727 }
1728
1729 #[test]
1730 fn tickets_archive_dir_absent_defaults_none() {
1731 let toml = r#"
1732[project]
1733name = "test"
1734
1735[tickets]
1736dir = "tickets"
1737"#;
1738 let config: Config = toml::from_str(toml).unwrap();
1739 assert!(config.tickets.archive_dir.is_none());
1740 }
1741
1742 #[test]
1743 fn agents_max_workers_per_epic_defaults_to_one() {
1744 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1745 let config: Config = toml::from_str(toml).unwrap();
1746 assert_eq!(config.agents.max_workers_per_epic, 1);
1747 }
1748
1749 #[test]
1750 fn blocked_epics_global_limit_one() {
1751 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1752 let config: Config = toml::from_str(toml).unwrap();
1753 let active = vec![Some("epicA".to_string())];
1755 let blocked = config.blocked_epics(&active);
1756 assert!(blocked.contains(&"epicA".to_string()));
1757 }
1758
1759 #[test]
1760 fn blocked_epics_global_limit_two() {
1761 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1762 let config: Config = toml::from_str(toml).unwrap();
1763 let active = vec![Some("epicA".to_string())];
1765 let blocked = config.blocked_epics(&active);
1766 assert!(!blocked.contains(&"epicA".to_string()));
1767 }
1768
1769 #[test]
1770 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1771 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1772 let config: Config = toml::from_str(base).unwrap();
1773 assert_eq!(config.agents.max_workers_on_default, 1);
1774 let active: Vec<Option<String>> = vec![];
1776 assert!(!config.is_default_branch_blocked(&active));
1777 }
1778
1779 #[test]
1780 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1781 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1782 let config: Config = toml::from_str(base).unwrap();
1783 let active = vec![None];
1785 assert!(config.is_default_branch_blocked(&active));
1786 }
1787
1788 #[test]
1789 fn default_branch_not_blocked_when_limit_zero() {
1790 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1791 let config: Config = toml::from_str(toml).unwrap();
1792 let active = vec![None, None, None];
1794 assert!(!config.is_default_branch_blocked(&active));
1795 }
1796
1797 #[test]
1798 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1799 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1800 let config: Config = toml::from_str(base).unwrap();
1801 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1803 assert!(!config.is_default_branch_blocked(&active));
1804 }
1805
1806 #[test]
1807 fn prefers_apm_agent_type() {
1808 let _g = ENV_LOCK.lock().unwrap();
1809 std::env::remove_var("APM_AGENT_NAME");
1810 std::env::set_var("APM_AGENT_TYPE", "explicit-type");
1811 assert_eq!(resolve_caller_name(), "explicit-type");
1812 std::env::remove_var("APM_AGENT_TYPE");
1813 }
1814
1815 #[test]
1816 fn prefers_apm_agent_name() {
1817 let _g = ENV_LOCK.lock().unwrap();
1818 std::env::remove_var("APM_AGENT_TYPE");
1819 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1820 assert_eq!(resolve_caller_name(), "explicit-agent");
1821 std::env::remove_var("APM_AGENT_NAME");
1822 }
1823
1824 #[test]
1825 fn falls_back_to_user() {
1826 let _g = ENV_LOCK.lock().unwrap();
1827 std::env::remove_var("APM_AGENT_TYPE");
1828 std::env::remove_var("APM_AGENT_NAME");
1829 std::env::set_var("USER", "unix-user");
1830 std::env::remove_var("USERNAME");
1831 assert_eq!(resolve_caller_name(), "unix-user");
1832 std::env::remove_var("USER");
1833 }
1834
1835 #[test]
1836 fn defaults_to_apm() {
1837 let _g = ENV_LOCK.lock().unwrap();
1838 std::env::remove_var("APM_AGENT_TYPE");
1839 std::env::remove_var("APM_AGENT_NAME");
1840 std::env::remove_var("USER");
1841 std::env::remove_var("USERNAME");
1842 assert_eq!(resolve_caller_name(), "apm");
1843 }
1844
1845}