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 find_section(&self, name: &str) -> Option<&TicketSection> {
665 self.ticket.sections.iter()
666 .find(|s| s.name.eq_ignore_ascii_case(name))
667 }
668
669 pub fn has_section(&self, name: &str) -> bool {
670 self.find_section(name).is_some()
671 }
672
673 pub fn load(repo_root: &Path) -> Result<Self> {
674 let apm_dir = repo_root.join(".apm");
675 let apm_dir_config = apm_dir.join("config.toml");
676 let path = apm_dir_config;
677 let contents = std::fs::read_to_string(&path)
678 .with_context(|| format!(
679 "cannot read {} -- run 'apm init' to initialise this repository",
680 path.display()
681 ))?;
682 let mut config: Config = toml::from_str(&contents)
683 .with_context(|| format!("cannot parse {}", path.display()))?;
684
685 let workflow_path = apm_dir.join("workflow.toml");
686 if workflow_path.exists() {
687 let wf_contents = std::fs::read_to_string(&workflow_path)
688 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
689 let wf: WorkflowFile = toml::from_str(&wf_contents)
690 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
691 if !config.workflow.states.is_empty() {
692 config.load_warnings.push(
693 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
694 );
695 }
696 config.workflow = wf.workflow;
697 }
698
699 let ticket_path = apm_dir.join("ticket.toml");
700 if ticket_path.exists() {
701 let tk_contents = std::fs::read_to_string(&ticket_path)
702 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
703 let tk: TicketFile = toml::from_str(&tk_contents)
704 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
705 if !config.ticket.sections.is_empty() {
706 config.load_warnings.push(
707 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
708 );
709 }
710 config.ticket = tk.ticket;
711 }
712
713 let local_path = apm_dir.join("local.toml");
714 if local_path.exists() {
715 let local_contents = std::fs::read_to_string(&local_path)
716 .with_context(|| format!("cannot read {}", local_path.display()))?;
717 let local: LocalConfig = toml::from_str(&local_contents)
718 .with_context(|| format!("cannot parse {}", local_path.display()))?;
719 config.workers.merge_local(&local.workers);
720 }
721
722 Ok(config)
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use std::sync::Mutex;
730
731 static ENV_LOCK: Mutex<()> = Mutex::new(());
732
733 #[test]
734 fn ticket_section_full_parse() {
735 let toml = r#"
736name = "Problem"
737type = "free"
738required = true
739placeholder = "What is broken or missing?"
740"#;
741 let s: TicketSection = toml::from_str(toml).unwrap();
742 assert_eq!(s.name, "Problem");
743 assert_eq!(s.type_, SectionType::Free);
744 assert!(s.required);
745 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
746 }
747
748 #[test]
749 fn ticket_section_minimal_parse() {
750 let toml = r#"
751name = "Open questions"
752type = "qa"
753"#;
754 let s: TicketSection = toml::from_str(toml).unwrap();
755 assert_eq!(s.name, "Open questions");
756 assert_eq!(s.type_, SectionType::Qa);
757 assert!(!s.required);
758 assert!(s.placeholder.is_none());
759 }
760
761 #[test]
762 fn section_type_all_variants() {
763 #[derive(Deserialize)]
764 struct W { t: SectionType }
765 let free: W = toml::from_str("t = \"free\"").unwrap();
766 assert_eq!(free.t, SectionType::Free);
767 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
768 assert_eq!(tasks.t, SectionType::Tasks);
769 let qa: W = toml::from_str("t = \"qa\"").unwrap();
770 assert_eq!(qa.t, SectionType::Qa);
771 }
772
773 #[test]
774 fn completion_strategy_all_variants() {
775 #[derive(Deserialize)]
776 struct W { c: CompletionStrategy }
777 let pr: W = toml::from_str("c = \"pr\"").unwrap();
778 assert_eq!(pr.c, CompletionStrategy::Pr);
779 let merge: W = toml::from_str("c = \"merge\"").unwrap();
780 assert_eq!(merge.c, CompletionStrategy::Merge);
781 let pull: W = toml::from_str("c = \"pull\"").unwrap();
782 assert_eq!(pull.c, CompletionStrategy::Pull);
783 let none: W = toml::from_str("c = \"none\"").unwrap();
784 assert_eq!(none.c, CompletionStrategy::None);
785 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
786 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
787 }
788
789 #[test]
790 fn completion_strategy_default() {
791 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
792 }
793
794 #[test]
795 fn transition_config_new_fields() {
796 let toml = r#"
797to = "implemented"
798trigger = "manual"
799completion = "pr"
800focus_section = "Code review"
801context_section = "Problem"
802"#;
803 let t: TransitionConfig = toml::from_str(toml).unwrap();
804 assert_eq!(t.completion, CompletionStrategy::Pr);
805 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
806 assert_eq!(t.context_section.as_deref(), Some("Problem"));
807 }
808
809 #[test]
810 fn transition_config_new_fields_default() {
811 let toml = r#"
812to = "ready"
813trigger = "manual"
814"#;
815 let t: TransitionConfig = toml::from_str(toml).unwrap();
816 assert_eq!(t.completion, CompletionStrategy::None);
817 assert!(t.focus_section.is_none());
818 assert!(t.context_section.is_none());
819 assert!(t.outcome.is_none());
820 assert!(t.worker_profile.is_none());
821 }
822
823 #[test]
824 fn transition_config_worker_profile_field() {
825 let toml = r#"
826to = "in_design"
827trigger = "command:start"
828worker_profile = "claude/spec-writer"
829"#;
830 let t: TransitionConfig = toml::from_str(toml).unwrap();
831 assert_eq!(t.worker_profile.as_deref(), Some("claude/spec-writer"));
832 }
833
834 #[test]
835 fn resolve_outcome_explicit_override() {
836 let t: TransitionConfig = toml::from_str(r#"
837to = "ammend"
838outcome = "rejected"
839"#).unwrap();
840 let s: StateConfig = toml::from_str(r#"
841id = "ammend"
842label = "Ammend"
843"#).unwrap();
844 assert_eq!(super::resolve_outcome(&t, &s), "rejected");
845 }
846
847 #[test]
848 fn resolve_outcome_implicit_success() {
849 let t: TransitionConfig = toml::from_str(r#"
850to = "implemented"
851completion = "merge"
852"#).unwrap();
853 let s: StateConfig = toml::from_str(r#"
854id = "implemented"
855label = "Implemented"
856"#).unwrap();
857 assert_eq!(super::resolve_outcome(&t, &s), "success");
858 }
859
860 #[test]
861 fn resolve_outcome_implicit_cancelled() {
862 let t: TransitionConfig = toml::from_str(r#"
863to = "closed"
864"#).unwrap();
865 let s: StateConfig = toml::from_str(r#"
866id = "closed"
867label = "Closed"
868terminal = true
869"#).unwrap();
870 assert_eq!(super::resolve_outcome(&t, &s), "cancelled");
871 }
872
873 #[test]
874 fn resolve_outcome_implicit_needs_input() {
875 let t: TransitionConfig = toml::from_str(r#"
876to = "blocked"
877"#).unwrap();
878 let s: StateConfig = toml::from_str(r#"
879id = "blocked"
880label = "Blocked"
881"#).unwrap();
882 assert_eq!(super::resolve_outcome(&t, &s), "needs_input");
883 }
884
885 #[test]
886 fn workers_config_parses() {
887 let toml = r#"
888[project]
889name = "test"
890
891[tickets]
892dir = "tickets"
893
894[workers]
895container = "apm-worker:latest"
896
897[workers.keychain]
898ANTHROPIC_API_KEY = "anthropic-api-key"
899"#;
900 let config: Config = toml::from_str(toml).unwrap();
901 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
902 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
903 }
904
905 #[test]
906 fn workers_config_default() {
907 let toml = r#"
908[project]
909name = "test"
910
911[tickets]
912dir = "tickets"
913"#;
914 let config: Config = toml::from_str(toml).unwrap();
915 assert!(config.workers.container.is_none());
916 assert!(config.workers.keychain.is_empty());
917 assert!(config.workers.default.is_none());
918 assert!(config.workers.model.is_none());
919 assert!(config.workers.env.is_empty());
920 }
921
922 #[test]
923 fn workers_config_default_field() {
924 let toml = r#"
925[project]
926name = "test"
927
928[tickets]
929dir = "tickets"
930
931[workers]
932default = "claude/worker"
933"#;
934 let config: Config = toml::from_str(toml).unwrap();
935 assert_eq!(config.workers.default.as_deref(), Some("claude/worker"));
936 }
937
938 #[test]
939 fn workers_config_env_field() {
940 let toml = r#"
941[project]
942name = "test"
943
944[tickets]
945dir = "tickets"
946
947[workers.env]
948CUSTOM_VAR = "value"
949"#;
950 let config: Config = toml::from_str(toml).unwrap();
951 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
952 }
953
954 #[test]
955 fn local_config_parses() {
956 let toml = r#"
957[workers]
958command = "aider"
959model = "gpt-4"
960
961[workers.env]
962OPENAI_API_KEY = "sk-test"
963"#;
964 let local: LocalConfig = toml::from_str(toml).unwrap();
965 assert_eq!(local.workers.command.as_deref(), Some("aider"));
966 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
967 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
968 assert!(local.workers.args.is_none());
969 }
970
971 #[test]
972 fn merge_local_extends_env() {
973 let mut wc = WorkersConfig::default();
974 let local = LocalWorkersOverride {
975 command: None,
976 args: None,
977 model: None,
978 env: [("KEY".to_string(), "val".to_string())].into(),
979 };
980 wc.merge_local(&local);
981 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
982 }
983
984 #[test]
985 fn agents_skip_permissions_parses_and_defaults() {
986 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
987
988 let config: Config = toml::from_str(base).unwrap();
990 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
991
992 let with_agents = format!("{base}[agents]\n");
994 let config: Config = toml::from_str(&with_agents).unwrap();
995 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
996
997 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
999 let config: Config = toml::from_str(&explicit_true).unwrap();
1000 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
1001
1002 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
1004 let config: Config = toml::from_str(&explicit_false).unwrap();
1005 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
1006 }
1007
1008 #[test]
1009 fn actionable_states_for_agent_includes_ready() {
1010 let toml = r#"
1011[project]
1012name = "test"
1013
1014[tickets]
1015dir = "tickets"
1016
1017[[workflow.states]]
1018id = "ready"
1019label = "Ready"
1020actionable = ["agent"]
1021
1022[[workflow.states]]
1023id = "in_progress"
1024label = "In Progress"
1025
1026[[workflow.states]]
1027id = "specd"
1028label = "Specd"
1029actionable = ["supervisor"]
1030"#;
1031 let config: Config = toml::from_str(toml).unwrap();
1032 let states = config.actionable_states_for("agent");
1033 assert!(states.contains(&"ready".to_string()));
1034 assert!(!states.contains(&"specd".to_string()));
1035 assert!(!states.contains(&"in_progress".to_string()));
1036 }
1037
1038 #[test]
1039 fn work_epic_parses() {
1040 let toml = r#"
1041[project]
1042name = "test"
1043
1044[tickets]
1045dir = "tickets"
1046
1047[work]
1048epic = "ab12cd34"
1049"#;
1050 let config: Config = toml::from_str(toml).unwrap();
1051 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
1052 }
1053
1054 #[test]
1055 fn work_config_defaults_to_none() {
1056 let toml = r#"
1057[project]
1058name = "test"
1059
1060[tickets]
1061dir = "tickets"
1062"#;
1063 let config: Config = toml::from_str(toml).unwrap();
1064 assert!(config.work.epic.is_none());
1065 }
1066
1067 #[test]
1068 fn sync_aggressive_defaults_to_true() {
1069 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1070
1071 let config: Config = toml::from_str(base).unwrap();
1073 assert!(config.sync.aggressive, "no [sync] section should default to true");
1074
1075 let with_sync = format!("{base}[sync]\n");
1077 let config: Config = toml::from_str(&with_sync).unwrap();
1078 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1079
1080 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1082 let config: Config = toml::from_str(&explicit_false).unwrap();
1083 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1084
1085 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1087 let config: Config = toml::from_str(&explicit_true).unwrap();
1088 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1089 }
1090
1091 #[test]
1092 fn collaborators_parses() {
1093 let toml = r#"
1094[project]
1095name = "test"
1096collaborators = ["alice", "bob"]
1097
1098[tickets]
1099dir = "tickets"
1100"#;
1101 let config: Config = toml::from_str(toml).unwrap();
1102 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1103 }
1104
1105 #[test]
1106 fn collaborators_defaults_empty() {
1107 let toml = r#"
1108[project]
1109name = "test"
1110
1111[tickets]
1112dir = "tickets"
1113"#;
1114 let config: Config = toml::from_str(toml).unwrap();
1115 assert!(config.project.collaborators.is_empty());
1116 }
1117
1118 #[test]
1119 fn resolve_identity_returns_username_when_present() {
1120 let tmp = tempfile::tempdir().unwrap();
1121 let apm_dir = tmp.path().join(".apm");
1122 std::fs::create_dir_all(&apm_dir).unwrap();
1123 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1124 assert_eq!(resolve_identity(tmp.path()), "alice");
1125 }
1126
1127 #[test]
1128 fn resolve_identity_returns_unassigned_when_absent() {
1129 let tmp = tempfile::tempdir().unwrap();
1130 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1131 }
1132
1133 #[test]
1134 fn resolve_identity_returns_unassigned_when_empty() {
1135 let tmp = tempfile::tempdir().unwrap();
1136 let apm_dir = tmp.path().join(".apm");
1137 std::fs::create_dir_all(&apm_dir).unwrap();
1138 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1139 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1140 }
1141
1142 #[test]
1143 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1144 let tmp = tempfile::tempdir().unwrap();
1145 let apm_dir = tmp.path().join(".apm");
1146 std::fs::create_dir_all(&apm_dir).unwrap();
1147 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1148 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1149 }
1150
1151 #[test]
1152 fn local_config_username_parses() {
1153 let toml = r#"
1154username = "bob"
1155"#;
1156 let local: LocalConfig = toml::from_str(toml).unwrap();
1157 assert_eq!(local.username.as_deref(), Some("bob"));
1158 }
1159
1160 #[test]
1161 fn local_config_username_defaults_none() {
1162 let local: LocalConfig = toml::from_str("").unwrap();
1163 assert!(local.username.is_none());
1164 }
1165
1166 #[test]
1167 fn server_config_defaults() {
1168 let toml = r#"
1169[project]
1170name = "test"
1171
1172[tickets]
1173dir = "tickets"
1174"#;
1175 let config: Config = toml::from_str(toml).unwrap();
1176 assert_eq!(config.server.origin, "http://localhost:3000");
1177 }
1178
1179 #[test]
1180 fn server_config_custom_origin() {
1181 let toml = r#"
1182[project]
1183name = "test"
1184
1185[tickets]
1186dir = "tickets"
1187
1188[server]
1189origin = "https://apm.example.com"
1190"#;
1191 let config: Config = toml::from_str(toml).unwrap();
1192 assert_eq!(config.server.origin, "https://apm.example.com");
1193 }
1194
1195 #[test]
1196 fn git_host_config_parses() {
1197 let toml = r#"
1198[project]
1199name = "test"
1200
1201[tickets]
1202dir = "tickets"
1203
1204[git_host]
1205provider = "github"
1206repo = "owner/name"
1207"#;
1208 let config: Config = toml::from_str(toml).unwrap();
1209 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1210 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1211 }
1212
1213 #[test]
1214 fn git_host_config_absent_defaults_none() {
1215 let toml = r#"
1216[project]
1217name = "test"
1218
1219[tickets]
1220dir = "tickets"
1221"#;
1222 let config: Config = toml::from_str(toml).unwrap();
1223 assert!(config.git_host.provider.is_none());
1224 assert!(config.git_host.repo.is_none());
1225 }
1226
1227 #[test]
1228 fn local_config_github_token_parses() {
1229 let toml = r#"github_token = "ghp_abc123""#;
1230 let local: LocalConfig = toml::from_str(toml).unwrap();
1231 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1232 }
1233
1234 #[test]
1235 fn local_config_github_token_absent_defaults_none() {
1236 let local: LocalConfig = toml::from_str("").unwrap();
1237 assert!(local.github_token.is_none());
1238 }
1239
1240 #[test]
1241 fn tickets_archive_dir_parses() {
1242 let toml = r#"
1243[project]
1244name = "test"
1245
1246[tickets]
1247dir = "tickets"
1248archive_dir = "archive/tickets"
1249"#;
1250 let config: Config = toml::from_str(toml).unwrap();
1251 assert_eq!(
1252 config.tickets.archive_dir.as_deref(),
1253 Some(std::path::Path::new("archive/tickets"))
1254 );
1255 }
1256
1257 #[test]
1258 fn tickets_archive_dir_absent_defaults_none() {
1259 let toml = r#"
1260[project]
1261name = "test"
1262
1263[tickets]
1264dir = "tickets"
1265"#;
1266 let config: Config = toml::from_str(toml).unwrap();
1267 assert!(config.tickets.archive_dir.is_none());
1268 }
1269
1270 #[test]
1271 fn agents_max_workers_per_epic_defaults_to_one() {
1272 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1273 let config: Config = toml::from_str(toml).unwrap();
1274 assert_eq!(config.agents.max_workers_per_epic, 1);
1275 }
1276
1277 #[test]
1278 fn blocked_epics_global_limit_one() {
1279 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1280 let config: Config = toml::from_str(toml).unwrap();
1281 let active = vec![Some("epicA".to_string())];
1283 let blocked = config.blocked_epics(&active);
1284 assert!(blocked.contains(&"epicA".to_string()));
1285 }
1286
1287 #[test]
1288 fn blocked_epics_global_limit_two() {
1289 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1290 let config: Config = toml::from_str(toml).unwrap();
1291 let active = vec![Some("epicA".to_string())];
1293 let blocked = config.blocked_epics(&active);
1294 assert!(!blocked.contains(&"epicA".to_string()));
1295 }
1296
1297 #[test]
1298 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1299 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1300 let config: Config = toml::from_str(base).unwrap();
1301 assert_eq!(config.agents.max_workers_on_default, 1);
1302 let active: Vec<Option<String>> = vec![];
1304 assert!(!config.is_default_branch_blocked(&active));
1305 }
1306
1307 #[test]
1308 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1309 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1310 let config: Config = toml::from_str(base).unwrap();
1311 let active = vec![None];
1313 assert!(config.is_default_branch_blocked(&active));
1314 }
1315
1316 #[test]
1317 fn default_branch_not_blocked_when_limit_zero() {
1318 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1319 let config: Config = toml::from_str(toml).unwrap();
1320 let active = vec![None, None, None];
1322 assert!(!config.is_default_branch_blocked(&active));
1323 }
1324
1325 #[test]
1326 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1327 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1328 let config: Config = toml::from_str(base).unwrap();
1329 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1331 assert!(!config.is_default_branch_blocked(&active));
1332 }
1333
1334 #[test]
1335 fn prefers_apm_agent_name() {
1336 let _g = ENV_LOCK.lock().unwrap();
1337 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1338 assert_eq!(resolve_caller_name(), "explicit-agent");
1339 std::env::remove_var("APM_AGENT_NAME");
1340 }
1341
1342 #[test]
1343 fn falls_back_to_user() {
1344 let _g = ENV_LOCK.lock().unwrap();
1345 std::env::remove_var("APM_AGENT_NAME");
1346 std::env::set_var("USER", "unix-user");
1347 std::env::remove_var("USERNAME");
1348 assert_eq!(resolve_caller_name(), "unix-user");
1349 std::env::remove_var("USER");
1350 }
1351
1352 #[test]
1353 fn defaults_to_apm() {
1354 let _g = ENV_LOCK.lock().unwrap();
1355 std::env::remove_var("APM_AGENT_NAME");
1356 std::env::remove_var("USER");
1357 std::env::remove_var("USERNAME");
1358 assert_eq!(resolve_caller_name(), "apm");
1359 }
1360
1361}