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)]
481 pub project: Option<PathBuf>,
482 #[serde(default)]
485 pub instructions: Option<PathBuf>,
486 #[serde(default = "default_true")]
488 pub side_tickets: bool,
489 #[serde(default)]
491 pub skip_permissions: bool,
492}
493
494fn default_max_concurrent() -> usize { 3 }
495fn default_max_workers_per_epic() -> usize { 1 }
496fn default_max_workers_on_default() -> usize { 1 }
497fn default_true() -> bool { true }
498
499#[derive(Debug, Deserialize, JsonSchema)]
500pub struct WorktreesConfig {
501 pub dir: PathBuf,
503 #[serde(default)]
505 pub agent_dirs: Vec<String>,
506}
507
508impl Default for WorktreesConfig {
509 fn default() -> Self {
510 Self {
511 dir: PathBuf::from("../worktrees"),
512 agent_dirs: Vec::new(),
513 }
514 }
515}
516
517impl Default for AgentsConfig {
518 fn default() -> Self {
519 Self {
520 max_concurrent: default_max_concurrent(),
521 max_workers_per_epic: default_max_workers_per_epic(),
522 max_workers_on_default: default_max_workers_on_default(),
523 project: None,
524 instructions: None,
525 side_tickets: true,
526 skip_permissions: false,
527 }
528 }
529}
530
531#[derive(Debug, Deserialize, Default)]
532pub struct LocalConfig {
533 #[serde(default)]
534 pub workers: LocalWorkersOverride,
535 #[serde(default)]
536 pub username: Option<String>,
537 #[serde(default)]
538 pub github_token: Option<String>,
539}
540
541#[derive(Debug, Deserialize, Default)]
542pub struct LocalWorkersOverride {
543 pub command: Option<String>,
544 pub args: Option<Vec<String>>,
545 pub model: Option<String>,
546 #[serde(default)]
547 pub env: std::collections::HashMap<String, String>,
548}
549
550impl LocalConfig {
551 pub fn load(root: &Path) -> Self {
552 let local_path = root.join(".apm").join("local.toml");
553 std::fs::read_to_string(&local_path)
554 .ok()
555 .and_then(|s| toml::from_str(&s).ok())
556 .unwrap_or_default()
557 }
558}
559
560fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
561 if let Some(ref t) = local.github_token {
562 if !t.is_empty() {
563 return Some(t.clone());
564 }
565 }
566 if let Some(ref env_var) = git_host.token_env {
567 if let Ok(t) = std::env::var(env_var) {
568 if !t.is_empty() {
569 return Some(t);
570 }
571 }
572 }
573 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
574}
575
576pub fn resolve_identity(repo_root: &Path) -> String {
577 let local_path = repo_root.join(".apm").join("local.toml");
578 let local: LocalConfig = std::fs::read_to_string(&local_path)
579 .ok()
580 .and_then(|s| toml::from_str(&s).ok())
581 .unwrap_or_default();
582
583 let config_path = repo_root.join(".apm").join("config.toml");
584 let config: Option<Config> = std::fs::read_to_string(&config_path)
585 .ok()
586 .and_then(|s| toml::from_str(&s).ok());
587
588 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
589 if git_host.provider.is_some() {
590 if git_host.provider.as_deref() == Some("github") {
592 if let Some(login) = crate::github::gh_username() {
593 return login;
594 }
595 if let Some(token) = effective_github_token(&local, &git_host) {
596 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
597 return login;
598 }
599 }
600 }
601 return "unassigned".to_string();
602 }
603
604 if let Some(ref u) = local.username {
606 if !u.is_empty() {
607 return u.clone();
608 }
609 }
610 "unassigned".to_string()
611}
612
613pub fn resolve_caller_name() -> String {
630 std::env::var("APM_AGENT_TYPE")
631 .or_else(|_| std::env::var("APM_AGENT_NAME"))
632 .or_else(|_| std::env::var("USER"))
633 .or_else(|_| std::env::var("USERNAME"))
634 .unwrap_or_else(|_| "apm".to_string())
635}
636
637pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
638 if git_host.provider.as_deref() != Some("github") {
639 return None;
640 }
641 if let Some(login) = crate::github::gh_username() {
642 return Some(login);
643 }
644 let local = LocalConfig::default();
645 let token = effective_github_token(&local, git_host)?;
646 crate::github::fetch_authenticated_user(&token).ok()
647}
648
649pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
650 let mut warnings = Vec::new();
651 if config.git_host.provider.as_deref() == Some("github") {
652 if let Some(ref repo) = config.git_host.repo {
653 if let Some(token) = effective_github_token(local, &config.git_host) {
654 match crate::github::fetch_repo_collaborators(&token, repo) {
655 Ok(logins) => return (logins, warnings),
656 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
657 }
658 }
659 }
660 }
661 (config.project.collaborators.clone(), warnings)
662}
663
664impl WorkersConfig {
665 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
666 if let Some(ref cmd) = local.command {
667 self.command = Some(cmd.clone());
668 }
669 if let Some(ref args) = local.args {
670 self.args = Some(args.clone());
671 }
672 if let Some(ref model) = local.model {
673 self.model = Some(model.clone());
674 }
675 for (k, v) in &local.env {
676 self.env.insert(k.clone(), v.clone());
677 }
678 }
679}
680
681impl Config {
682 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
685 let limit = self.agents.max_workers_per_epic;
686 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
687 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
688 *counts.entry(eid).or_insert(0) += 1;
689 }
690 counts.into_iter()
691 .filter(|(_, count)| *count >= limit)
692 .map(|(eid, _)| eid.to_string())
693 .collect()
694 }
695
696 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
699 if self.agents.max_workers_on_default == 0 {
700 return false;
701 }
702 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
703 count >= self.agents.max_workers_on_default
704 }
705
706 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
709 self.workflow.states.iter()
710 .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
711 .map(|s| s.id.clone())
712 .collect()
713 }
714
715 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
716 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
717 .filter(|s| s.terminal)
718 .map(|s| s.id.clone())
719 .collect();
720 ids.insert("closed".to_string());
721 ids
722 }
723
724 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
725 self.ticket.sections.iter()
726 .find(|s| s.name.eq_ignore_ascii_case(name))
727 }
728
729 pub fn has_section(&self, name: &str) -> bool {
730 self.find_section(name).is_some()
731 }
732
733 pub fn load(repo_root: &Path) -> Result<Self> {
734 let apm_dir = repo_root.join(".apm");
735 let apm_dir_config = apm_dir.join("config.toml");
736 let path = apm_dir_config;
737 let contents = std::fs::read_to_string(&path)
738 .with_context(|| format!(
739 "cannot read {} -- run 'apm init' to initialise this repository",
740 path.display()
741 ))?;
742 let mut config: Config = toml::from_str(&contents)
743 .with_context(|| format!("cannot parse {}", path.display()))?;
744
745 let workflow_path = apm_dir.join("workflow.toml");
746 if workflow_path.exists() {
747 let wf_contents = std::fs::read_to_string(&workflow_path)
748 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
749 let wf: WorkflowFile = toml::from_str(&wf_contents)
750 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
751 if !config.workflow.states.is_empty() {
752 config.load_warnings.push(
753 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
754 );
755 }
756 config.workflow = wf.workflow;
757 }
758
759 let ticket_path = apm_dir.join("ticket.toml");
760 if ticket_path.exists() {
761 let tk_contents = std::fs::read_to_string(&ticket_path)
762 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
763 let tk: TicketFile = toml::from_str(&tk_contents)
764 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
765 if !config.ticket.sections.is_empty() {
766 config.load_warnings.push(
767 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
768 );
769 }
770 config.ticket = tk.ticket;
771 }
772
773 let local_path = apm_dir.join("local.toml");
774 if local_path.exists() {
775 let local_contents = std::fs::read_to_string(&local_path)
776 .with_context(|| format!("cannot read {}", local_path.display()))?;
777 let local: LocalConfig = toml::from_str(&local_contents)
778 .with_context(|| format!("cannot parse {}", local_path.display()))?;
779 config.workers.merge_local(&local.workers);
780 }
781
782 Ok(config)
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use std::sync::Mutex;
790
791 static ENV_LOCK: Mutex<()> = Mutex::new(());
792
793 #[test]
794 fn ticket_section_full_parse() {
795 let toml = r#"
796name = "Problem"
797type = "free"
798required = true
799placeholder = "What is broken or missing?"
800"#;
801 let s: TicketSection = toml::from_str(toml).unwrap();
802 assert_eq!(s.name, "Problem");
803 assert_eq!(s.type_, SectionType::Free);
804 assert!(s.required);
805 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
806 }
807
808 #[test]
809 fn ticket_section_minimal_parse() {
810 let toml = r#"
811name = "Open questions"
812type = "qa"
813"#;
814 let s: TicketSection = toml::from_str(toml).unwrap();
815 assert_eq!(s.name, "Open questions");
816 assert_eq!(s.type_, SectionType::Qa);
817 assert!(!s.required);
818 assert!(s.placeholder.is_none());
819 }
820
821 #[test]
822 fn section_type_all_variants() {
823 #[derive(Deserialize)]
824 struct W { t: SectionType }
825 let free: W = toml::from_str("t = \"free\"").unwrap();
826 assert_eq!(free.t, SectionType::Free);
827 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
828 assert_eq!(tasks.t, SectionType::Tasks);
829 let qa: W = toml::from_str("t = \"qa\"").unwrap();
830 assert_eq!(qa.t, SectionType::Qa);
831 }
832
833 #[test]
834 fn completion_strategy_all_variants() {
835 #[derive(Deserialize)]
836 struct W { c: CompletionStrategy }
837 let pr: W = toml::from_str("c = \"pr\"").unwrap();
838 assert_eq!(pr.c, CompletionStrategy::Pr);
839 let merge: W = toml::from_str("c = \"merge\"").unwrap();
840 assert_eq!(merge.c, CompletionStrategy::Merge);
841 let pull: W = toml::from_str("c = \"pull\"").unwrap();
842 assert_eq!(pull.c, CompletionStrategy::Pull);
843 let none: W = toml::from_str("c = \"none\"").unwrap();
844 assert_eq!(none.c, CompletionStrategy::None);
845 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
846 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
847 }
848
849 #[test]
850 fn completion_strategy_default() {
851 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
852 }
853
854 #[test]
855 fn state_config_with_instructions() {
856 let toml = r#"
857id = "in_progress"
858label = "In Progress"
859instructions = "apm.worker.md"
860"#;
861 let s: StateConfig = toml::from_str(toml).unwrap();
862 assert_eq!(s.id, "in_progress");
863 assert_eq!(s.instructions.as_deref(), Some("apm.worker.md"));
864 }
865
866 #[test]
867 fn state_config_instructions_default_none() {
868 let toml = r#"
869id = "new"
870label = "New"
871"#;
872 let s: StateConfig = toml::from_str(toml).unwrap();
873 assert!(s.instructions.is_none());
874 }
875
876 #[test]
877 fn transition_config_new_fields() {
878 let toml = r#"
879to = "implemented"
880trigger = "manual"
881completion = "pr"
882focus_section = "Code review"
883context_section = "Problem"
884"#;
885 let t: TransitionConfig = toml::from_str(toml).unwrap();
886 assert_eq!(t.completion, CompletionStrategy::Pr);
887 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
888 assert_eq!(t.context_section.as_deref(), Some("Problem"));
889 }
890
891 #[test]
892 fn transition_config_new_fields_default() {
893 let toml = r#"
894to = "ready"
895trigger = "manual"
896"#;
897 let t: TransitionConfig = toml::from_str(toml).unwrap();
898 assert_eq!(t.completion, CompletionStrategy::None);
899 assert!(t.focus_section.is_none());
900 assert!(t.context_section.is_none());
901 assert!(t.outcome.is_none());
902 assert!(t.instructions.is_none());
903 assert!(t.role_prefix.is_none());
904 assert!(t.agent.is_none());
905 }
906
907 #[test]
908 fn resolve_outcome_explicit_override() {
909 let t: TransitionConfig = toml::from_str(r#"
910to = "ammend"
911outcome = "rejected"
912"#).unwrap();
913 let s: StateConfig = toml::from_str(r#"
914id = "ammend"
915label = "Ammend"
916"#).unwrap();
917 assert_eq!(super::resolve_outcome(&t, &s), "rejected");
918 }
919
920 #[test]
921 fn resolve_outcome_implicit_success() {
922 let t: TransitionConfig = toml::from_str(r#"
923to = "implemented"
924completion = "merge"
925"#).unwrap();
926 let s: StateConfig = toml::from_str(r#"
927id = "implemented"
928label = "Implemented"
929"#).unwrap();
930 assert_eq!(super::resolve_outcome(&t, &s), "success");
931 }
932
933 #[test]
934 fn resolve_outcome_implicit_cancelled() {
935 let t: TransitionConfig = toml::from_str(r#"
936to = "closed"
937"#).unwrap();
938 let s: StateConfig = toml::from_str(r#"
939id = "closed"
940label = "Closed"
941terminal = true
942"#).unwrap();
943 assert_eq!(super::resolve_outcome(&t, &s), "cancelled");
944 }
945
946 #[test]
947 fn resolve_outcome_implicit_needs_input() {
948 let t: TransitionConfig = toml::from_str(r#"
949to = "blocked"
950"#).unwrap();
951 let s: StateConfig = toml::from_str(r#"
952id = "blocked"
953label = "Blocked"
954"#).unwrap();
955 assert_eq!(super::resolve_outcome(&t, &s), "needs_input");
956 }
957
958 #[test]
959 fn workers_config_parses() {
960 let toml = r#"
961[project]
962name = "test"
963
964[tickets]
965dir = "tickets"
966
967[workers]
968container = "apm-worker:latest"
969
970[workers.keychain]
971ANTHROPIC_API_KEY = "anthropic-api-key"
972"#;
973 let config: Config = toml::from_str(toml).unwrap();
974 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
975 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
976 }
977
978 #[test]
979 fn workers_config_default() {
980 let toml = r#"
981[project]
982name = "test"
983
984[tickets]
985dir = "tickets"
986"#;
987 let config: Config = toml::from_str(toml).unwrap();
988 assert!(config.workers.container.is_none());
989 assert!(config.workers.keychain.is_empty());
990 assert!(config.workers.command.is_none());
991 assert!(config.workers.args.is_none());
992 assert!(config.workers.agent.is_none());
993 assert!(config.workers.options.is_empty());
994 assert!(config.workers.model.is_none());
995 assert!(config.workers.env.is_empty());
996 }
997
998 #[test]
999 fn workers_config_all_fields() {
1000 let toml = r#"
1001[project]
1002name = "test"
1003
1004[tickets]
1005dir = "tickets"
1006
1007[workers]
1008command = "codex"
1009args = ["--full-auto"]
1010model = "o3"
1011
1012[workers.env]
1013CUSTOM_VAR = "value"
1014"#;
1015 let config: Config = toml::from_str(toml).unwrap();
1016 assert_eq!(config.workers.command.as_deref(), Some("codex"));
1017 assert_eq!(config.workers.args.as_deref(), Some(["--full-auto".to_string()][..].as_ref()));
1018 assert_eq!(config.workers.model.as_deref(), Some("o3"));
1019 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
1020 }
1021
1022 #[test]
1023 fn local_config_parses() {
1024 let toml = r#"
1025[workers]
1026command = "aider"
1027model = "gpt-4"
1028
1029[workers.env]
1030OPENAI_API_KEY = "sk-test"
1031"#;
1032 let local: LocalConfig = toml::from_str(toml).unwrap();
1033 assert_eq!(local.workers.command.as_deref(), Some("aider"));
1034 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
1035 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
1036 assert!(local.workers.args.is_none());
1037 }
1038
1039 #[test]
1040 fn merge_local_overrides_and_extends() {
1041 let mut wc = WorkersConfig::default();
1042 assert!(wc.command.is_none());
1043 assert!(wc.args.is_none());
1044
1045 let local = LocalWorkersOverride {
1046 command: Some("aider".to_string()),
1047 args: None,
1048 model: Some("gpt-4".to_string()),
1049 env: [("KEY".to_string(), "val".to_string())].into(),
1050 };
1051 wc.merge_local(&local);
1052
1053 assert_eq!(wc.command.as_deref(), Some("aider"));
1054 assert!(wc.args.is_none()); assert_eq!(wc.model.as_deref(), Some("gpt-4"));
1056 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
1057 }
1058
1059 #[test]
1060 fn agents_skip_permissions_parses_and_defaults() {
1061 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1062
1063 let config: Config = toml::from_str(base).unwrap();
1065 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
1066
1067 let with_agents = format!("{base}[agents]\n");
1069 let config: Config = toml::from_str(&with_agents).unwrap();
1070 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
1071
1072 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
1074 let config: Config = toml::from_str(&explicit_true).unwrap();
1075 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
1076
1077 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
1079 let config: Config = toml::from_str(&explicit_false).unwrap();
1080 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
1081 }
1082
1083 #[test]
1084 fn actionable_states_for_agent_includes_ready() {
1085 let toml = r#"
1086[project]
1087name = "test"
1088
1089[tickets]
1090dir = "tickets"
1091
1092[[workflow.states]]
1093id = "ready"
1094label = "Ready"
1095actionable = ["agent"]
1096
1097[[workflow.states]]
1098id = "in_progress"
1099label = "In Progress"
1100
1101[[workflow.states]]
1102id = "specd"
1103label = "Specd"
1104actionable = ["supervisor"]
1105"#;
1106 let config: Config = toml::from_str(toml).unwrap();
1107 let states = config.actionable_states_for("agent");
1108 assert!(states.contains(&"ready".to_string()));
1109 assert!(!states.contains(&"specd".to_string()));
1110 assert!(!states.contains(&"in_progress".to_string()));
1111 }
1112
1113 #[test]
1114 fn work_epic_parses() {
1115 let toml = r#"
1116[project]
1117name = "test"
1118
1119[tickets]
1120dir = "tickets"
1121
1122[work]
1123epic = "ab12cd34"
1124"#;
1125 let config: Config = toml::from_str(toml).unwrap();
1126 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
1127 }
1128
1129 #[test]
1130 fn work_config_defaults_to_none() {
1131 let toml = r#"
1132[project]
1133name = "test"
1134
1135[tickets]
1136dir = "tickets"
1137"#;
1138 let config: Config = toml::from_str(toml).unwrap();
1139 assert!(config.work.epic.is_none());
1140 }
1141
1142 #[test]
1143 fn sync_aggressive_defaults_to_true() {
1144 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
1145
1146 let config: Config = toml::from_str(base).unwrap();
1148 assert!(config.sync.aggressive, "no [sync] section should default to true");
1149
1150 let with_sync = format!("{base}[sync]\n");
1152 let config: Config = toml::from_str(&with_sync).unwrap();
1153 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1154
1155 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1157 let config: Config = toml::from_str(&explicit_false).unwrap();
1158 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1159
1160 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1162 let config: Config = toml::from_str(&explicit_true).unwrap();
1163 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1164 }
1165
1166 #[test]
1167 fn collaborators_parses() {
1168 let toml = r#"
1169[project]
1170name = "test"
1171collaborators = ["alice", "bob"]
1172
1173[tickets]
1174dir = "tickets"
1175"#;
1176 let config: Config = toml::from_str(toml).unwrap();
1177 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1178 }
1179
1180 #[test]
1181 fn collaborators_defaults_empty() {
1182 let toml = r#"
1183[project]
1184name = "test"
1185
1186[tickets]
1187dir = "tickets"
1188"#;
1189 let config: Config = toml::from_str(toml).unwrap();
1190 assert!(config.project.collaborators.is_empty());
1191 }
1192
1193 #[test]
1194 fn resolve_identity_returns_username_when_present() {
1195 let tmp = tempfile::tempdir().unwrap();
1196 let apm_dir = tmp.path().join(".apm");
1197 std::fs::create_dir_all(&apm_dir).unwrap();
1198 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1199 assert_eq!(resolve_identity(tmp.path()), "alice");
1200 }
1201
1202 #[test]
1203 fn resolve_identity_returns_unassigned_when_absent() {
1204 let tmp = tempfile::tempdir().unwrap();
1205 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1206 }
1207
1208 #[test]
1209 fn resolve_identity_returns_unassigned_when_empty() {
1210 let tmp = tempfile::tempdir().unwrap();
1211 let apm_dir = tmp.path().join(".apm");
1212 std::fs::create_dir_all(&apm_dir).unwrap();
1213 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1214 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1215 }
1216
1217 #[test]
1218 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1219 let tmp = tempfile::tempdir().unwrap();
1220 let apm_dir = tmp.path().join(".apm");
1221 std::fs::create_dir_all(&apm_dir).unwrap();
1222 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1223 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1224 }
1225
1226 #[test]
1227 fn local_config_username_parses() {
1228 let toml = r#"
1229username = "bob"
1230"#;
1231 let local: LocalConfig = toml::from_str(toml).unwrap();
1232 assert_eq!(local.username.as_deref(), Some("bob"));
1233 }
1234
1235 #[test]
1236 fn local_config_username_defaults_none() {
1237 let local: LocalConfig = toml::from_str("").unwrap();
1238 assert!(local.username.is_none());
1239 }
1240
1241 #[test]
1242 fn server_config_defaults() {
1243 let toml = r#"
1244[project]
1245name = "test"
1246
1247[tickets]
1248dir = "tickets"
1249"#;
1250 let config: Config = toml::from_str(toml).unwrap();
1251 assert_eq!(config.server.origin, "http://localhost:3000");
1252 }
1253
1254 #[test]
1255 fn server_config_custom_origin() {
1256 let toml = r#"
1257[project]
1258name = "test"
1259
1260[tickets]
1261dir = "tickets"
1262
1263[server]
1264origin = "https://apm.example.com"
1265"#;
1266 let config: Config = toml::from_str(toml).unwrap();
1267 assert_eq!(config.server.origin, "https://apm.example.com");
1268 }
1269
1270 #[test]
1271 fn git_host_config_parses() {
1272 let toml = r#"
1273[project]
1274name = "test"
1275
1276[tickets]
1277dir = "tickets"
1278
1279[git_host]
1280provider = "github"
1281repo = "owner/name"
1282"#;
1283 let config: Config = toml::from_str(toml).unwrap();
1284 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1285 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1286 }
1287
1288 #[test]
1289 fn git_host_config_absent_defaults_none() {
1290 let toml = r#"
1291[project]
1292name = "test"
1293
1294[tickets]
1295dir = "tickets"
1296"#;
1297 let config: Config = toml::from_str(toml).unwrap();
1298 assert!(config.git_host.provider.is_none());
1299 assert!(config.git_host.repo.is_none());
1300 }
1301
1302 #[test]
1303 fn local_config_github_token_parses() {
1304 let toml = r#"github_token = "ghp_abc123""#;
1305 let local: LocalConfig = toml::from_str(toml).unwrap();
1306 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1307 }
1308
1309 #[test]
1310 fn local_config_github_token_absent_defaults_none() {
1311 let local: LocalConfig = toml::from_str("").unwrap();
1312 assert!(local.github_token.is_none());
1313 }
1314
1315 #[test]
1316 fn tickets_archive_dir_parses() {
1317 let toml = r#"
1318[project]
1319name = "test"
1320
1321[tickets]
1322dir = "tickets"
1323archive_dir = "archive/tickets"
1324"#;
1325 let config: Config = toml::from_str(toml).unwrap();
1326 assert_eq!(
1327 config.tickets.archive_dir.as_deref(),
1328 Some(std::path::Path::new("archive/tickets"))
1329 );
1330 }
1331
1332 #[test]
1333 fn tickets_archive_dir_absent_defaults_none() {
1334 let toml = r#"
1335[project]
1336name = "test"
1337
1338[tickets]
1339dir = "tickets"
1340"#;
1341 let config: Config = toml::from_str(toml).unwrap();
1342 assert!(config.tickets.archive_dir.is_none());
1343 }
1344
1345 #[test]
1346 fn agents_max_workers_per_epic_defaults_to_one() {
1347 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1348 let config: Config = toml::from_str(toml).unwrap();
1349 assert_eq!(config.agents.max_workers_per_epic, 1);
1350 }
1351
1352 #[test]
1353 fn blocked_epics_global_limit_one() {
1354 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1355 let config: Config = toml::from_str(toml).unwrap();
1356 let active = vec![Some("epicA".to_string())];
1358 let blocked = config.blocked_epics(&active);
1359 assert!(blocked.contains(&"epicA".to_string()));
1360 }
1361
1362 #[test]
1363 fn blocked_epics_global_limit_two() {
1364 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1365 let config: Config = toml::from_str(toml).unwrap();
1366 let active = vec![Some("epicA".to_string())];
1368 let blocked = config.blocked_epics(&active);
1369 assert!(!blocked.contains(&"epicA".to_string()));
1370 }
1371
1372 #[test]
1373 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1374 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1375 let config: Config = toml::from_str(base).unwrap();
1376 assert_eq!(config.agents.max_workers_on_default, 1);
1377 let active: Vec<Option<String>> = vec![];
1379 assert!(!config.is_default_branch_blocked(&active));
1380 }
1381
1382 #[test]
1383 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1384 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1385 let config: Config = toml::from_str(base).unwrap();
1386 let active = vec![None];
1388 assert!(config.is_default_branch_blocked(&active));
1389 }
1390
1391 #[test]
1392 fn default_branch_not_blocked_when_limit_zero() {
1393 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1394 let config: Config = toml::from_str(toml).unwrap();
1395 let active = vec![None, None, None];
1397 assert!(!config.is_default_branch_blocked(&active));
1398 }
1399
1400 #[test]
1401 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1402 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1403 let config: Config = toml::from_str(base).unwrap();
1404 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1406 assert!(!config.is_default_branch_blocked(&active));
1407 }
1408
1409 #[test]
1410 fn prefers_apm_agent_name() {
1411 let _g = ENV_LOCK.lock().unwrap();
1412 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1413 assert_eq!(resolve_caller_name(), "explicit-agent");
1414 std::env::remove_var("APM_AGENT_NAME");
1415 }
1416
1417 #[test]
1418 fn falls_back_to_user() {
1419 let _g = ENV_LOCK.lock().unwrap();
1420 std::env::remove_var("APM_AGENT_NAME");
1421 std::env::set_var("USER", "unix-user");
1422 std::env::remove_var("USERNAME");
1423 assert_eq!(resolve_caller_name(), "unix-user");
1424 std::env::remove_var("USER");
1425 }
1426
1427 #[test]
1428 fn defaults_to_apm() {
1429 let _g = ENV_LOCK.lock().unwrap();
1430 std::env::remove_var("APM_AGENT_NAME");
1431 std::env::remove_var("USER");
1432 std::env::remove_var("USERNAME");
1433 assert_eq!(resolve_caller_name(), "apm");
1434 }
1435
1436 #[test]
1437 fn config_round_trip_new_shape() {
1438 let toml = r#"
1439[project]
1440name = "test"
1441
1442[tickets]
1443dir = "tickets"
1444
1445[workers]
1446agent = "claude"
1447
1448[workers.options]
1449model = "sonnet"
1450timeout = "30"
1451"#;
1452 let config: Config = toml::from_str(toml).unwrap();
1453 assert_eq!(config.workers.agent.as_deref(), Some("claude"));
1454 assert_eq!(config.workers.options.get("model").map(|s| s.as_str()), Some("sonnet"));
1455 assert_eq!(config.workers.options.get("timeout").map(|s| s.as_str()), Some("30"));
1456 assert!(config.workers.command.is_none());
1457 assert!(config.workers.args.is_none());
1458 }
1459
1460 #[test]
1461 fn config_round_trip_legacy_shape() {
1462 let toml = r#"
1463[project]
1464name = "test"
1465
1466[tickets]
1467dir = "tickets"
1468
1469[workers]
1470command = "claude"
1471args = ["--print"]
1472model = "opus"
1473"#;
1474 let config: Config = toml::from_str(toml).unwrap();
1475 assert!(config.workers.agent.is_none());
1476 assert_eq!(config.workers.command.as_deref(), Some("claude"));
1477 assert_eq!(config.workers.model.as_deref(), Some("opus"));
1478 }
1479
1480 #[test]
1481 fn worker_profile_config_new_fields() {
1482 let toml = r#"
1483[project]
1484name = "test"
1485
1486[tickets]
1487dir = "tickets"
1488
1489[worker_profiles.my_agent]
1490agent = "mock-happy"
1491
1492[worker_profiles.my_agent.options]
1493model = "sonnet"
1494"#;
1495 let config: Config = toml::from_str(toml).unwrap();
1496 let profile = config.worker_profiles.get("my_agent").unwrap();
1497 assert_eq!(profile.agent.as_deref(), Some("mock-happy"));
1498 assert_eq!(profile.options.get("model").map(|s| s.as_str()), Some("sonnet"));
1499 }
1500}