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, Default, JsonSchema)]
56pub struct LoggingConfig {
57 #[serde(default)]
59 pub enabled: bool,
60 pub file: Option<std::path::PathBuf>,
62}
63
64#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
65#[serde(default)]
66pub struct GitHostConfig {
67 pub provider: Option<String>,
69 pub repo: Option<String>,
71 pub token_env: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76pub struct WorkersConfig {
77 pub container: Option<String>,
79 #[serde(default)]
81 pub keychain: std::collections::HashMap<String, String>,
82 pub command: Option<String>,
84 pub args: Option<Vec<String>>,
86 #[serde(default)]
88 pub model: Option<String>,
89 #[serde(default)]
91 pub env: std::collections::HashMap<String, String>,
92 pub agent: Option<String>,
94 #[serde(default)]
96 pub options: std::collections::HashMap<String, String>,
97 pub instructions: Option<String>,
99}
100
101impl Default for WorkersConfig {
102 fn default() -> Self {
103 Self {
104 container: None,
105 keychain: std::collections::HashMap::new(),
106 command: None,
107 args: None,
108 model: None,
109 env: std::collections::HashMap::new(),
110 agent: None,
111 options: std::collections::HashMap::new(),
112 instructions: None,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
118pub struct WorkerProfileConfig {
119 pub command: Option<String>,
121 pub args: Option<Vec<String>>,
123 pub model: Option<String>,
125 #[serde(default)]
127 pub env: std::collections::HashMap<String, String>,
128 pub container: Option<String>,
130 pub instructions: Option<String>,
132 pub role_prefix: Option<String>,
134 pub agent: Option<String>,
136 #[serde(default)]
138 pub options: std::collections::HashMap<String, String>,
139 pub role: Option<String>,
141}
142
143#[derive(Debug, Deserialize, Default, JsonSchema)]
144pub struct WorkConfig {
145 #[serde(default)]
147 pub epic: Option<String>,
148}
149
150#[derive(Debug, Clone, Deserialize, JsonSchema)]
151pub struct ServerConfig {
152 #[serde(default = "default_server_origin")]
154 pub origin: String,
155 #[serde(default = "default_server_url")]
157 pub url: String,
158}
159
160fn default_server_origin() -> String {
161 "http://localhost:3000".to_string()
162}
163
164fn default_server_url() -> String {
165 "http://127.0.0.1:3000".to_string()
166}
167
168impl Default for ServerConfig {
169 fn default() -> Self {
170 Self { origin: default_server_origin(), url: default_server_url() }
171 }
172}
173
174#[derive(Debug, Deserialize, JsonSchema)]
175pub struct ContextConfig {
176 #[serde(default = "default_epic_sibling_cap")]
178 pub epic_sibling_cap: usize,
179 #[serde(default = "default_epic_byte_cap")]
181 pub epic_byte_cap: usize,
182}
183
184fn default_epic_sibling_cap() -> usize { 20 }
185fn default_epic_byte_cap() -> usize { 8192 }
186
187impl Default for ContextConfig {
188 fn default() -> Self {
189 Self {
190 epic_sibling_cap: default_epic_sibling_cap(),
191 epic_byte_cap: default_epic_byte_cap(),
192 }
193 }
194}
195
196#[derive(Debug, Deserialize, JsonSchema)]
197pub struct Config {
198 pub project: ProjectConfig,
199 #[serde(default)]
200 pub ticket: TicketConfig,
201 #[serde(default)]
202 pub tickets: TicketsConfig,
203 #[serde(default)]
204 pub workflow: WorkflowConfig,
205 #[serde(default)]
206 pub agents: AgentsConfig,
207 #[serde(default)]
208 pub worktrees: WorktreesConfig,
209 #[serde(default)]
210 pub sync: SyncConfig,
211 #[serde(default)]
212 pub logging: LoggingConfig,
213 #[serde(default)]
214 pub workers: WorkersConfig,
215 #[serde(default)]
216 pub work: WorkConfig,
217 #[serde(default)]
218 pub server: ServerConfig,
219 #[serde(default)]
220 pub git_host: GitHostConfig,
221 #[serde(default)]
222 pub worker_profiles: std::collections::HashMap<String, WorkerProfileConfig>,
223 #[serde(default)]
224 pub context: ContextConfig,
225 #[serde(skip)]
227 pub load_warnings: Vec<String>,
228}
229
230#[derive(Deserialize)]
231pub(crate) struct WorkflowFile {
232 pub(crate) workflow: WorkflowConfig,
233}
234
235#[derive(Deserialize)]
236pub(crate) struct TicketFile {
237 pub(crate) ticket: TicketConfig,
238}
239
240#[derive(Debug, Clone, Deserialize, JsonSchema)]
241pub struct SyncConfig {
242 #[serde(default = "default_true")]
244 pub aggressive: bool,
245}
246
247impl Default for SyncConfig {
248 fn default() -> Self {
249 Self { aggressive: true }
250 }
251}
252
253#[derive(Debug, Deserialize, JsonSchema)]
254pub struct ProjectConfig {
255 pub name: String,
257 #[serde(default)]
259 pub description: String,
260 #[serde(default = "default_branch_main")]
262 pub default_branch: String,
263 #[serde(default)]
265 pub collaborators: Vec<String>,
266}
267
268fn default_branch_main() -> String {
269 "main".to_string()
270}
271
272#[derive(Debug, Deserialize, JsonSchema)]
273pub struct TicketsConfig {
274 pub dir: PathBuf,
276 #[serde(default)]
277 pub sections: Vec<String>,
278 #[serde(default)]
280 pub archive_dir: Option<PathBuf>,
281}
282
283impl Default for TicketsConfig {
284 fn default() -> Self {
285 Self {
286 dir: PathBuf::from("tickets"),
287 sections: Vec::new(),
288 archive_dir: None,
289 }
290 }
291}
292
293#[derive(Debug, Deserialize, Default, JsonSchema)]
295pub struct WorkflowConfig {
296 #[serde(default)]
298 pub states: Vec<StateConfig>,
299 #[serde(default)]
301 pub prioritization: PrioritizationConfig,
302}
303
304#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
306#[serde(untagged)]
307pub enum SatisfiesDeps {
308 Bool(bool),
310 Tag(String),
312}
313
314impl Default for SatisfiesDeps {
315 fn default() -> Self { SatisfiesDeps::Bool(false) }
316}
317
318#[derive(Debug, Clone, Deserialize, JsonSchema)]
320pub struct StateConfig {
321 pub id: String,
323 pub label: String,
325 #[serde(default)]
327 pub description: String,
328 #[serde(default)]
330 pub terminal: bool,
331 #[serde(default)]
333 pub worker_end: bool,
334 #[serde(default)]
336 pub satisfies_deps: SatisfiesDeps,
337 #[serde(default)]
339 pub dep_requires: Option<String>,
340 #[serde(default)]
342 pub transitions: Vec<TransitionConfig>,
343 #[serde(default)]
345 pub actionable: Vec<String>,
346 #[serde(default)]
348 pub instructions: Option<String>,
349}
350
351#[derive(Debug, Clone, Deserialize, JsonSchema)]
353pub struct TransitionConfig {
354 pub to: String,
356 #[serde(default)]
358 pub trigger: String,
359 #[serde(default)]
361 pub label: String,
362 #[serde(default)]
364 pub hint: String,
365 #[serde(default)]
367 pub completion: CompletionStrategy,
368 #[serde(default)]
370 pub focus_section: Option<String>,
371 #[serde(default)]
373 pub context_section: Option<String>,
374 #[serde(default)]
376 pub warning: Option<String>,
377 #[serde(default)]
379 pub profile: Option<String>,
380 #[serde(default)]
381 pub on_failure: Option<String>,
382 #[serde(default)]
387 pub outcome: Option<String>,
388}
389
390#[derive(Debug, Deserialize, Default, JsonSchema)]
392pub struct PrioritizationConfig {
393 #[serde(default = "default_priority_weight")]
395 pub priority_weight: f64,
396 #[serde(default = "default_effort_weight")]
398 pub effort_weight: f64,
399 #[serde(default = "default_risk_weight")]
401 pub risk_weight: f64,
402}
403
404fn default_priority_weight() -> f64 { 10.0 }
405fn default_effort_weight() -> f64 { -2.0 }
406fn default_risk_weight() -> f64 { -1.0 }
407
408pub fn resolve_outcome<'a>(
415 transition: &'a TransitionConfig,
416 target_state: &StateConfig,
417) -> &'a str {
418 if let Some(ref o) = transition.outcome {
419 return o.as_str();
420 }
421 if transition.completion != CompletionStrategy::None {
422 return "success";
423 }
424 if target_state.terminal {
425 return "cancelled";
426 }
427 "needs_input"
428}
429
430#[derive(Debug, Deserialize, JsonSchema)]
431pub struct AgentsConfig {
432 #[serde(default = "default_max_concurrent")]
434 pub max_concurrent: usize,
435 #[serde(default = "default_max_workers_per_epic")]
437 pub max_workers_per_epic: usize,
438 #[serde(default = "default_max_workers_on_default")]
440 pub max_workers_on_default: usize,
441 #[serde(default)]
443 pub instructions: Option<PathBuf>,
444 #[serde(default = "default_true")]
446 pub side_tickets: bool,
447 #[serde(default)]
449 pub skip_permissions: bool,
450}
451
452fn default_max_concurrent() -> usize { 3 }
453fn default_max_workers_per_epic() -> usize { 1 }
454fn default_max_workers_on_default() -> usize { 1 }
455fn default_true() -> bool { true }
456
457#[derive(Debug, Deserialize, JsonSchema)]
458pub struct WorktreesConfig {
459 pub dir: PathBuf,
461 #[serde(default)]
463 pub agent_dirs: Vec<String>,
464}
465
466impl Default for WorktreesConfig {
467 fn default() -> Self {
468 Self {
469 dir: PathBuf::from("../worktrees"),
470 agent_dirs: Vec::new(),
471 }
472 }
473}
474
475impl Default for AgentsConfig {
476 fn default() -> Self {
477 Self {
478 max_concurrent: default_max_concurrent(),
479 max_workers_per_epic: default_max_workers_per_epic(),
480 max_workers_on_default: default_max_workers_on_default(),
481 instructions: None,
482 side_tickets: true,
483 skip_permissions: false,
484 }
485 }
486}
487
488#[derive(Debug, Deserialize, Default)]
489pub struct LocalConfig {
490 #[serde(default)]
491 pub workers: LocalWorkersOverride,
492 #[serde(default)]
493 pub username: Option<String>,
494 #[serde(default)]
495 pub github_token: Option<String>,
496}
497
498#[derive(Debug, Deserialize, Default)]
499pub struct LocalWorkersOverride {
500 pub command: Option<String>,
501 pub args: Option<Vec<String>>,
502 pub model: Option<String>,
503 #[serde(default)]
504 pub env: std::collections::HashMap<String, String>,
505}
506
507impl LocalConfig {
508 pub fn load(root: &Path) -> Self {
509 let local_path = root.join(".apm").join("local.toml");
510 std::fs::read_to_string(&local_path)
511 .ok()
512 .and_then(|s| toml::from_str(&s).ok())
513 .unwrap_or_default()
514 }
515}
516
517fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
518 if let Some(ref t) = local.github_token {
519 if !t.is_empty() {
520 return Some(t.clone());
521 }
522 }
523 if let Some(ref env_var) = git_host.token_env {
524 if let Ok(t) = std::env::var(env_var) {
525 if !t.is_empty() {
526 return Some(t);
527 }
528 }
529 }
530 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
531}
532
533pub fn resolve_identity(repo_root: &Path) -> String {
534 let local_path = repo_root.join(".apm").join("local.toml");
535 let local: LocalConfig = std::fs::read_to_string(&local_path)
536 .ok()
537 .and_then(|s| toml::from_str(&s).ok())
538 .unwrap_or_default();
539
540 let config_path = repo_root.join(".apm").join("config.toml");
541 let config: Option<Config> = std::fs::read_to_string(&config_path)
542 .ok()
543 .and_then(|s| toml::from_str(&s).ok());
544
545 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
546 if git_host.provider.is_some() {
547 if git_host.provider.as_deref() == Some("github") {
549 if let Some(login) = crate::github::gh_username() {
550 return login;
551 }
552 if let Some(token) = effective_github_token(&local, &git_host) {
553 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
554 return login;
555 }
556 }
557 }
558 return "unassigned".to_string();
559 }
560
561 if let Some(ref u) = local.username {
563 if !u.is_empty() {
564 return u.clone();
565 }
566 }
567 "unassigned".to_string()
568}
569
570pub fn resolve_caller_name() -> String {
580 std::env::var("APM_AGENT_NAME")
581 .or_else(|_| std::env::var("USER"))
582 .or_else(|_| std::env::var("USERNAME"))
583 .unwrap_or_else(|_| "apm".to_string())
584}
585
586pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
587 if git_host.provider.as_deref() != Some("github") {
588 return None;
589 }
590 if let Some(login) = crate::github::gh_username() {
591 return Some(login);
592 }
593 let local = LocalConfig::default();
594 let token = effective_github_token(&local, git_host)?;
595 crate::github::fetch_authenticated_user(&token).ok()
596}
597
598pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
599 let mut warnings = Vec::new();
600 if config.git_host.provider.as_deref() == Some("github") {
601 if let Some(ref repo) = config.git_host.repo {
602 if let Some(token) = effective_github_token(local, &config.git_host) {
603 match crate::github::fetch_repo_collaborators(&token, repo) {
604 Ok(logins) => return (logins, warnings),
605 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
606 }
607 }
608 }
609 }
610 (config.project.collaborators.clone(), warnings)
611}
612
613impl WorkersConfig {
614 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
615 if let Some(ref cmd) = local.command {
616 self.command = Some(cmd.clone());
617 }
618 if let Some(ref args) = local.args {
619 self.args = Some(args.clone());
620 }
621 if let Some(ref model) = local.model {
622 self.model = Some(model.clone());
623 }
624 for (k, v) in &local.env {
625 self.env.insert(k.clone(), v.clone());
626 }
627 }
628}
629
630impl Config {
631 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
634 let limit = self.agents.max_workers_per_epic;
635 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
636 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
637 *counts.entry(eid).or_insert(0) += 1;
638 }
639 counts.into_iter()
640 .filter(|(_, count)| *count >= limit)
641 .map(|(eid, _)| eid.to_string())
642 .collect()
643 }
644
645 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
648 if self.agents.max_workers_on_default == 0 {
649 return false;
650 }
651 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
652 count >= self.agents.max_workers_on_default
653 }
654
655 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
658 self.workflow.states.iter()
659 .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
660 .map(|s| s.id.clone())
661 .collect()
662 }
663
664 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
665 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
666 .filter(|s| s.terminal)
667 .map(|s| s.id.clone())
668 .collect();
669 ids.insert("closed".to_string());
670 ids
671 }
672
673 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
674 self.ticket.sections.iter()
675 .find(|s| s.name.eq_ignore_ascii_case(name))
676 }
677
678 pub fn has_section(&self, name: &str) -> bool {
679 self.find_section(name).is_some()
680 }
681
682 pub fn load(repo_root: &Path) -> Result<Self> {
683 let apm_dir = repo_root.join(".apm");
684 let apm_dir_config = apm_dir.join("config.toml");
685 let path = if apm_dir_config.exists() {
686 apm_dir_config
687 } else {
688 repo_root.join("apm.toml")
689 };
690 let contents = std::fs::read_to_string(&path)
691 .with_context(|| format!("cannot read {}", path.display()))?;
692 let mut config: Config = toml::from_str(&contents)
693 .with_context(|| format!("cannot parse {}", path.display()))?;
694
695 let workflow_path = apm_dir.join("workflow.toml");
696 if workflow_path.exists() {
697 let wf_contents = std::fs::read_to_string(&workflow_path)
698 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
699 let wf: WorkflowFile = toml::from_str(&wf_contents)
700 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
701 if !config.workflow.states.is_empty() {
702 config.load_warnings.push(
703 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
704 );
705 }
706 config.workflow = wf.workflow;
707 }
708
709 let ticket_path = apm_dir.join("ticket.toml");
710 if ticket_path.exists() {
711 let tk_contents = std::fs::read_to_string(&ticket_path)
712 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
713 let tk: TicketFile = toml::from_str(&tk_contents)
714 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
715 if !config.ticket.sections.is_empty() {
716 config.load_warnings.push(
717 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
718 );
719 }
720 config.ticket = tk.ticket;
721 }
722
723 let local_path = apm_dir.join("local.toml");
724 if local_path.exists() {
725 let local_contents = std::fs::read_to_string(&local_path)
726 .with_context(|| format!("cannot read {}", local_path.display()))?;
727 let local: LocalConfig = toml::from_str(&local_contents)
728 .with_context(|| format!("cannot parse {}", local_path.display()))?;
729 config.workers.merge_local(&local.workers);
730 }
731
732 Ok(config)
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use std::sync::Mutex;
740
741 static ENV_LOCK: Mutex<()> = Mutex::new(());
742
743 #[test]
744 fn ticket_section_full_parse() {
745 let toml = r#"
746name = "Problem"
747type = "free"
748required = true
749placeholder = "What is broken or missing?"
750"#;
751 let s: TicketSection = toml::from_str(toml).unwrap();
752 assert_eq!(s.name, "Problem");
753 assert_eq!(s.type_, SectionType::Free);
754 assert!(s.required);
755 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
756 }
757
758 #[test]
759 fn ticket_section_minimal_parse() {
760 let toml = r#"
761name = "Open questions"
762type = "qa"
763"#;
764 let s: TicketSection = toml::from_str(toml).unwrap();
765 assert_eq!(s.name, "Open questions");
766 assert_eq!(s.type_, SectionType::Qa);
767 assert!(!s.required);
768 assert!(s.placeholder.is_none());
769 }
770
771 #[test]
772 fn section_type_all_variants() {
773 #[derive(Deserialize)]
774 struct W { t: SectionType }
775 let free: W = toml::from_str("t = \"free\"").unwrap();
776 assert_eq!(free.t, SectionType::Free);
777 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
778 assert_eq!(tasks.t, SectionType::Tasks);
779 let qa: W = toml::from_str("t = \"qa\"").unwrap();
780 assert_eq!(qa.t, SectionType::Qa);
781 }
782
783 #[test]
784 fn completion_strategy_all_variants() {
785 #[derive(Deserialize)]
786 struct W { c: CompletionStrategy }
787 let pr: W = toml::from_str("c = \"pr\"").unwrap();
788 assert_eq!(pr.c, CompletionStrategy::Pr);
789 let merge: W = toml::from_str("c = \"merge\"").unwrap();
790 assert_eq!(merge.c, CompletionStrategy::Merge);
791 let pull: W = toml::from_str("c = \"pull\"").unwrap();
792 assert_eq!(pull.c, CompletionStrategy::Pull);
793 let none: W = toml::from_str("c = \"none\"").unwrap();
794 assert_eq!(none.c, CompletionStrategy::None);
795 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
796 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
797 }
798
799 #[test]
800 fn completion_strategy_default() {
801 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
802 }
803
804 #[test]
805 fn state_config_with_instructions() {
806 let toml = r#"
807id = "in_progress"
808label = "In Progress"
809instructions = "apm.worker.md"
810"#;
811 let s: StateConfig = toml::from_str(toml).unwrap();
812 assert_eq!(s.id, "in_progress");
813 assert_eq!(s.instructions.as_deref(), Some("apm.worker.md"));
814 }
815
816 #[test]
817 fn state_config_instructions_default_none() {
818 let toml = r#"
819id = "new"
820label = "New"
821"#;
822 let s: StateConfig = toml::from_str(toml).unwrap();
823 assert!(s.instructions.is_none());
824 }
825
826 #[test]
827 fn transition_config_new_fields() {
828 let toml = r#"
829to = "implemented"
830trigger = "manual"
831completion = "pr"
832focus_section = "Code review"
833context_section = "Problem"
834"#;
835 let t: TransitionConfig = toml::from_str(toml).unwrap();
836 assert_eq!(t.completion, CompletionStrategy::Pr);
837 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
838 assert_eq!(t.context_section.as_deref(), Some("Problem"));
839 }
840
841 #[test]
842 fn transition_config_new_fields_default() {
843 let toml = r#"
844to = "ready"
845trigger = "manual"
846"#;
847 let t: TransitionConfig = toml::from_str(toml).unwrap();
848 assert_eq!(t.completion, CompletionStrategy::None);
849 assert!(t.focus_section.is_none());
850 assert!(t.context_section.is_none());
851 assert!(t.outcome.is_none());
852 }
853
854 #[test]
855 fn resolve_outcome_explicit_override() {
856 let t: TransitionConfig = toml::from_str(r#"
857to = "ammend"
858outcome = "rejected"
859"#).unwrap();
860 let s: StateConfig = toml::from_str(r#"
861id = "ammend"
862label = "Ammend"
863"#).unwrap();
864 assert_eq!(super::resolve_outcome(&t, &s), "rejected");
865 }
866
867 #[test]
868 fn resolve_outcome_implicit_success() {
869 let t: TransitionConfig = toml::from_str(r#"
870to = "implemented"
871completion = "merge"
872"#).unwrap();
873 let s: StateConfig = toml::from_str(r#"
874id = "implemented"
875label = "Implemented"
876"#).unwrap();
877 assert_eq!(super::resolve_outcome(&t, &s), "success");
878 }
879
880 #[test]
881 fn resolve_outcome_implicit_cancelled() {
882 let t: TransitionConfig = toml::from_str(r#"
883to = "closed"
884"#).unwrap();
885 let s: StateConfig = toml::from_str(r#"
886id = "closed"
887label = "Closed"
888terminal = true
889"#).unwrap();
890 assert_eq!(super::resolve_outcome(&t, &s), "cancelled");
891 }
892
893 #[test]
894 fn resolve_outcome_implicit_needs_input() {
895 let t: TransitionConfig = toml::from_str(r#"
896to = "blocked"
897"#).unwrap();
898 let s: StateConfig = toml::from_str(r#"
899id = "blocked"
900label = "Blocked"
901"#).unwrap();
902 assert_eq!(super::resolve_outcome(&t, &s), "needs_input");
903 }
904
905 #[test]
906 fn workers_config_parses() {
907 let toml = r#"
908[project]
909name = "test"
910
911[tickets]
912dir = "tickets"
913
914[workers]
915container = "apm-worker:latest"
916
917[workers.keychain]
918ANTHROPIC_API_KEY = "anthropic-api-key"
919"#;
920 let config: Config = toml::from_str(toml).unwrap();
921 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
922 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
923 }
924
925 #[test]
926 fn workers_config_default() {
927 let toml = r#"
928[project]
929name = "test"
930
931[tickets]
932dir = "tickets"
933"#;
934 let config: Config = toml::from_str(toml).unwrap();
935 assert!(config.workers.container.is_none());
936 assert!(config.workers.keychain.is_empty());
937 assert!(config.workers.command.is_none());
938 assert!(config.workers.args.is_none());
939 assert!(config.workers.agent.is_none());
940 assert!(config.workers.options.is_empty());
941 assert!(config.workers.model.is_none());
942 assert!(config.workers.env.is_empty());
943 }
944
945 #[test]
946 fn workers_config_all_fields() {
947 let toml = r#"
948[project]
949name = "test"
950
951[tickets]
952dir = "tickets"
953
954[workers]
955command = "codex"
956args = ["--full-auto"]
957model = "o3"
958
959[workers.env]
960CUSTOM_VAR = "value"
961"#;
962 let config: Config = toml::from_str(toml).unwrap();
963 assert_eq!(config.workers.command.as_deref(), Some("codex"));
964 assert_eq!(config.workers.args.as_deref(), Some(["--full-auto".to_string()][..].as_ref()));
965 assert_eq!(config.workers.model.as_deref(), Some("o3"));
966 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
967 }
968
969 #[test]
970 fn local_config_parses() {
971 let toml = r#"
972[workers]
973command = "aider"
974model = "gpt-4"
975
976[workers.env]
977OPENAI_API_KEY = "sk-test"
978"#;
979 let local: LocalConfig = toml::from_str(toml).unwrap();
980 assert_eq!(local.workers.command.as_deref(), Some("aider"));
981 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
982 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
983 assert!(local.workers.args.is_none());
984 }
985
986 #[test]
987 fn merge_local_overrides_and_extends() {
988 let mut wc = WorkersConfig::default();
989 assert!(wc.command.is_none());
990 assert!(wc.args.is_none());
991
992 let local = LocalWorkersOverride {
993 command: Some("aider".to_string()),
994 args: None,
995 model: Some("gpt-4".to_string()),
996 env: [("KEY".to_string(), "val".to_string())].into(),
997 };
998 wc.merge_local(&local);
999
1000 assert_eq!(wc.command.as_deref(), Some("aider"));
1001 assert!(wc.args.is_none()); assert_eq!(wc.model.as_deref(), Some("gpt-4"));
1003 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
1004 }
1005
1006 #[test]
1007 fn agents_skip_permissions_parses_and_defaults() {
1008 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1009
1010 let config: Config = toml::from_str(base).unwrap();
1012 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
1013
1014 let with_agents = format!("{base}[agents]\n");
1016 let config: Config = toml::from_str(&with_agents).unwrap();
1017 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
1018
1019 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
1021 let config: Config = toml::from_str(&explicit_true).unwrap();
1022 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
1023
1024 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
1026 let config: Config = toml::from_str(&explicit_false).unwrap();
1027 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
1028 }
1029
1030 #[test]
1031 fn actionable_states_for_agent_includes_ready() {
1032 let toml = r#"
1033[project]
1034name = "test"
1035
1036[tickets]
1037dir = "tickets"
1038
1039[[workflow.states]]
1040id = "ready"
1041label = "Ready"
1042actionable = ["agent"]
1043
1044[[workflow.states]]
1045id = "in_progress"
1046label = "In Progress"
1047
1048[[workflow.states]]
1049id = "specd"
1050label = "Specd"
1051actionable = ["supervisor"]
1052"#;
1053 let config: Config = toml::from_str(toml).unwrap();
1054 let states = config.actionable_states_for("agent");
1055 assert!(states.contains(&"ready".to_string()));
1056 assert!(!states.contains(&"specd".to_string()));
1057 assert!(!states.contains(&"in_progress".to_string()));
1058 }
1059
1060 #[test]
1061 fn work_epic_parses() {
1062 let toml = r#"
1063[project]
1064name = "test"
1065
1066[tickets]
1067dir = "tickets"
1068
1069[work]
1070epic = "ab12cd34"
1071"#;
1072 let config: Config = toml::from_str(toml).unwrap();
1073 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
1074 }
1075
1076 #[test]
1077 fn work_config_defaults_to_none() {
1078 let toml = r#"
1079[project]
1080name = "test"
1081
1082[tickets]
1083dir = "tickets"
1084"#;
1085 let config: Config = toml::from_str(toml).unwrap();
1086 assert!(config.work.epic.is_none());
1087 }
1088
1089 #[test]
1090 fn sync_aggressive_defaults_to_true() {
1091 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1092
1093 let config: Config = toml::from_str(base).unwrap();
1095 assert!(config.sync.aggressive, "no [sync] section should default to true");
1096
1097 let with_sync = format!("{base}[sync]\n");
1099 let config: Config = toml::from_str(&with_sync).unwrap();
1100 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1101
1102 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1104 let config: Config = toml::from_str(&explicit_false).unwrap();
1105 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1106
1107 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1109 let config: Config = toml::from_str(&explicit_true).unwrap();
1110 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1111 }
1112
1113 #[test]
1114 fn collaborators_parses() {
1115 let toml = r#"
1116[project]
1117name = "test"
1118collaborators = ["alice", "bob"]
1119
1120[tickets]
1121dir = "tickets"
1122"#;
1123 let config: Config = toml::from_str(toml).unwrap();
1124 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1125 }
1126
1127 #[test]
1128 fn collaborators_defaults_empty() {
1129 let toml = r#"
1130[project]
1131name = "test"
1132
1133[tickets]
1134dir = "tickets"
1135"#;
1136 let config: Config = toml::from_str(toml).unwrap();
1137 assert!(config.project.collaborators.is_empty());
1138 }
1139
1140 #[test]
1141 fn resolve_identity_returns_username_when_present() {
1142 let tmp = tempfile::tempdir().unwrap();
1143 let apm_dir = tmp.path().join(".apm");
1144 std::fs::create_dir_all(&apm_dir).unwrap();
1145 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1146 assert_eq!(resolve_identity(tmp.path()), "alice");
1147 }
1148
1149 #[test]
1150 fn resolve_identity_returns_unassigned_when_absent() {
1151 let tmp = tempfile::tempdir().unwrap();
1152 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1153 }
1154
1155 #[test]
1156 fn resolve_identity_returns_unassigned_when_empty() {
1157 let tmp = tempfile::tempdir().unwrap();
1158 let apm_dir = tmp.path().join(".apm");
1159 std::fs::create_dir_all(&apm_dir).unwrap();
1160 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1161 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1162 }
1163
1164 #[test]
1165 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1166 let tmp = tempfile::tempdir().unwrap();
1167 let apm_dir = tmp.path().join(".apm");
1168 std::fs::create_dir_all(&apm_dir).unwrap();
1169 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1170 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1171 }
1172
1173 #[test]
1174 fn local_config_username_parses() {
1175 let toml = r#"
1176username = "bob"
1177"#;
1178 let local: LocalConfig = toml::from_str(toml).unwrap();
1179 assert_eq!(local.username.as_deref(), Some("bob"));
1180 }
1181
1182 #[test]
1183 fn local_config_username_defaults_none() {
1184 let local: LocalConfig = toml::from_str("").unwrap();
1185 assert!(local.username.is_none());
1186 }
1187
1188 #[test]
1189 fn server_config_defaults() {
1190 let toml = r#"
1191[project]
1192name = "test"
1193
1194[tickets]
1195dir = "tickets"
1196"#;
1197 let config: Config = toml::from_str(toml).unwrap();
1198 assert_eq!(config.server.origin, "http://localhost:3000");
1199 }
1200
1201 #[test]
1202 fn server_config_custom_origin() {
1203 let toml = r#"
1204[project]
1205name = "test"
1206
1207[tickets]
1208dir = "tickets"
1209
1210[server]
1211origin = "https://apm.example.com"
1212"#;
1213 let config: Config = toml::from_str(toml).unwrap();
1214 assert_eq!(config.server.origin, "https://apm.example.com");
1215 }
1216
1217 #[test]
1218 fn git_host_config_parses() {
1219 let toml = r#"
1220[project]
1221name = "test"
1222
1223[tickets]
1224dir = "tickets"
1225
1226[git_host]
1227provider = "github"
1228repo = "owner/name"
1229"#;
1230 let config: Config = toml::from_str(toml).unwrap();
1231 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1232 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1233 }
1234
1235 #[test]
1236 fn git_host_config_absent_defaults_none() {
1237 let toml = r#"
1238[project]
1239name = "test"
1240
1241[tickets]
1242dir = "tickets"
1243"#;
1244 let config: Config = toml::from_str(toml).unwrap();
1245 assert!(config.git_host.provider.is_none());
1246 assert!(config.git_host.repo.is_none());
1247 }
1248
1249 #[test]
1250 fn local_config_github_token_parses() {
1251 let toml = r#"github_token = "ghp_abc123""#;
1252 let local: LocalConfig = toml::from_str(toml).unwrap();
1253 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1254 }
1255
1256 #[test]
1257 fn local_config_github_token_absent_defaults_none() {
1258 let local: LocalConfig = toml::from_str("").unwrap();
1259 assert!(local.github_token.is_none());
1260 }
1261
1262 #[test]
1263 fn tickets_archive_dir_parses() {
1264 let toml = r#"
1265[project]
1266name = "test"
1267
1268[tickets]
1269dir = "tickets"
1270archive_dir = "archive/tickets"
1271"#;
1272 let config: Config = toml::from_str(toml).unwrap();
1273 assert_eq!(
1274 config.tickets.archive_dir.as_deref(),
1275 Some(std::path::Path::new("archive/tickets"))
1276 );
1277 }
1278
1279 #[test]
1280 fn tickets_archive_dir_absent_defaults_none() {
1281 let toml = r#"
1282[project]
1283name = "test"
1284
1285[tickets]
1286dir = "tickets"
1287"#;
1288 let config: Config = toml::from_str(toml).unwrap();
1289 assert!(config.tickets.archive_dir.is_none());
1290 }
1291
1292 #[test]
1293 fn agents_max_workers_per_epic_defaults_to_one() {
1294 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1295 let config: Config = toml::from_str(toml).unwrap();
1296 assert_eq!(config.agents.max_workers_per_epic, 1);
1297 }
1298
1299 #[test]
1300 fn blocked_epics_global_limit_one() {
1301 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1302 let config: Config = toml::from_str(toml).unwrap();
1303 let active = vec![Some("epicA".to_string())];
1305 let blocked = config.blocked_epics(&active);
1306 assert!(blocked.contains(&"epicA".to_string()));
1307 }
1308
1309 #[test]
1310 fn blocked_epics_global_limit_two() {
1311 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1312 let config: Config = toml::from_str(toml).unwrap();
1313 let active = vec![Some("epicA".to_string())];
1315 let blocked = config.blocked_epics(&active);
1316 assert!(!blocked.contains(&"epicA".to_string()));
1317 }
1318
1319 #[test]
1320 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1321 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1322 let config: Config = toml::from_str(base).unwrap();
1323 assert_eq!(config.agents.max_workers_on_default, 1);
1324 let active: Vec<Option<String>> = vec![];
1326 assert!(!config.is_default_branch_blocked(&active));
1327 }
1328
1329 #[test]
1330 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1331 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1332 let config: Config = toml::from_str(base).unwrap();
1333 let active = vec![None];
1335 assert!(config.is_default_branch_blocked(&active));
1336 }
1337
1338 #[test]
1339 fn default_branch_not_blocked_when_limit_zero() {
1340 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1341 let config: Config = toml::from_str(toml).unwrap();
1342 let active = vec![None, None, None];
1344 assert!(!config.is_default_branch_blocked(&active));
1345 }
1346
1347 #[test]
1348 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1349 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1350 let config: Config = toml::from_str(base).unwrap();
1351 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1353 assert!(!config.is_default_branch_blocked(&active));
1354 }
1355
1356 #[test]
1357 fn prefers_apm_agent_name() {
1358 let _g = ENV_LOCK.lock().unwrap();
1359 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1360 assert_eq!(resolve_caller_name(), "explicit-agent");
1361 std::env::remove_var("APM_AGENT_NAME");
1362 }
1363
1364 #[test]
1365 fn falls_back_to_user() {
1366 let _g = ENV_LOCK.lock().unwrap();
1367 std::env::remove_var("APM_AGENT_NAME");
1368 std::env::set_var("USER", "unix-user");
1369 std::env::remove_var("USERNAME");
1370 assert_eq!(resolve_caller_name(), "unix-user");
1371 std::env::remove_var("USER");
1372 }
1373
1374 #[test]
1375 fn defaults_to_apm() {
1376 let _g = ENV_LOCK.lock().unwrap();
1377 std::env::remove_var("APM_AGENT_NAME");
1378 std::env::remove_var("USER");
1379 std::env::remove_var("USERNAME");
1380 assert_eq!(resolve_caller_name(), "apm");
1381 }
1382
1383 #[test]
1384 fn config_round_trip_new_shape() {
1385 let toml = r#"
1386[project]
1387name = "test"
1388
1389[tickets]
1390dir = "tickets"
1391
1392[workers]
1393agent = "claude"
1394
1395[workers.options]
1396model = "sonnet"
1397timeout = "30"
1398"#;
1399 let config: Config = toml::from_str(toml).unwrap();
1400 assert_eq!(config.workers.agent.as_deref(), Some("claude"));
1401 assert_eq!(config.workers.options.get("model").map(|s| s.as_str()), Some("sonnet"));
1402 assert_eq!(config.workers.options.get("timeout").map(|s| s.as_str()), Some("30"));
1403 assert!(config.workers.command.is_none());
1404 assert!(config.workers.args.is_none());
1405 }
1406
1407 #[test]
1408 fn config_round_trip_legacy_shape() {
1409 let toml = r#"
1410[project]
1411name = "test"
1412
1413[tickets]
1414dir = "tickets"
1415
1416[workers]
1417command = "claude"
1418args = ["--print"]
1419model = "opus"
1420"#;
1421 let config: Config = toml::from_str(toml).unwrap();
1422 assert!(config.workers.agent.is_none());
1423 assert_eq!(config.workers.command.as_deref(), Some("claude"));
1424 assert_eq!(config.workers.model.as_deref(), Some("opus"));
1425 }
1426
1427 #[test]
1428 fn worker_profile_config_new_fields() {
1429 let toml = r#"
1430[project]
1431name = "test"
1432
1433[tickets]
1434dir = "tickets"
1435
1436[worker_profiles.my_agent]
1437agent = "mock-happy"
1438
1439[worker_profiles.my_agent.options]
1440model = "sonnet"
1441"#;
1442 let config: Config = toml::from_str(toml).unwrap();
1443 let profile = config.worker_profiles.get("my_agent").unwrap();
1444 assert_eq!(profile.agent.as_deref(), Some("mock-happy"));
1445 assert_eq!(profile.options.get("model").map(|s| s.as_str()), Some("sonnet"));
1446 }
1447}