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