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, Default, JsonSchema)]
56pub struct LoggingConfig {
57 #[serde(default)]
59 pub enabled: bool,
60 pub file: Option<std::path::PathBuf>,
62}
63
64#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
65#[serde(default)]
66pub struct GitHostConfig {
67 pub provider: Option<String>,
69 pub repo: Option<String>,
71 pub token_env: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76pub struct WorkersConfig {
77 pub container: Option<String>,
79 #[serde(default)]
81 pub keychain: std::collections::HashMap<String, String>,
82 #[serde(default = "default_command")]
84 pub command: String,
85 #[serde(default = "default_args")]
87 pub args: Vec<String>,
88 #[serde(default)]
90 pub model: Option<String>,
91 #[serde(default)]
93 pub env: std::collections::HashMap<String, String>,
94}
95
96impl Default for WorkersConfig {
97 fn default() -> Self {
98 Self {
99 container: None,
100 keychain: std::collections::HashMap::new(),
101 command: default_command(),
102 args: default_args(),
103 model: None,
104 env: std::collections::HashMap::new(),
105 }
106 }
107}
108
109fn default_command() -> String { "claude".to_string() }
110fn default_args() -> Vec<String> { vec!["--print".to_string()] }
111
112#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
113pub struct WorkerProfileConfig {
114 pub command: Option<String>,
116 pub args: Option<Vec<String>>,
118 pub model: Option<String>,
120 #[serde(default)]
122 pub env: std::collections::HashMap<String, String>,
123 pub container: Option<String>,
125 pub instructions: Option<String>,
127 pub role_prefix: Option<String>,
129}
130
131#[derive(Debug, Deserialize, Default, JsonSchema)]
132pub struct WorkConfig {
133 #[serde(default)]
135 pub epic: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize, JsonSchema)]
139pub struct ServerConfig {
140 #[serde(default = "default_server_origin")]
142 pub origin: String,
143 #[serde(default = "default_server_url")]
145 pub url: String,
146}
147
148fn default_server_origin() -> String {
149 "http://localhost:3000".to_string()
150}
151
152fn default_server_url() -> String {
153 "http://127.0.0.1:3000".to_string()
154}
155
156impl Default for ServerConfig {
157 fn default() -> Self {
158 Self { origin: default_server_origin(), url: default_server_url() }
159 }
160}
161
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct ContextConfig {
164 #[serde(default = "default_epic_sibling_cap")]
166 pub epic_sibling_cap: usize,
167 #[serde(default = "default_epic_byte_cap")]
169 pub epic_byte_cap: usize,
170}
171
172fn default_epic_sibling_cap() -> usize { 20 }
173fn default_epic_byte_cap() -> usize { 8192 }
174
175impl Default for ContextConfig {
176 fn default() -> Self {
177 Self {
178 epic_sibling_cap: default_epic_sibling_cap(),
179 epic_byte_cap: default_epic_byte_cap(),
180 }
181 }
182}
183
184#[derive(Debug, Deserialize, JsonSchema)]
185pub struct Config {
186 pub project: ProjectConfig,
187 #[serde(default)]
188 pub ticket: TicketConfig,
189 #[serde(default)]
190 pub tickets: TicketsConfig,
191 #[serde(default)]
192 pub workflow: WorkflowConfig,
193 #[serde(default)]
194 pub agents: AgentsConfig,
195 #[serde(default)]
196 pub worktrees: WorktreesConfig,
197 #[serde(default)]
198 pub sync: SyncConfig,
199 #[serde(default)]
200 pub logging: LoggingConfig,
201 #[serde(default)]
202 pub workers: WorkersConfig,
203 #[serde(default)]
204 pub work: WorkConfig,
205 #[serde(default)]
206 pub server: ServerConfig,
207 #[serde(default)]
208 pub git_host: GitHostConfig,
209 #[serde(default)]
210 pub worker_profiles: std::collections::HashMap<String, WorkerProfileConfig>,
211 #[serde(default)]
212 pub context: ContextConfig,
213 #[serde(skip)]
215 pub load_warnings: Vec<String>,
216}
217
218#[derive(Deserialize)]
219pub(crate) struct WorkflowFile {
220 pub(crate) workflow: WorkflowConfig,
221}
222
223#[derive(Deserialize)]
224pub(crate) struct TicketFile {
225 pub(crate) ticket: TicketConfig,
226}
227
228#[derive(Debug, Clone, Deserialize, JsonSchema)]
229pub struct SyncConfig {
230 #[serde(default = "default_true")]
232 pub aggressive: bool,
233}
234
235impl Default for SyncConfig {
236 fn default() -> Self {
237 Self { aggressive: true }
238 }
239}
240
241#[derive(Debug, Deserialize, JsonSchema)]
242pub struct ProjectConfig {
243 pub name: String,
245 #[serde(default)]
247 pub description: String,
248 #[serde(default = "default_branch_main")]
250 pub default_branch: String,
251 #[serde(default)]
253 pub collaborators: Vec<String>,
254}
255
256fn default_branch_main() -> String {
257 "main".to_string()
258}
259
260#[derive(Debug, Deserialize, JsonSchema)]
261pub struct TicketsConfig {
262 pub dir: PathBuf,
264 #[serde(default)]
265 pub sections: Vec<String>,
266 #[serde(default)]
268 pub archive_dir: Option<PathBuf>,
269}
270
271impl Default for TicketsConfig {
272 fn default() -> Self {
273 Self {
274 dir: PathBuf::from("tickets"),
275 sections: Vec::new(),
276 archive_dir: None,
277 }
278 }
279}
280
281#[derive(Debug, Deserialize, Default, JsonSchema)]
283pub struct WorkflowConfig {
284 #[serde(default)]
286 pub states: Vec<StateConfig>,
287 #[serde(default)]
289 pub prioritization: PrioritizationConfig,
290}
291
292#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
294#[serde(untagged)]
295pub enum SatisfiesDeps {
296 Bool(bool),
298 Tag(String),
300}
301
302impl Default for SatisfiesDeps {
303 fn default() -> Self { SatisfiesDeps::Bool(false) }
304}
305
306#[derive(Debug, Deserialize, JsonSchema)]
308pub struct StateConfig {
309 pub id: String,
311 pub label: String,
313 #[serde(default)]
315 pub description: String,
316 #[serde(default)]
318 pub terminal: bool,
319 #[serde(default)]
321 pub worker_end: bool,
322 #[serde(default)]
324 pub satisfies_deps: SatisfiesDeps,
325 #[serde(default)]
327 pub dep_requires: Option<String>,
328 #[serde(default)]
330 pub transitions: Vec<TransitionConfig>,
331 #[serde(default)]
333 pub actionable: Vec<String>,
334 #[serde(default)]
336 pub instructions: Option<String>,
337}
338
339#[derive(Debug, Clone, Deserialize, JsonSchema)]
341pub struct TransitionConfig {
342 pub to: String,
344 #[serde(default)]
346 pub trigger: String,
347 #[serde(default)]
349 pub label: String,
350 #[serde(default)]
352 pub hint: String,
353 #[serde(default)]
355 pub completion: CompletionStrategy,
356 #[serde(default)]
358 pub focus_section: Option<String>,
359 #[serde(default)]
361 pub context_section: Option<String>,
362 #[serde(default)]
364 pub warning: Option<String>,
365 #[serde(default)]
367 pub profile: Option<String>,
368 #[serde(default)]
369 pub on_failure: Option<String>,
370}
371
372#[derive(Debug, Deserialize, Default, JsonSchema)]
374pub struct PrioritizationConfig {
375 #[serde(default = "default_priority_weight")]
377 pub priority_weight: f64,
378 #[serde(default = "default_effort_weight")]
380 pub effort_weight: f64,
381 #[serde(default = "default_risk_weight")]
383 pub risk_weight: f64,
384}
385
386fn default_priority_weight() -> f64 { 10.0 }
387fn default_effort_weight() -> f64 { -2.0 }
388fn default_risk_weight() -> f64 { -1.0 }
389
390#[derive(Debug, Deserialize, JsonSchema)]
391pub struct AgentsConfig {
392 #[serde(default = "default_max_concurrent")]
394 pub max_concurrent: usize,
395 #[serde(default = "default_max_workers_per_epic")]
397 pub max_workers_per_epic: usize,
398 #[serde(default = "default_max_workers_on_default")]
400 pub max_workers_on_default: usize,
401 #[serde(default)]
403 pub instructions: Option<PathBuf>,
404 #[serde(default = "default_true")]
406 pub side_tickets: bool,
407 #[serde(default)]
409 pub skip_permissions: bool,
410}
411
412fn default_max_concurrent() -> usize { 3 }
413fn default_max_workers_per_epic() -> usize { 1 }
414fn default_max_workers_on_default() -> usize { 1 }
415fn default_true() -> bool { true }
416
417#[derive(Debug, Deserialize, JsonSchema)]
418pub struct WorktreesConfig {
419 pub dir: PathBuf,
421 #[serde(default)]
423 pub agent_dirs: Vec<String>,
424}
425
426impl Default for WorktreesConfig {
427 fn default() -> Self {
428 Self {
429 dir: PathBuf::from("../worktrees"),
430 agent_dirs: Vec::new(),
431 }
432 }
433}
434
435impl Default for AgentsConfig {
436 fn default() -> Self {
437 Self {
438 max_concurrent: default_max_concurrent(),
439 max_workers_per_epic: default_max_workers_per_epic(),
440 max_workers_on_default: default_max_workers_on_default(),
441 instructions: None,
442 side_tickets: true,
443 skip_permissions: false,
444 }
445 }
446}
447
448#[derive(Debug, Deserialize, Default)]
449pub struct LocalConfig {
450 #[serde(default)]
451 pub workers: LocalWorkersOverride,
452 #[serde(default)]
453 pub username: Option<String>,
454 #[serde(default)]
455 pub github_token: Option<String>,
456}
457
458#[derive(Debug, Deserialize, Default)]
459pub struct LocalWorkersOverride {
460 pub command: Option<String>,
461 pub args: Option<Vec<String>>,
462 pub model: Option<String>,
463 #[serde(default)]
464 pub env: std::collections::HashMap<String, String>,
465}
466
467impl LocalConfig {
468 pub fn load(root: &Path) -> Self {
469 let local_path = root.join(".apm").join("local.toml");
470 std::fs::read_to_string(&local_path)
471 .ok()
472 .and_then(|s| toml::from_str(&s).ok())
473 .unwrap_or_default()
474 }
475}
476
477fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
478 if let Some(ref t) = local.github_token {
479 if !t.is_empty() {
480 return Some(t.clone());
481 }
482 }
483 if let Some(ref env_var) = git_host.token_env {
484 if let Ok(t) = std::env::var(env_var) {
485 if !t.is_empty() {
486 return Some(t);
487 }
488 }
489 }
490 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
491}
492
493pub fn resolve_identity(repo_root: &Path) -> String {
494 let local_path = repo_root.join(".apm").join("local.toml");
495 let local: LocalConfig = std::fs::read_to_string(&local_path)
496 .ok()
497 .and_then(|s| toml::from_str(&s).ok())
498 .unwrap_or_default();
499
500 let config_path = repo_root.join(".apm").join("config.toml");
501 let config: Option<Config> = std::fs::read_to_string(&config_path)
502 .ok()
503 .and_then(|s| toml::from_str(&s).ok());
504
505 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
506 if git_host.provider.is_some() {
507 if git_host.provider.as_deref() == Some("github") {
509 if let Some(login) = crate::github::gh_username() {
510 return login;
511 }
512 if let Some(token) = effective_github_token(&local, &git_host) {
513 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
514 return login;
515 }
516 }
517 }
518 return "unassigned".to_string();
519 }
520
521 if let Some(ref u) = local.username {
523 if !u.is_empty() {
524 return u.clone();
525 }
526 }
527 "unassigned".to_string()
528}
529
530pub fn resolve_caller_name() -> String {
540 std::env::var("APM_AGENT_NAME")
541 .or_else(|_| std::env::var("USER"))
542 .or_else(|_| std::env::var("USERNAME"))
543 .unwrap_or_else(|_| "apm".to_string())
544}
545
546pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
547 if git_host.provider.as_deref() != Some("github") {
548 return None;
549 }
550 if let Some(login) = crate::github::gh_username() {
551 return Some(login);
552 }
553 let local = LocalConfig::default();
554 let token = effective_github_token(&local, git_host)?;
555 crate::github::fetch_authenticated_user(&token).ok()
556}
557
558pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
559 let mut warnings = Vec::new();
560 if config.git_host.provider.as_deref() == Some("github") {
561 if let Some(ref repo) = config.git_host.repo {
562 if let Some(token) = effective_github_token(local, &config.git_host) {
563 match crate::github::fetch_repo_collaborators(&token, repo) {
564 Ok(logins) => return (logins, warnings),
565 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
566 }
567 }
568 }
569 }
570 (config.project.collaborators.clone(), warnings)
571}
572
573impl WorkersConfig {
574 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
575 if let Some(ref cmd) = local.command {
576 self.command = cmd.clone();
577 }
578 if let Some(ref args) = local.args {
579 self.args = args.clone();
580 }
581 if let Some(ref model) = local.model {
582 self.model = Some(model.clone());
583 }
584 for (k, v) in &local.env {
585 self.env.insert(k.clone(), v.clone());
586 }
587 }
588}
589
590impl Config {
591 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
594 let limit = self.agents.max_workers_per_epic;
595 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
596 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
597 *counts.entry(eid).or_insert(0) += 1;
598 }
599 counts.into_iter()
600 .filter(|(_, count)| *count >= limit)
601 .map(|(eid, _)| eid.to_string())
602 .collect()
603 }
604
605 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
608 if self.agents.max_workers_on_default == 0 {
609 return false;
610 }
611 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
612 count >= self.agents.max_workers_on_default
613 }
614
615 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
618 self.workflow.states.iter()
619 .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
620 .map(|s| s.id.clone())
621 .collect()
622 }
623
624 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
625 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
626 .filter(|s| s.terminal)
627 .map(|s| s.id.clone())
628 .collect();
629 ids.insert("closed".to_string());
630 ids
631 }
632
633 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
634 self.ticket.sections.iter()
635 .find(|s| s.name.eq_ignore_ascii_case(name))
636 }
637
638 pub fn has_section(&self, name: &str) -> bool {
639 self.find_section(name).is_some()
640 }
641
642 pub fn load(repo_root: &Path) -> Result<Self> {
643 let apm_dir = repo_root.join(".apm");
644 let apm_dir_config = apm_dir.join("config.toml");
645 let path = if apm_dir_config.exists() {
646 apm_dir_config
647 } else {
648 repo_root.join("apm.toml")
649 };
650 let contents = std::fs::read_to_string(&path)
651 .with_context(|| format!("cannot read {}", path.display()))?;
652 let mut config: Config = toml::from_str(&contents)
653 .with_context(|| format!("cannot parse {}", path.display()))?;
654
655 let workflow_path = apm_dir.join("workflow.toml");
656 if workflow_path.exists() {
657 let wf_contents = std::fs::read_to_string(&workflow_path)
658 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
659 let wf: WorkflowFile = toml::from_str(&wf_contents)
660 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
661 if !config.workflow.states.is_empty() {
662 config.load_warnings.push(
663 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
664 );
665 }
666 config.workflow = wf.workflow;
667 }
668
669 let ticket_path = apm_dir.join("ticket.toml");
670 if ticket_path.exists() {
671 let tk_contents = std::fs::read_to_string(&ticket_path)
672 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
673 let tk: TicketFile = toml::from_str(&tk_contents)
674 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
675 if !config.ticket.sections.is_empty() {
676 config.load_warnings.push(
677 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
678 );
679 }
680 config.ticket = tk.ticket;
681 }
682
683 let local_path = apm_dir.join("local.toml");
684 if local_path.exists() {
685 let local_contents = std::fs::read_to_string(&local_path)
686 .with_context(|| format!("cannot read {}", local_path.display()))?;
687 let local: LocalConfig = toml::from_str(&local_contents)
688 .with_context(|| format!("cannot parse {}", local_path.display()))?;
689 config.workers.merge_local(&local.workers);
690 }
691
692 Ok(config)
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use std::sync::Mutex;
700
701 static ENV_LOCK: Mutex<()> = Mutex::new(());
702
703 #[test]
704 fn ticket_section_full_parse() {
705 let toml = r#"
706name = "Problem"
707type = "free"
708required = true
709placeholder = "What is broken or missing?"
710"#;
711 let s: TicketSection = toml::from_str(toml).unwrap();
712 assert_eq!(s.name, "Problem");
713 assert_eq!(s.type_, SectionType::Free);
714 assert!(s.required);
715 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
716 }
717
718 #[test]
719 fn ticket_section_minimal_parse() {
720 let toml = r#"
721name = "Open questions"
722type = "qa"
723"#;
724 let s: TicketSection = toml::from_str(toml).unwrap();
725 assert_eq!(s.name, "Open questions");
726 assert_eq!(s.type_, SectionType::Qa);
727 assert!(!s.required);
728 assert!(s.placeholder.is_none());
729 }
730
731 #[test]
732 fn section_type_all_variants() {
733 #[derive(Deserialize)]
734 struct W { t: SectionType }
735 let free: W = toml::from_str("t = \"free\"").unwrap();
736 assert_eq!(free.t, SectionType::Free);
737 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
738 assert_eq!(tasks.t, SectionType::Tasks);
739 let qa: W = toml::from_str("t = \"qa\"").unwrap();
740 assert_eq!(qa.t, SectionType::Qa);
741 }
742
743 #[test]
744 fn completion_strategy_all_variants() {
745 #[derive(Deserialize)]
746 struct W { c: CompletionStrategy }
747 let pr: W = toml::from_str("c = \"pr\"").unwrap();
748 assert_eq!(pr.c, CompletionStrategy::Pr);
749 let merge: W = toml::from_str("c = \"merge\"").unwrap();
750 assert_eq!(merge.c, CompletionStrategy::Merge);
751 let pull: W = toml::from_str("c = \"pull\"").unwrap();
752 assert_eq!(pull.c, CompletionStrategy::Pull);
753 let none: W = toml::from_str("c = \"none\"").unwrap();
754 assert_eq!(none.c, CompletionStrategy::None);
755 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
756 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
757 }
758
759 #[test]
760 fn completion_strategy_default() {
761 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
762 }
763
764 #[test]
765 fn state_config_with_instructions() {
766 let toml = r#"
767id = "in_progress"
768label = "In Progress"
769instructions = "apm.worker.md"
770"#;
771 let s: StateConfig = toml::from_str(toml).unwrap();
772 assert_eq!(s.id, "in_progress");
773 assert_eq!(s.instructions.as_deref(), Some("apm.worker.md"));
774 }
775
776 #[test]
777 fn state_config_instructions_default_none() {
778 let toml = r#"
779id = "new"
780label = "New"
781"#;
782 let s: StateConfig = toml::from_str(toml).unwrap();
783 assert!(s.instructions.is_none());
784 }
785
786 #[test]
787 fn transition_config_new_fields() {
788 let toml = r#"
789to = "implemented"
790trigger = "manual"
791completion = "pr"
792focus_section = "Code review"
793context_section = "Problem"
794"#;
795 let t: TransitionConfig = toml::from_str(toml).unwrap();
796 assert_eq!(t.completion, CompletionStrategy::Pr);
797 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
798 assert_eq!(t.context_section.as_deref(), Some("Problem"));
799 }
800
801 #[test]
802 fn transition_config_new_fields_default() {
803 let toml = r#"
804to = "ready"
805trigger = "manual"
806"#;
807 let t: TransitionConfig = toml::from_str(toml).unwrap();
808 assert_eq!(t.completion, CompletionStrategy::None);
809 assert!(t.focus_section.is_none());
810 assert!(t.context_section.is_none());
811 }
812
813 #[test]
814 fn workers_config_parses() {
815 let toml = r#"
816[project]
817name = "test"
818
819[tickets]
820dir = "tickets"
821
822[workers]
823container = "apm-worker:latest"
824
825[workers.keychain]
826ANTHROPIC_API_KEY = "anthropic-api-key"
827"#;
828 let config: Config = toml::from_str(toml).unwrap();
829 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
830 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
831 }
832
833 #[test]
834 fn workers_config_default() {
835 let toml = r#"
836[project]
837name = "test"
838
839[tickets]
840dir = "tickets"
841"#;
842 let config: Config = toml::from_str(toml).unwrap();
843 assert!(config.workers.container.is_none());
844 assert!(config.workers.keychain.is_empty());
845 assert_eq!(config.workers.command, "claude");
846 assert_eq!(config.workers.args, vec!["--print"]);
847 assert!(config.workers.model.is_none());
848 assert!(config.workers.env.is_empty());
849 }
850
851 #[test]
852 fn workers_config_all_fields() {
853 let toml = r#"
854[project]
855name = "test"
856
857[tickets]
858dir = "tickets"
859
860[workers]
861command = "codex"
862args = ["--full-auto"]
863model = "o3"
864
865[workers.env]
866CUSTOM_VAR = "value"
867"#;
868 let config: Config = toml::from_str(toml).unwrap();
869 assert_eq!(config.workers.command, "codex");
870 assert_eq!(config.workers.args, vec!["--full-auto"]);
871 assert_eq!(config.workers.model.as_deref(), Some("o3"));
872 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
873 }
874
875 #[test]
876 fn local_config_parses() {
877 let toml = r#"
878[workers]
879command = "aider"
880model = "gpt-4"
881
882[workers.env]
883OPENAI_API_KEY = "sk-test"
884"#;
885 let local: LocalConfig = toml::from_str(toml).unwrap();
886 assert_eq!(local.workers.command.as_deref(), Some("aider"));
887 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
888 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
889 assert!(local.workers.args.is_none());
890 }
891
892 #[test]
893 fn merge_local_overrides_and_extends() {
894 let mut wc = WorkersConfig::default();
895 assert_eq!(wc.command, "claude");
896 assert_eq!(wc.args, vec!["--print"]);
897
898 let local = LocalWorkersOverride {
899 command: Some("aider".to_string()),
900 args: None,
901 model: Some("gpt-4".to_string()),
902 env: [("KEY".to_string(), "val".to_string())].into(),
903 };
904 wc.merge_local(&local);
905
906 assert_eq!(wc.command, "aider");
907 assert_eq!(wc.args, vec!["--print"]); assert_eq!(wc.model.as_deref(), Some("gpt-4"));
909 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
910 }
911
912 #[test]
913 fn agents_skip_permissions_parses_and_defaults() {
914 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
915
916 let config: Config = toml::from_str(base).unwrap();
918 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
919
920 let with_agents = format!("{base}[agents]\n");
922 let config: Config = toml::from_str(&with_agents).unwrap();
923 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
924
925 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
927 let config: Config = toml::from_str(&explicit_true).unwrap();
928 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
929
930 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
932 let config: Config = toml::from_str(&explicit_false).unwrap();
933 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
934 }
935
936 #[test]
937 fn actionable_states_for_agent_includes_ready() {
938 let toml = r#"
939[project]
940name = "test"
941
942[tickets]
943dir = "tickets"
944
945[[workflow.states]]
946id = "ready"
947label = "Ready"
948actionable = ["agent"]
949
950[[workflow.states]]
951id = "in_progress"
952label = "In Progress"
953
954[[workflow.states]]
955id = "specd"
956label = "Specd"
957actionable = ["supervisor"]
958"#;
959 let config: Config = toml::from_str(toml).unwrap();
960 let states = config.actionable_states_for("agent");
961 assert!(states.contains(&"ready".to_string()));
962 assert!(!states.contains(&"specd".to_string()));
963 assert!(!states.contains(&"in_progress".to_string()));
964 }
965
966 #[test]
967 fn work_epic_parses() {
968 let toml = r#"
969[project]
970name = "test"
971
972[tickets]
973dir = "tickets"
974
975[work]
976epic = "ab12cd34"
977"#;
978 let config: Config = toml::from_str(toml).unwrap();
979 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
980 }
981
982 #[test]
983 fn work_config_defaults_to_none() {
984 let toml = r#"
985[project]
986name = "test"
987
988[tickets]
989dir = "tickets"
990"#;
991 let config: Config = toml::from_str(toml).unwrap();
992 assert!(config.work.epic.is_none());
993 }
994
995 #[test]
996 fn sync_aggressive_defaults_to_true() {
997 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
998
999 let config: Config = toml::from_str(base).unwrap();
1001 assert!(config.sync.aggressive, "no [sync] section should default to true");
1002
1003 let with_sync = format!("{base}[sync]\n");
1005 let config: Config = toml::from_str(&with_sync).unwrap();
1006 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1007
1008 let explicit_false = format!("{base}[sync]\naggressive = false\n");
1010 let config: Config = toml::from_str(&explicit_false).unwrap();
1011 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1012
1013 let explicit_true = format!("{base}[sync]\naggressive = true\n");
1015 let config: Config = toml::from_str(&explicit_true).unwrap();
1016 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1017 }
1018
1019 #[test]
1020 fn collaborators_parses() {
1021 let toml = r#"
1022[project]
1023name = "test"
1024collaborators = ["alice", "bob"]
1025
1026[tickets]
1027dir = "tickets"
1028"#;
1029 let config: Config = toml::from_str(toml).unwrap();
1030 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1031 }
1032
1033 #[test]
1034 fn collaborators_defaults_empty() {
1035 let toml = r#"
1036[project]
1037name = "test"
1038
1039[tickets]
1040dir = "tickets"
1041"#;
1042 let config: Config = toml::from_str(toml).unwrap();
1043 assert!(config.project.collaborators.is_empty());
1044 }
1045
1046 #[test]
1047 fn resolve_identity_returns_username_when_present() {
1048 let tmp = tempfile::tempdir().unwrap();
1049 let apm_dir = tmp.path().join(".apm");
1050 std::fs::create_dir_all(&apm_dir).unwrap();
1051 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1052 assert_eq!(resolve_identity(tmp.path()), "alice");
1053 }
1054
1055 #[test]
1056 fn resolve_identity_returns_unassigned_when_absent() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1059 }
1060
1061 #[test]
1062 fn resolve_identity_returns_unassigned_when_empty() {
1063 let tmp = tempfile::tempdir().unwrap();
1064 let apm_dir = tmp.path().join(".apm");
1065 std::fs::create_dir_all(&apm_dir).unwrap();
1066 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1067 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1068 }
1069
1070 #[test]
1071 fn resolve_identity_returns_unassigned_when_username_key_absent() {
1072 let tmp = tempfile::tempdir().unwrap();
1073 let apm_dir = tmp.path().join(".apm");
1074 std::fs::create_dir_all(&apm_dir).unwrap();
1075 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1076 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1077 }
1078
1079 #[test]
1080 fn local_config_username_parses() {
1081 let toml = r#"
1082username = "bob"
1083"#;
1084 let local: LocalConfig = toml::from_str(toml).unwrap();
1085 assert_eq!(local.username.as_deref(), Some("bob"));
1086 }
1087
1088 #[test]
1089 fn local_config_username_defaults_none() {
1090 let local: LocalConfig = toml::from_str("").unwrap();
1091 assert!(local.username.is_none());
1092 }
1093
1094 #[test]
1095 fn server_config_defaults() {
1096 let toml = r#"
1097[project]
1098name = "test"
1099
1100[tickets]
1101dir = "tickets"
1102"#;
1103 let config: Config = toml::from_str(toml).unwrap();
1104 assert_eq!(config.server.origin, "http://localhost:3000");
1105 }
1106
1107 #[test]
1108 fn server_config_custom_origin() {
1109 let toml = r#"
1110[project]
1111name = "test"
1112
1113[tickets]
1114dir = "tickets"
1115
1116[server]
1117origin = "https://apm.example.com"
1118"#;
1119 let config: Config = toml::from_str(toml).unwrap();
1120 assert_eq!(config.server.origin, "https://apm.example.com");
1121 }
1122
1123 #[test]
1124 fn git_host_config_parses() {
1125 let toml = r#"
1126[project]
1127name = "test"
1128
1129[tickets]
1130dir = "tickets"
1131
1132[git_host]
1133provider = "github"
1134repo = "owner/name"
1135"#;
1136 let config: Config = toml::from_str(toml).unwrap();
1137 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1138 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1139 }
1140
1141 #[test]
1142 fn git_host_config_absent_defaults_none() {
1143 let toml = r#"
1144[project]
1145name = "test"
1146
1147[tickets]
1148dir = "tickets"
1149"#;
1150 let config: Config = toml::from_str(toml).unwrap();
1151 assert!(config.git_host.provider.is_none());
1152 assert!(config.git_host.repo.is_none());
1153 }
1154
1155 #[test]
1156 fn local_config_github_token_parses() {
1157 let toml = r#"github_token = "ghp_abc123""#;
1158 let local: LocalConfig = toml::from_str(toml).unwrap();
1159 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1160 }
1161
1162 #[test]
1163 fn local_config_github_token_absent_defaults_none() {
1164 let local: LocalConfig = toml::from_str("").unwrap();
1165 assert!(local.github_token.is_none());
1166 }
1167
1168 #[test]
1169 fn tickets_archive_dir_parses() {
1170 let toml = r#"
1171[project]
1172name = "test"
1173
1174[tickets]
1175dir = "tickets"
1176archive_dir = "archive/tickets"
1177"#;
1178 let config: Config = toml::from_str(toml).unwrap();
1179 assert_eq!(
1180 config.tickets.archive_dir.as_deref(),
1181 Some(std::path::Path::new("archive/tickets"))
1182 );
1183 }
1184
1185 #[test]
1186 fn tickets_archive_dir_absent_defaults_none() {
1187 let toml = r#"
1188[project]
1189name = "test"
1190
1191[tickets]
1192dir = "tickets"
1193"#;
1194 let config: Config = toml::from_str(toml).unwrap();
1195 assert!(config.tickets.archive_dir.is_none());
1196 }
1197
1198 #[test]
1199 fn agents_max_workers_per_epic_defaults_to_one() {
1200 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1201 let config: Config = toml::from_str(toml).unwrap();
1202 assert_eq!(config.agents.max_workers_per_epic, 1);
1203 }
1204
1205 #[test]
1206 fn blocked_epics_global_limit_one() {
1207 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1208 let config: Config = toml::from_str(toml).unwrap();
1209 let active = vec![Some("epicA".to_string())];
1211 let blocked = config.blocked_epics(&active);
1212 assert!(blocked.contains(&"epicA".to_string()));
1213 }
1214
1215 #[test]
1216 fn blocked_epics_global_limit_two() {
1217 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1218 let config: Config = toml::from_str(toml).unwrap();
1219 let active = vec![Some("epicA".to_string())];
1221 let blocked = config.blocked_epics(&active);
1222 assert!(!blocked.contains(&"epicA".to_string()));
1223 }
1224
1225 #[test]
1226 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1227 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1228 let config: Config = toml::from_str(base).unwrap();
1229 assert_eq!(config.agents.max_workers_on_default, 1);
1230 let active: Vec<Option<String>> = vec![];
1232 assert!(!config.is_default_branch_blocked(&active));
1233 }
1234
1235 #[test]
1236 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1237 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1238 let config: Config = toml::from_str(base).unwrap();
1239 let active = vec![None];
1241 assert!(config.is_default_branch_blocked(&active));
1242 }
1243
1244 #[test]
1245 fn default_branch_not_blocked_when_limit_zero() {
1246 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1247 let config: Config = toml::from_str(toml).unwrap();
1248 let active = vec![None, None, None];
1250 assert!(!config.is_default_branch_blocked(&active));
1251 }
1252
1253 #[test]
1254 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1255 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1256 let config: Config = toml::from_str(base).unwrap();
1257 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1259 assert!(!config.is_default_branch_blocked(&active));
1260 }
1261
1262 #[test]
1263 fn prefers_apm_agent_name() {
1264 let _g = ENV_LOCK.lock().unwrap();
1265 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1266 assert_eq!(resolve_caller_name(), "explicit-agent");
1267 std::env::remove_var("APM_AGENT_NAME");
1268 }
1269
1270 #[test]
1271 fn falls_back_to_user() {
1272 let _g = ENV_LOCK.lock().unwrap();
1273 std::env::remove_var("APM_AGENT_NAME");
1274 std::env::set_var("USER", "unix-user");
1275 std::env::remove_var("USERNAME");
1276 assert_eq!(resolve_caller_name(), "unix-user");
1277 std::env::remove_var("USER");
1278 }
1279
1280 #[test]
1281 fn defaults_to_apm() {
1282 let _g = ENV_LOCK.lock().unwrap();
1283 std::env::remove_var("APM_AGENT_NAME");
1284 std::env::remove_var("USER");
1285 std::env::remove_var("USERNAME");
1286 assert_eq!(resolve_caller_name(), "apm");
1287 }
1288}