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