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