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