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