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, 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: Option<String>,
114 pub model: Option<String>,
117}
118
119impl Default for WorkersConfig {
120 fn default() -> Self {
121 Self {
122 container: None,
123 keychain: std::collections::HashMap::new(),
124 env: std::collections::HashMap::new(),
125 default: None,
126 model: None,
127 }
128 }
129}
130
131#[derive(Debug, Deserialize, Default, JsonSchema)]
132pub struct WorkConfig {
133 #[serde(default)]
135 pub epic: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize, JsonSchema)]
139pub struct ServerConfig {
140 #[serde(default = "default_server_origin")]
142 pub origin: String,
143 #[serde(default = "default_server_url")]
145 pub url: String,
146}
147
148fn default_server_origin() -> String {
149 "http://localhost:3000".to_string()
150}
151
152fn default_server_url() -> String {
153 "http://127.0.0.1:3000".to_string()
154}
155
156impl Default for ServerConfig {
157 fn default() -> Self {
158 Self { origin: default_server_origin(), url: default_server_url() }
159 }
160}
161
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct ContextConfig {
164 #[serde(default = "default_epic_sibling_cap")]
166 pub epic_sibling_cap: usize,
167 #[serde(default = "default_epic_byte_cap")]
169 pub epic_byte_cap: usize,
170}
171
172fn default_epic_sibling_cap() -> usize { 20 }
173fn default_epic_byte_cap() -> usize { 8192 }
174
175impl Default for ContextConfig {
176 fn default() -> Self {
177 Self {
178 epic_sibling_cap: default_epic_sibling_cap(),
179 epic_byte_cap: default_epic_byte_cap(),
180 }
181 }
182}
183
184#[derive(Debug, Deserialize, JsonSchema)]
185pub struct Config {
186 pub project: ProjectConfig,
187 #[serde(default)]
188 pub ticket: TicketConfig,
189 #[serde(default)]
190 pub tickets: TicketsConfig,
191 #[serde(default)]
192 pub workflow: WorkflowConfig,
193 #[serde(default)]
194 pub agents: AgentsConfig,
195 #[serde(default)]
196 pub worktrees: WorktreesConfig,
197 #[serde(default)]
198 pub sync: SyncConfig,
199 #[serde(default)]
200 pub logging: LoggingConfig,
201 #[serde(default)]
202 pub workers: WorkersConfig,
203 #[serde(default)]
204 pub work: WorkConfig,
205 #[serde(default)]
206 pub server: ServerConfig,
207 #[serde(default)]
208 pub git_host: GitHostConfig,
209 #[serde(default)]
210 pub context: ContextConfig,
211 #[serde(default)]
212 pub isolation: IsolationConfig,
213 #[serde(skip)]
215 pub load_warnings: Vec<String>,
216}
217
218#[derive(Deserialize)]
219pub(crate) struct WorkflowFile {
220 pub(crate) workflow: WorkflowConfig,
221}
222
223#[derive(Deserialize)]
224pub(crate) struct TicketFile {
225 pub(crate) ticket: TicketConfig,
226}
227
228#[derive(Debug, Clone, Deserialize, JsonSchema)]
229pub struct SyncConfig {
230 #[serde(default = "default_true")]
232 pub aggressive: bool,
233}
234
235impl Default for SyncConfig {
236 fn default() -> Self {
237 Self { aggressive: true }
238 }
239}
240
241#[derive(Debug, Deserialize, JsonSchema)]
242pub struct ProjectConfig {
243 pub name: String,
245 #[serde(default)]
247 pub description: String,
248 #[serde(default = "default_branch_main")]
250 pub default_branch: String,
251 #[serde(default)]
253 pub collaborators: Vec<String>,
254}
255
256fn default_branch_main() -> String {
257 "main".to_string()
258}
259
260#[derive(Debug, Deserialize, JsonSchema)]
261pub struct TicketsConfig {
262 pub dir: PathBuf,
264 #[serde(default)]
265 pub sections: Vec<String>,
266 #[serde(default)]
268 pub archive_dir: Option<PathBuf>,
269}
270
271impl Default for TicketsConfig {
272 fn default() -> Self {
273 Self {
274 dir: PathBuf::from("tickets"),
275 sections: Vec::new(),
276 archive_dir: None,
277 }
278 }
279}
280
281#[derive(Debug, Deserialize, Default, JsonSchema)]
283pub struct WorkflowConfig {
284 #[serde(default)]
286 pub states: Vec<StateConfig>,
287 #[serde(default)]
289 pub prioritization: PrioritizationConfig,
290}
291
292#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
294#[serde(untagged)]
295pub enum SatisfiesDeps {
296 Bool(bool),
298 Tag(String),
300}
301
302impl Default for SatisfiesDeps {
303 fn default() -> Self { SatisfiesDeps::Bool(false) }
304}
305
306#[derive(Debug, Clone, Deserialize, JsonSchema)]
308pub struct StateConfig {
309 pub id: String,
311 pub label: String,
313 #[serde(default)]
315 pub description: String,
316 #[serde(default)]
318 pub terminal: bool,
319 #[serde(default)]
321 pub worker_end: bool,
322 #[serde(default)]
324 pub satisfies_deps: SatisfiesDeps,
325 #[serde(default)]
327 pub dep_requires: Option<String>,
328 #[serde(default)]
330 pub transitions: Vec<TransitionConfig>,
331 #[serde(default)]
333 pub actionable: Vec<String>,
334}
335
336#[derive(Debug, Clone, Deserialize, JsonSchema)]
338pub struct TransitionConfig {
339 pub to: String,
341 #[serde(default)]
343 pub trigger: String,
344 #[serde(default)]
346 pub label: String,
347 #[serde(default)]
349 pub hint: String,
350 #[serde(default)]
352 pub completion: CompletionStrategy,
353 #[serde(default)]
355 pub focus_section: Option<String>,
356 #[serde(default)]
358 pub context_section: Option<String>,
359 #[serde(default)]
361 pub warning: Option<String>,
362 #[serde(default)]
366 pub worker_profile: Option<String>,
367 #[serde(default)]
368 pub on_failure: Option<String>,
369 #[serde(default)]
374 pub outcome: Option<String>,
375}
376
377#[derive(Debug, Deserialize, Default, JsonSchema)]
379pub struct PrioritizationConfig {
380 #[serde(default = "default_priority_weight")]
382 pub priority_weight: f64,
383 #[serde(default = "default_effort_weight")]
385 pub effort_weight: f64,
386 #[serde(default = "default_risk_weight")]
388 pub risk_weight: f64,
389}
390
391fn default_priority_weight() -> f64 { 10.0 }
392fn default_effort_weight() -> f64 { -2.0 }
393fn default_risk_weight() -> f64 { -1.0 }
394
395pub fn resolve_outcome<'a>(
402 transition: &'a TransitionConfig,
403 target_state: &StateConfig,
404) -> &'a str {
405 if let Some(ref o) = transition.outcome {
406 return o.as_str();
407 }
408 if transition.completion != CompletionStrategy::None {
409 return "success";
410 }
411 if target_state.terminal {
412 return "cancelled";
413 }
414 "needs_input"
415}
416
417#[derive(Debug, Deserialize, JsonSchema)]
418pub struct AgentsConfig {
419 #[serde(default = "default_max_concurrent")]
421 pub max_concurrent: usize,
422 #[serde(default = "default_max_workers_per_epic")]
424 pub max_workers_per_epic: usize,
425 #[serde(default = "default_max_workers_on_default")]
427 pub max_workers_on_default: usize,
428 #[serde(default)]
430 pub project: Option<PathBuf>,
431 #[serde(default = "default_true")]
433 pub side_tickets: bool,
434 #[serde(default)]
436 pub skip_permissions: bool,
437}
438
439fn default_max_concurrent() -> usize { 3 }
440fn default_max_workers_per_epic() -> usize { 1 }
441fn default_max_workers_on_default() -> usize { 1 }
442fn default_true() -> bool { true }
443
444#[derive(Debug, Deserialize, JsonSchema)]
445pub struct WorktreesConfig {
446 pub dir: PathBuf,
448 #[serde(default)]
450 pub agent_dirs: Vec<String>,
451}
452
453impl Default for WorktreesConfig {
454 fn default() -> Self {
455 Self {
456 dir: PathBuf::from("../worktrees"),
457 agent_dirs: Vec::new(),
458 }
459 }
460}
461
462impl Default for AgentsConfig {
463 fn default() -> Self {
464 Self {
465 max_concurrent: default_max_concurrent(),
466 max_workers_per_epic: default_max_workers_per_epic(),
467 max_workers_on_default: default_max_workers_on_default(),
468 project: None,
469 side_tickets: true,
470 skip_permissions: false,
471 }
472 }
473}
474
475#[derive(Debug, Deserialize, Default)]
476pub struct LocalConfig {
477 #[serde(default)]
478 pub workers: LocalWorkersOverride,
479 #[serde(default)]
480 pub username: Option<String>,
481 #[serde(default)]
482 pub github_token: Option<String>,
483}
484
485#[derive(Debug, Deserialize, Default)]
486pub struct LocalWorkersOverride {
487 pub command: Option<String>,
488 pub args: Option<Vec<String>>,
489 pub model: Option<String>,
490 #[serde(default)]
491 pub env: std::collections::HashMap<String, String>,
492}
493
494impl LocalConfig {
495 pub fn load(root: &Path) -> Self {
496 let local_path = root.join(".apm").join("local.toml");
497 std::fs::read_to_string(&local_path)
498 .ok()
499 .and_then(|s| toml::from_str(&s).ok())
500 .unwrap_or_default()
501 }
502}
503
504fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
505 if let Some(ref t) = local.github_token {
506 if !t.is_empty() {
507 return Some(t.clone());
508 }
509 }
510 if let Some(ref env_var) = git_host.token_env {
511 if let Ok(t) = std::env::var(env_var) {
512 if !t.is_empty() {
513 return Some(t);
514 }
515 }
516 }
517 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
518}
519
520pub fn resolve_identity(repo_root: &Path) -> String {
521 let local_path = repo_root.join(".apm").join("local.toml");
522 let local: LocalConfig = std::fs::read_to_string(&local_path)
523 .ok()
524 .and_then(|s| toml::from_str(&s).ok())
525 .unwrap_or_default();
526
527 let config_path = repo_root.join(".apm").join("config.toml");
528 let config: Option<Config> = std::fs::read_to_string(&config_path)
529 .ok()
530 .and_then(|s| toml::from_str(&s).ok());
531
532 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
533 if git_host.provider.is_some() {
534 if git_host.provider.as_deref() == Some("github") {
536 if let Some(login) = crate::github::gh_username() {
537 return login;
538 }
539 if let Some(token) = effective_github_token(&local, &git_host) {
540 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
541 return login;
542 }
543 }
544 }
545 return "unassigned".to_string();
546 }
547
548 if let Some(ref u) = local.username {
550 if !u.is_empty() {
551 return u.clone();
552 }
553 }
554 "unassigned".to_string()
555}
556
557pub fn resolve_caller_name() -> String {
574 std::env::var("APM_AGENT_TYPE")
575 .or_else(|_| std::env::var("APM_AGENT_NAME"))
576 .or_else(|_| std::env::var("USER"))
577 .or_else(|_| std::env::var("USERNAME"))
578 .unwrap_or_else(|_| "apm".to_string())
579}
580
581pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
582 if git_host.provider.as_deref() != Some("github") {
583 return None;
584 }
585 if let Some(login) = crate::github::gh_username() {
586 return Some(login);
587 }
588 let local = LocalConfig::default();
589 let token = effective_github_token(&local, git_host)?;
590 crate::github::fetch_authenticated_user(&token).ok()
591}
592
593pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
594 let mut warnings = Vec::new();
595 if config.git_host.provider.as_deref() == Some("github") {
596 if let Some(ref repo) = config.git_host.repo {
597 if let Some(token) = effective_github_token(local, &config.git_host) {
598 match crate::github::fetch_repo_collaborators(&token, repo) {
599 Ok(logins) => return (logins, warnings),
600 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
601 }
602 }
603 }
604 }
605 (config.project.collaborators.clone(), warnings)
606}
607
608impl WorkersConfig {
609 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
610 for (k, v) in &local.env {
611 self.env.insert(k.clone(), v.clone());
612 }
613 if let Some(ref m) = local.model {
614 if !m.is_empty() {
615 self.model = Some(m.clone());
616 }
617 }
618 }
619}
620
621impl Config {
622 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
625 let limit = self.agents.max_workers_per_epic;
626 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
627 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
628 *counts.entry(eid).or_insert(0) += 1;
629 }
630 counts.into_iter()
631 .filter(|(_, count)| *count >= limit)
632 .map(|(eid, _)| eid.to_string())
633 .collect()
634 }
635
636 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
639 if self.agents.max_workers_on_default == 0 {
640 return false;
641 }
642 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
643 count >= self.agents.max_workers_on_default
644 }
645
646 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
649 self.workflow.states.iter()
650 .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
651 .map(|s| s.id.clone())
652 .collect()
653 }
654
655 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
656 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
657 .filter(|s| s.terminal)
658 .map(|s| s.id.clone())
659 .collect();
660 ids.insert("closed".to_string());
661 ids
662 }
663
664 pub fn implementation_state_ids(&self) -> std::collections::HashSet<String> {
665 self.workflow.states.iter()
666 .flat_map(|s| s.transitions.iter())
667 .filter(|t| {
668 let is_coder_start = t.trigger == "command:start"
669 && t.worker_profile.as_deref().map_or(true, |p| !p.ends_with("/spec-writer"));
670 let is_merge_completion = matches!(t.completion,
671 CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge);
672 is_coder_start || is_merge_completion
673 })
674 .map(|t| t.to.clone())
675 .collect()
676 }
677
678 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
679 self.ticket.sections.iter()
680 .find(|s| s.name.eq_ignore_ascii_case(name))
681 }
682
683 pub fn has_section(&self, name: &str) -> bool {
684 self.find_section(name).is_some()
685 }
686
687 pub fn load(repo_root: &Path) -> Result<Self> {
688 let apm_dir = repo_root.join(".apm");
689 let apm_dir_config = apm_dir.join("config.toml");
690 let path = apm_dir_config;
691 let contents = std::fs::read_to_string(&path)
692 .with_context(|| format!(
693 "cannot read {} -- run 'apm init' to initialise this repository",
694 path.display()
695 ))?;
696 let mut config: Config = toml::from_str(&contents)
697 .with_context(|| format!("cannot parse {}", path.display()))?;
698
699 let workflow_path = apm_dir.join("workflow.toml");
700 if workflow_path.exists() {
701 let wf_contents = std::fs::read_to_string(&workflow_path)
702 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
703 let wf: WorkflowFile = toml::from_str(&wf_contents)
704 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
705 if !config.workflow.states.is_empty() {
706 config.load_warnings.push(
707 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
708 );
709 }
710 config.workflow = wf.workflow;
711 }
712
713 let ticket_path = apm_dir.join("ticket.toml");
714 if ticket_path.exists() {
715 let tk_contents = std::fs::read_to_string(&ticket_path)
716 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
717 let tk: TicketFile = toml::from_str(&tk_contents)
718 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
719 if !config.ticket.sections.is_empty() {
720 config.load_warnings.push(
721 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
722 );
723 }
724 config.ticket = tk.ticket;
725 }
726
727 let local_path = apm_dir.join("local.toml");
728 if local_path.exists() {
729 let local_contents = std::fs::read_to_string(&local_path)
730 .with_context(|| format!("cannot read {}", local_path.display()))?;
731 let local: LocalConfig = toml::from_str(&local_contents)
732 .with_context(|| format!("cannot parse {}", local_path.display()))?;
733 config.workers.merge_local(&local.workers);
734 }
735
736 Ok(config)
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use std::sync::Mutex;
744
745 static ENV_LOCK: Mutex<()> = Mutex::new(());
746
747 #[test]
748 fn ticket_section_full_parse() {
749 let toml = r#"
750name = "Problem"
751type = "free"
752required = true
753placeholder = "What is broken or missing?"
754"#;
755 let s: TicketSection = toml::from_str(toml).unwrap();
756 assert_eq!(s.name, "Problem");
757 assert_eq!(s.type_, SectionType::Free);
758 assert!(s.required);
759 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
760 }
761
762 #[test]
763 fn ticket_section_minimal_parse() {
764 let toml = r#"
765name = "Open questions"
766type = "qa"
767"#;
768 let s: TicketSection = toml::from_str(toml).unwrap();
769 assert_eq!(s.name, "Open questions");
770 assert_eq!(s.type_, SectionType::Qa);
771 assert!(!s.required);
772 assert!(s.placeholder.is_none());
773 }
774
775 #[test]
776 fn section_type_all_variants() {
777 #[derive(Deserialize)]
778 struct W { t: SectionType }
779 let free: W = toml::from_str("t = \"free\"").unwrap();
780 assert_eq!(free.t, SectionType::Free);
781 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
782 assert_eq!(tasks.t, SectionType::Tasks);
783 let qa: W = toml::from_str("t = \"qa\"").unwrap();
784 assert_eq!(qa.t, SectionType::Qa);
785 }
786
787 #[test]
788 fn completion_strategy_all_variants() {
789 #[derive(Deserialize)]
790 struct W { c: CompletionStrategy }
791 let pr: W = toml::from_str("c = \"pr\"").unwrap();
792 assert_eq!(pr.c, CompletionStrategy::Pr);
793 let merge: W = toml::from_str("c = \"merge\"").unwrap();
794 assert_eq!(merge.c, CompletionStrategy::Merge);
795 let pull: W = toml::from_str("c = \"pull\"").unwrap();
796 assert_eq!(pull.c, CompletionStrategy::Pull);
797 let none: W = toml::from_str("c = \"none\"").unwrap();
798 assert_eq!(none.c, CompletionStrategy::None);
799 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
800 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
801 }
802
803 #[test]
804 fn completion_strategy_default() {
805 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
806 }
807
808 #[test]
809 fn transition_config_new_fields() {
810 let toml = r#"
811to = "implemented"
812trigger = "manual"
813completion = "pr"
814focus_section = "Code review"
815context_section = "Problem"
816"#;
817 let t: TransitionConfig = toml::from_str(toml).unwrap();
818 assert_eq!(t.completion, CompletionStrategy::Pr);
819 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
820 assert_eq!(t.context_section.as_deref(), Some("Problem"));
821 }
822
823 #[test]
824 fn transition_config_new_fields_default() {
825 let toml = r#"
826to = "ready"
827trigger = "manual"
828"#;
829 let t: TransitionConfig = toml::from_str(toml).unwrap();
830 assert_eq!(t.completion, CompletionStrategy::None);
831 assert!(t.focus_section.is_none());
832 assert!(t.context_section.is_none());
833 assert!(t.outcome.is_none());
834 assert!(t.worker_profile.is_none());
835 }
836
837 #[test]
838 fn transition_config_worker_profile_field() {
839 let toml = r#"
840to = "in_design"
841trigger = "command:start"
842worker_profile = "claude/spec-writer"
843"#;
844 let t: TransitionConfig = toml::from_str(toml).unwrap();
845 assert_eq!(t.worker_profile.as_deref(), Some("claude/spec-writer"));
846 }
847
848 #[test]
849 fn resolve_outcome_explicit_override() {
850 let t: TransitionConfig = toml::from_str(r#"
851to = "ammend"
852outcome = "rejected"
853"#).unwrap();
854 let s: StateConfig = toml::from_str(r#"
855id = "ammend"
856label = "Ammend"
857"#).unwrap();
858 assert_eq!(super::resolve_outcome(&t, &s), "rejected");
859 }
860
861 #[test]
862 fn resolve_outcome_implicit_success() {
863 let t: TransitionConfig = toml::from_str(r#"
864to = "implemented"
865completion = "merge"
866"#).unwrap();
867 let s: StateConfig = toml::from_str(r#"
868id = "implemented"
869label = "Implemented"
870"#).unwrap();
871 assert_eq!(super::resolve_outcome(&t, &s), "success");
872 }
873
874 #[test]
875 fn resolve_outcome_implicit_cancelled() {
876 let t: TransitionConfig = toml::from_str(r#"
877to = "closed"
878"#).unwrap();
879 let s: StateConfig = toml::from_str(r#"
880id = "closed"
881label = "Closed"
882terminal = true
883"#).unwrap();
884 assert_eq!(super::resolve_outcome(&t, &s), "cancelled");
885 }
886
887 #[test]
888 fn resolve_outcome_implicit_needs_input() {
889 let t: TransitionConfig = toml::from_str(r#"
890to = "blocked"
891"#).unwrap();
892 let s: StateConfig = toml::from_str(r#"
893id = "blocked"
894label = "Blocked"
895"#).unwrap();
896 assert_eq!(super::resolve_outcome(&t, &s), "needs_input");
897 }
898
899 #[test]
900 fn workers_config_parses() {
901 let toml = r#"
902[project]
903name = "test"
904
905[tickets]
906dir = "tickets"
907
908[workers]
909container = "apm-worker:latest"
910
911[workers.keychain]
912ANTHROPIC_API_KEY = "anthropic-api-key"
913"#;
914 let config: Config = toml::from_str(toml).unwrap();
915 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
916 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
917 }
918
919 #[test]
920 fn workers_config_default() {
921 let toml = r#"
922[project]
923name = "test"
924
925[tickets]
926dir = "tickets"
927"#;
928 let config: Config = toml::from_str(toml).unwrap();
929 assert!(config.workers.container.is_none());
930 assert!(config.workers.keychain.is_empty());
931 assert!(config.workers.default.is_none());
932 assert!(config.workers.model.is_none());
933 assert!(config.workers.env.is_empty());
934 }
935
936 #[test]
937 fn workers_config_default_field() {
938 let toml = r#"
939[project]
940name = "test"
941
942[tickets]
943dir = "tickets"
944
945[workers]
946default = "claude/coder"
947"#;
948 let config: Config = toml::from_str(toml).unwrap();
949 assert_eq!(config.workers.default.as_deref(), Some("claude/coder"));
950 }
951
952 #[test]
953 fn workers_config_env_field() {
954 let toml = r#"
955[project]
956name = "test"
957
958[tickets]
959dir = "tickets"
960
961[workers.env]
962CUSTOM_VAR = "value"
963"#;
964 let config: Config = toml::from_str(toml).unwrap();
965 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
966 }
967
968 #[test]
969 fn local_config_parses() {
970 let toml = r#"
971[workers]
972command = "aider"
973model = "gpt-4"
974
975[workers.env]
976OPENAI_API_KEY = "sk-test"
977"#;
978 let local: LocalConfig = toml::from_str(toml).unwrap();
979 assert_eq!(local.workers.command.as_deref(), Some("aider"));
980 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
981 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
982 assert!(local.workers.args.is_none());
983 }
984
985 #[test]
986 fn merge_local_extends_env() {
987 let mut wc = WorkersConfig::default();
988 let local = LocalWorkersOverride {
989 command: None,
990 args: None,
991 model: None,
992 env: [("KEY".to_string(), "val".to_string())].into(),
993 };
994 wc.merge_local(&local);
995 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
996 }
997
998 #[test]
999 fn agents_skip_permissions_parses_and_defaults() {
1000 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1001
1002 let config: Config = toml::from_str(base).unwrap();
1004 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
1005
1006 let with_agents = format!("{base}[agents]\n");
1008 let config: Config = toml::from_str(&with_agents).unwrap();
1009 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
1010
1011 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
1013 let config: Config = toml::from_str(&explicit_true).unwrap();
1014 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
1015
1016 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
1018 let config: Config = toml::from_str(&explicit_false).unwrap();
1019 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
1020 }
1021
1022 #[test]
1023 fn actionable_states_for_agent_includes_ready() {
1024 let toml = r#"
1025[project]
1026name = "test"
1027
1028[tickets]
1029dir = "tickets"
1030
1031[[workflow.states]]
1032id = "ready"
1033label = "Ready"
1034actionable = ["agent"]
1035
1036[[workflow.states]]
1037id = "in_progress"
1038label = "In Progress"
1039
1040[[workflow.states]]
1041id = "specd"
1042label = "Specd"
1043actionable = ["supervisor"]
1044"#;
1045 let config: Config = toml::from_str(toml).unwrap();
1046 let states = config.actionable_states_for("agent");
1047 assert!(states.contains(&"ready".to_string()));
1048 assert!(!states.contains(&"specd".to_string()));
1049 assert!(!states.contains(&"in_progress".to_string()));
1050 }
1051
1052 #[test]
1053 fn work_epic_parses() {
1054 let toml = r#"
1055[project]
1056name = "test"
1057
1058[tickets]
1059dir = "tickets"
1060
1061[work]
1062epic = "ab12cd34"
1063"#;
1064 let config: Config = toml::from_str(toml).unwrap();
1065 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
1066 }
1067
1068 #[test]
1069 fn work_config_defaults_to_none() {
1070 let toml = r#"
1071[project]
1072name = "test"
1073
1074[tickets]
1075dir = "tickets"
1076"#;
1077 let config: Config = toml::from_str(toml).unwrap();
1078 assert!(config.work.epic.is_none());
1079 }
1080
1081 #[test]
1082 fn implementation_state_ids_coder_start_and_merge_completion() {
1083 let toml = r#"
1086[project]
1087name = "test"
1088
1089[tickets]
1090dir = "tickets"
1091
1092[[workflow.states]]
1093id = "ready"
1094label = "Ready"
1095
1096 [[workflow.states.transitions]]
1097 to = "in_progress"
1098 trigger = "command:start"
1099 worker_profile = "claude/coder"
1100
1101[[workflow.states]]
1102id = "in_progress"
1103label = "In Progress"
1104
1105 [[workflow.states.transitions]]
1106 to = "implemented"
1107 trigger = "manual"
1108 completion = "pr_or_epic_merge"
1109
1110[[workflow.states]]
1111id = "implemented"
1112label = "Implemented"
1113"#;
1114 let config: Config = toml::from_str(toml).unwrap();
1115 let ids = config.implementation_state_ids();
1116 let expected: std::collections::HashSet<String> =
1117 ["in_progress", "implemented"].iter().map(|s| s.to_string()).collect();
1118 assert_eq!(ids, expected);
1119 }
1120
1121 #[test]
1122 fn implementation_state_ids_none_completion_still_nonempty_via_coder_start() {
1123 let toml = r#"
1126[project]
1127name = "test"
1128
1129[tickets]
1130dir = "tickets"
1131
1132[[workflow.states]]
1133id = "ready"
1134label = "Ready"
1135
1136 [[workflow.states.transitions]]
1137 to = "in_progress"
1138 trigger = "command:start"
1139 worker_profile = "claude/coder"
1140
1141[[workflow.states]]
1142id = "in_progress"
1143label = "In Progress"
1144
1145 [[workflow.states.transitions]]
1146 to = "implemented"
1147 trigger = "manual"
1148 completion = "none"
1149
1150[[workflow.states]]
1151id = "implemented"
1152label = "Implemented"
1153"#;
1154 let config: Config = toml::from_str(toml).unwrap();
1155 let ids = config.implementation_state_ids();
1156 assert_eq!(ids, ["in_progress".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1157 }
1158
1159 #[test]
1160 fn implementation_state_ids_no_coder_start_uses_merge_completion() {
1161 let toml = r#"
1164[project]
1165name = "test"
1166
1167[tickets]
1168dir = "tickets"
1169
1170[[workflow.states]]
1171id = "in_progress"
1172label = "In Progress"
1173
1174 [[workflow.states.transitions]]
1175 to = "shipped"
1176 trigger = "manual"
1177 completion = "merge"
1178
1179[[workflow.states]]
1180id = "shipped"
1181label = "Shipped"
1182"#;
1183 let config: Config = toml::from_str(toml).unwrap();
1184 let ids = config.implementation_state_ids();
1185 assert_eq!(ids, ["shipped".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1186 }
1187
1188 #[test]
1189 fn implementation_state_ids_command_start_no_profile_treated_as_coder() {
1190 let toml = r#"
1193[project]
1194name = "test"
1195
1196[tickets]
1197dir = "tickets"
1198
1199[[workflow.states]]
1200id = "ready"
1201label = "Ready"
1202
1203 [[workflow.states.transitions]]
1204 to = "in_progress"
1205 trigger = "command:start"
1206
1207[[workflow.states]]
1208id = "in_progress"
1209label = "In Progress"
1210"#;
1211 let config: Config = toml::from_str(toml).unwrap();
1212 let ids = config.implementation_state_ids();
1213 assert_eq!(ids, ["in_progress".to_string()].into_iter().collect::<std::collections::HashSet<_>>());
1214 }
1215
1216 #[test]
1217 fn implementation_state_ids_spec_writer_start_excluded() {
1218 let toml = r#"
1221[project]
1222name = "test"
1223
1224[tickets]
1225dir = "tickets"
1226
1227[[workflow.states]]
1228id = "ready"
1229label = "Ready"
1230
1231 [[workflow.states.transitions]]
1232 to = "in_design"
1233 trigger = "command:start"
1234 worker_profile = "claude/spec-writer"
1235
1236[[workflow.states]]
1237id = "in_design"
1238label = "In Design"
1239"#;
1240 let config: Config = toml::from_str(toml).unwrap();
1241 let ids = config.implementation_state_ids();
1242 assert!(ids.is_empty(), "spec-writer start must not count as an implementation state");
1243 }
1244
1245 #[test]
1246 fn implementation_state_ids_order_invariant() {
1247 let toml_v1 = 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 worker_profile = "claude/coder"
1264
1265[[workflow.states]]
1266id = "in_progress"
1267label = "In Progress"
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 toml_v2 = r#"
1280[project]
1281name = "test"
1282
1283[tickets]
1284dir = "tickets"
1285
1286[[workflow.states]]
1287id = "implemented"
1288label = "Implemented"
1289
1290[[workflow.states]]
1291id = "in_progress"
1292label = "In Progress"
1293
1294 [[workflow.states.transitions]]
1295 to = "implemented"
1296 trigger = "manual"
1297 completion = "pr_or_epic_merge"
1298
1299[[workflow.states]]
1300id = "ready"
1301label = "Ready"
1302
1303 [[workflow.states.transitions]]
1304 to = "in_progress"
1305 trigger = "command:start"
1306 worker_profile = "claude/coder"
1307"#;
1308 let c1: Config = toml::from_str(toml_v1).unwrap();
1309 let c2: Config = toml::from_str(toml_v2).unwrap();
1310 assert_eq!(
1311 c1.implementation_state_ids(),
1312 c2.implementation_state_ids(),
1313 "implementation_state_ids must be invariant to state list order"
1314 );
1315 }
1316
1317 #[test]
1318 fn sync_aggressive_defaults_to_true() {
1319 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1320
1321 let config: Config = toml::from_str(base).unwrap();
1323 assert!(config.sync.aggressive, "no [sync] section should default to true");
1324
1325 let with_sync = format!("{base}[sync]\n");
1327 let config: Config = toml::from_str(&with_sync).unwrap();
1328 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1329
1330 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1332 let config: Config = toml::from_str(&explicit_false).unwrap();
1333 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1334
1335 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1337 let config: Config = toml::from_str(&explicit_true).unwrap();
1338 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1339 }
1340
1341 #[test]
1342 fn collaborators_parses() {
1343 let toml = r#"
1344[project]
1345name = "test"
1346collaborators = ["alice", "bob"]
1347
1348[tickets]
1349dir = "tickets"
1350"#;
1351 let config: Config = toml::from_str(toml).unwrap();
1352 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1353 }
1354
1355 #[test]
1356 fn collaborators_defaults_empty() {
1357 let toml = r#"
1358[project]
1359name = "test"
1360
1361[tickets]
1362dir = "tickets"
1363"#;
1364 let config: Config = toml::from_str(toml).unwrap();
1365 assert!(config.project.collaborators.is_empty());
1366 }
1367
1368 #[test]
1369 fn resolve_identity_returns_username_when_present() {
1370 let tmp = tempfile::tempdir().unwrap();
1371 let apm_dir = tmp.path().join(".apm");
1372 std::fs::create_dir_all(&apm_dir).unwrap();
1373 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1374 assert_eq!(resolve_identity(tmp.path()), "alice");
1375 }
1376
1377 #[test]
1378 fn resolve_identity_returns_unassigned_when_absent() {
1379 let tmp = tempfile::tempdir().unwrap();
1380 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1381 }
1382
1383 #[test]
1384 fn resolve_identity_returns_unassigned_when_empty() {
1385 let tmp = tempfile::tempdir().unwrap();
1386 let apm_dir = tmp.path().join(".apm");
1387 std::fs::create_dir_all(&apm_dir).unwrap();
1388 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1389 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1390 }
1391
1392 #[test]
1393 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1394 let tmp = tempfile::tempdir().unwrap();
1395 let apm_dir = tmp.path().join(".apm");
1396 std::fs::create_dir_all(&apm_dir).unwrap();
1397 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1398 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1399 }
1400
1401 #[test]
1402 fn local_config_username_parses() {
1403 let toml = r#"
1404username = "bob"
1405"#;
1406 let local: LocalConfig = toml::from_str(toml).unwrap();
1407 assert_eq!(local.username.as_deref(), Some("bob"));
1408 }
1409
1410 #[test]
1411 fn local_config_username_defaults_none() {
1412 let local: LocalConfig = toml::from_str("").unwrap();
1413 assert!(local.username.is_none());
1414 }
1415
1416 #[test]
1417 fn server_config_defaults() {
1418 let toml = r#"
1419[project]
1420name = "test"
1421
1422[tickets]
1423dir = "tickets"
1424"#;
1425 let config: Config = toml::from_str(toml).unwrap();
1426 assert_eq!(config.server.origin, "http://localhost:3000");
1427 }
1428
1429 #[test]
1430 fn server_config_custom_origin() {
1431 let toml = r#"
1432[project]
1433name = "test"
1434
1435[tickets]
1436dir = "tickets"
1437
1438[server]
1439origin = "https://apm.example.com"
1440"#;
1441 let config: Config = toml::from_str(toml).unwrap();
1442 assert_eq!(config.server.origin, "https://apm.example.com");
1443 }
1444
1445 #[test]
1446 fn git_host_config_parses() {
1447 let toml = r#"
1448[project]
1449name = "test"
1450
1451[tickets]
1452dir = "tickets"
1453
1454[git_host]
1455provider = "github"
1456repo = "owner/name"
1457"#;
1458 let config: Config = toml::from_str(toml).unwrap();
1459 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1460 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1461 }
1462
1463 #[test]
1464 fn git_host_config_absent_defaults_none() {
1465 let toml = r#"
1466[project]
1467name = "test"
1468
1469[tickets]
1470dir = "tickets"
1471"#;
1472 let config: Config = toml::from_str(toml).unwrap();
1473 assert!(config.git_host.provider.is_none());
1474 assert!(config.git_host.repo.is_none());
1475 }
1476
1477 #[test]
1478 fn local_config_github_token_parses() {
1479 let toml = r#"github_token = "ghp_abc123""#;
1480 let local: LocalConfig = toml::from_str(toml).unwrap();
1481 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1482 }
1483
1484 #[test]
1485 fn local_config_github_token_absent_defaults_none() {
1486 let local: LocalConfig = toml::from_str("").unwrap();
1487 assert!(local.github_token.is_none());
1488 }
1489
1490 #[test]
1491 fn tickets_archive_dir_parses() {
1492 let toml = r#"
1493[project]
1494name = "test"
1495
1496[tickets]
1497dir = "tickets"
1498archive_dir = "archive/tickets"
1499"#;
1500 let config: Config = toml::from_str(toml).unwrap();
1501 assert_eq!(
1502 config.tickets.archive_dir.as_deref(),
1503 Some(std::path::Path::new("archive/tickets"))
1504 );
1505 }
1506
1507 #[test]
1508 fn tickets_archive_dir_absent_defaults_none() {
1509 let toml = r#"
1510[project]
1511name = "test"
1512
1513[tickets]
1514dir = "tickets"
1515"#;
1516 let config: Config = toml::from_str(toml).unwrap();
1517 assert!(config.tickets.archive_dir.is_none());
1518 }
1519
1520 #[test]
1521 fn agents_max_workers_per_epic_defaults_to_one() {
1522 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1523 let config: Config = toml::from_str(toml).unwrap();
1524 assert_eq!(config.agents.max_workers_per_epic, 1);
1525 }
1526
1527 #[test]
1528 fn blocked_epics_global_limit_one() {
1529 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1530 let config: Config = toml::from_str(toml).unwrap();
1531 let active = vec![Some("epicA".to_string())];
1533 let blocked = config.blocked_epics(&active);
1534 assert!(blocked.contains(&"epicA".to_string()));
1535 }
1536
1537 #[test]
1538 fn blocked_epics_global_limit_two() {
1539 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1540 let config: Config = toml::from_str(toml).unwrap();
1541 let active = vec![Some("epicA".to_string())];
1543 let blocked = config.blocked_epics(&active);
1544 assert!(!blocked.contains(&"epicA".to_string()));
1545 }
1546
1547 #[test]
1548 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1549 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1550 let config: Config = toml::from_str(base).unwrap();
1551 assert_eq!(config.agents.max_workers_on_default, 1);
1552 let active: Vec<Option<String>> = vec![];
1554 assert!(!config.is_default_branch_blocked(&active));
1555 }
1556
1557 #[test]
1558 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1559 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1560 let config: Config = toml::from_str(base).unwrap();
1561 let active = vec![None];
1563 assert!(config.is_default_branch_blocked(&active));
1564 }
1565
1566 #[test]
1567 fn default_branch_not_blocked_when_limit_zero() {
1568 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1569 let config: Config = toml::from_str(toml).unwrap();
1570 let active = vec![None, None, None];
1572 assert!(!config.is_default_branch_blocked(&active));
1573 }
1574
1575 #[test]
1576 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1577 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1578 let config: Config = toml::from_str(base).unwrap();
1579 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1581 assert!(!config.is_default_branch_blocked(&active));
1582 }
1583
1584 #[test]
1585 fn prefers_apm_agent_type() {
1586 let _g = ENV_LOCK.lock().unwrap();
1587 std::env::remove_var("APM_AGENT_NAME");
1588 std::env::set_var("APM_AGENT_TYPE", "explicit-type");
1589 assert_eq!(resolve_caller_name(), "explicit-type");
1590 std::env::remove_var("APM_AGENT_TYPE");
1591 }
1592
1593 #[test]
1594 fn prefers_apm_agent_name() {
1595 let _g = ENV_LOCK.lock().unwrap();
1596 std::env::remove_var("APM_AGENT_TYPE");
1597 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1598 assert_eq!(resolve_caller_name(), "explicit-agent");
1599 std::env::remove_var("APM_AGENT_NAME");
1600 }
1601
1602 #[test]
1603 fn falls_back_to_user() {
1604 let _g = ENV_LOCK.lock().unwrap();
1605 std::env::remove_var("APM_AGENT_TYPE");
1606 std::env::remove_var("APM_AGENT_NAME");
1607 std::env::set_var("USER", "unix-user");
1608 std::env::remove_var("USERNAME");
1609 assert_eq!(resolve_caller_name(), "unix-user");
1610 std::env::remove_var("USER");
1611 }
1612
1613 #[test]
1614 fn defaults_to_apm() {
1615 let _g = ENV_LOCK.lock().unwrap();
1616 std::env::remove_var("APM_AGENT_TYPE");
1617 std::env::remove_var("APM_AGENT_NAME");
1618 std::env::remove_var("USER");
1619 std::env::remove_var("USERNAME");
1620 assert_eq!(resolve_caller_name(), "apm");
1621 }
1622
1623}