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