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 #[serde(default)]
304 pub on_failure: Option<String>,
305}
306
307#[derive(Debug, Deserialize, Default)]
308pub struct PrioritizationConfig {
309 #[serde(default = "default_priority_weight")]
310 pub priority_weight: f64,
311 #[serde(default = "default_effort_weight")]
312 pub effort_weight: f64,
313 #[serde(default = "default_risk_weight")]
314 pub risk_weight: f64,
315}
316
317fn default_priority_weight() -> f64 { 10.0 }
318fn default_effort_weight() -> f64 { -2.0 }
319fn default_risk_weight() -> f64 { -1.0 }
320
321#[derive(Debug, Deserialize)]
322pub struct AgentsConfig {
323 #[serde(default = "default_max_concurrent")]
324 pub max_concurrent: usize,
325 #[serde(default = "default_max_workers_per_epic")]
326 pub max_workers_per_epic: usize,
327 #[serde(default = "default_max_workers_on_default")]
328 pub max_workers_on_default: usize,
329 #[serde(default)]
330 pub instructions: Option<PathBuf>,
331 #[serde(default = "default_true")]
332 pub side_tickets: bool,
333 #[serde(default)]
334 pub skip_permissions: bool,
335}
336
337fn default_max_concurrent() -> usize { 3 }
338fn default_max_workers_per_epic() -> usize { 1 }
339fn default_max_workers_on_default() -> usize { 1 }
340fn default_true() -> bool { true }
341
342#[derive(Debug, Deserialize)]
343pub struct WorktreesConfig {
344 pub dir: PathBuf,
345 #[serde(default)]
346 pub agent_dirs: Vec<String>,
347}
348
349impl Default for WorktreesConfig {
350 fn default() -> Self {
351 Self {
352 dir: PathBuf::from("../worktrees"),
353 agent_dirs: Vec::new(),
354 }
355 }
356}
357
358impl Default for AgentsConfig {
359 fn default() -> Self {
360 Self {
361 max_concurrent: default_max_concurrent(),
362 max_workers_per_epic: default_max_workers_per_epic(),
363 max_workers_on_default: default_max_workers_on_default(),
364 instructions: None,
365 side_tickets: true,
366 skip_permissions: false,
367 }
368 }
369}
370
371#[derive(Debug, Deserialize, Default)]
372pub struct LocalConfig {
373 #[serde(default)]
374 pub workers: LocalWorkersOverride,
375 #[serde(default)]
376 pub username: Option<String>,
377 #[serde(default)]
378 pub github_token: Option<String>,
379}
380
381#[derive(Debug, Deserialize, Default)]
382pub struct LocalWorkersOverride {
383 pub command: Option<String>,
384 pub args: Option<Vec<String>>,
385 pub model: Option<String>,
386 #[serde(default)]
387 pub env: std::collections::HashMap<String, String>,
388}
389
390impl LocalConfig {
391 pub fn load(root: &Path) -> Self {
392 let local_path = root.join(".apm").join("local.toml");
393 std::fs::read_to_string(&local_path)
394 .ok()
395 .and_then(|s| toml::from_str(&s).ok())
396 .unwrap_or_default()
397 }
398}
399
400fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
401 if let Some(ref t) = local.github_token {
402 if !t.is_empty() {
403 return Some(t.clone());
404 }
405 }
406 if let Some(ref env_var) = git_host.token_env {
407 if let Ok(t) = std::env::var(env_var) {
408 if !t.is_empty() {
409 return Some(t);
410 }
411 }
412 }
413 std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
414}
415
416pub fn resolve_identity(repo_root: &Path) -> String {
417 let local_path = repo_root.join(".apm").join("local.toml");
418 let local: LocalConfig = std::fs::read_to_string(&local_path)
419 .ok()
420 .and_then(|s| toml::from_str(&s).ok())
421 .unwrap_or_default();
422
423 let config_path = repo_root.join(".apm").join("config.toml");
424 let config: Option<Config> = std::fs::read_to_string(&config_path)
425 .ok()
426 .and_then(|s| toml::from_str(&s).ok());
427
428 let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
429 if git_host.provider.is_some() {
430 if git_host.provider.as_deref() == Some("github") {
432 if let Some(login) = crate::github::gh_username() {
433 return login;
434 }
435 if let Some(token) = effective_github_token(&local, &git_host) {
436 if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
437 return login;
438 }
439 }
440 }
441 return "unassigned".to_string();
442 }
443
444 if let Some(ref u) = local.username {
446 if !u.is_empty() {
447 return u.clone();
448 }
449 }
450 "unassigned".to_string()
451}
452
453pub fn resolve_caller_name() -> String {
463 std::env::var("APM_AGENT_NAME")
464 .or_else(|_| std::env::var("USER"))
465 .or_else(|_| std::env::var("USERNAME"))
466 .unwrap_or_else(|_| "apm".to_string())
467}
468
469pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
470 if git_host.provider.as_deref() != Some("github") {
471 return None;
472 }
473 if let Some(login) = crate::github::gh_username() {
474 return Some(login);
475 }
476 let local = LocalConfig::default();
477 let token = effective_github_token(&local, git_host)?;
478 crate::github::fetch_authenticated_user(&token).ok()
479}
480
481pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
482 let mut warnings = Vec::new();
483 if config.git_host.provider.as_deref() == Some("github") {
484 if let Some(ref repo) = config.git_host.repo {
485 if let Some(token) = effective_github_token(local, &config.git_host) {
486 match crate::github::fetch_repo_collaborators(&token, repo) {
487 Ok(logins) => return (logins, warnings),
488 Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
489 }
490 }
491 }
492 }
493 (config.project.collaborators.clone(), warnings)
494}
495
496impl WorkersConfig {
497 pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
498 if let Some(ref cmd) = local.command {
499 self.command = cmd.clone();
500 }
501 if let Some(ref args) = local.args {
502 self.args = args.clone();
503 }
504 if let Some(ref model) = local.model {
505 self.model = Some(model.clone());
506 }
507 for (k, v) in &local.env {
508 self.env.insert(k.clone(), v.clone());
509 }
510 }
511}
512
513impl Config {
514 pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
517 let limit = self.agents.max_workers_per_epic;
518 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
519 for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
520 *counts.entry(eid).or_insert(0) += 1;
521 }
522 counts.into_iter()
523 .filter(|(_, count)| *count >= limit)
524 .map(|(eid, _)| eid.to_string())
525 .collect()
526 }
527
528 pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
531 if self.agents.max_workers_on_default == 0 {
532 return false;
533 }
534 let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
535 count >= self.agents.max_workers_on_default
536 }
537
538 pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
541 self.workflow.states.iter()
542 .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
543 .map(|s| s.id.clone())
544 .collect()
545 }
546
547 pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
548 let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
549 .filter(|s| s.terminal)
550 .map(|s| s.id.clone())
551 .collect();
552 ids.insert("closed".to_string());
553 ids
554 }
555
556 pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
557 self.ticket.sections.iter()
558 .find(|s| s.name.eq_ignore_ascii_case(name))
559 }
560
561 pub fn has_section(&self, name: &str) -> bool {
562 self.find_section(name).is_some()
563 }
564
565 pub fn load(repo_root: &Path) -> Result<Self> {
566 let apm_dir = repo_root.join(".apm");
567 let apm_dir_config = apm_dir.join("config.toml");
568 let path = if apm_dir_config.exists() {
569 apm_dir_config
570 } else {
571 repo_root.join("apm.toml")
572 };
573 let contents = std::fs::read_to_string(&path)
574 .with_context(|| format!("cannot read {}", path.display()))?;
575 let mut config: Config = toml::from_str(&contents)
576 .with_context(|| format!("cannot parse {}", path.display()))?;
577
578 let workflow_path = apm_dir.join("workflow.toml");
579 if workflow_path.exists() {
580 let wf_contents = std::fs::read_to_string(&workflow_path)
581 .with_context(|| format!("cannot read {}", workflow_path.display()))?;
582 let wf: WorkflowFile = toml::from_str(&wf_contents)
583 .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
584 if !config.workflow.states.is_empty() {
585 config.load_warnings.push(
586 "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
587 );
588 }
589 config.workflow = wf.workflow;
590 }
591
592 let ticket_path = apm_dir.join("ticket.toml");
593 if ticket_path.exists() {
594 let tk_contents = std::fs::read_to_string(&ticket_path)
595 .with_context(|| format!("cannot read {}", ticket_path.display()))?;
596 let tk: TicketFile = toml::from_str(&tk_contents)
597 .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
598 if !config.ticket.sections.is_empty() {
599 config.load_warnings.push(
600 "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
601 );
602 }
603 config.ticket = tk.ticket;
604 }
605
606 let local_path = apm_dir.join("local.toml");
607 if local_path.exists() {
608 let local_contents = std::fs::read_to_string(&local_path)
609 .with_context(|| format!("cannot read {}", local_path.display()))?;
610 let local: LocalConfig = toml::from_str(&local_contents)
611 .with_context(|| format!("cannot parse {}", local_path.display()))?;
612 config.workers.merge_local(&local.workers);
613 }
614
615 Ok(config)
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use std::sync::Mutex;
623
624 static ENV_LOCK: Mutex<()> = Mutex::new(());
625
626 #[test]
627 fn ticket_section_full_parse() {
628 let toml = r#"
629name = "Problem"
630type = "free"
631required = true
632placeholder = "What is broken or missing?"
633"#;
634 let s: TicketSection = toml::from_str(toml).unwrap();
635 assert_eq!(s.name, "Problem");
636 assert_eq!(s.type_, SectionType::Free);
637 assert!(s.required);
638 assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
639 }
640
641 #[test]
642 fn ticket_section_minimal_parse() {
643 let toml = r#"
644name = "Open questions"
645type = "qa"
646"#;
647 let s: TicketSection = toml::from_str(toml).unwrap();
648 assert_eq!(s.name, "Open questions");
649 assert_eq!(s.type_, SectionType::Qa);
650 assert!(!s.required);
651 assert!(s.placeholder.is_none());
652 }
653
654 #[test]
655 fn section_type_all_variants() {
656 #[derive(Deserialize)]
657 struct W { t: SectionType }
658 let free: W = toml::from_str("t = \"free\"").unwrap();
659 assert_eq!(free.t, SectionType::Free);
660 let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
661 assert_eq!(tasks.t, SectionType::Tasks);
662 let qa: W = toml::from_str("t = \"qa\"").unwrap();
663 assert_eq!(qa.t, SectionType::Qa);
664 }
665
666 #[test]
667 fn completion_strategy_all_variants() {
668 #[derive(Deserialize)]
669 struct W { c: CompletionStrategy }
670 let pr: W = toml::from_str("c = \"pr\"").unwrap();
671 assert_eq!(pr.c, CompletionStrategy::Pr);
672 let merge: W = toml::from_str("c = \"merge\"").unwrap();
673 assert_eq!(merge.c, CompletionStrategy::Merge);
674 let pull: W = toml::from_str("c = \"pull\"").unwrap();
675 assert_eq!(pull.c, CompletionStrategy::Pull);
676 let none: W = toml::from_str("c = \"none\"").unwrap();
677 assert_eq!(none.c, CompletionStrategy::None);
678 let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
679 assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
680 }
681
682 #[test]
683 fn completion_strategy_default() {
684 assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
685 }
686
687 #[test]
688 fn state_config_with_instructions() {
689 let toml = r#"
690id = "in_progress"
691label = "In Progress"
692instructions = "apm.worker.md"
693"#;
694 let s: StateConfig = toml::from_str(toml).unwrap();
695 assert_eq!(s.id, "in_progress");
696 assert_eq!(s.instructions.as_deref(), Some("apm.worker.md"));
697 }
698
699 #[test]
700 fn state_config_instructions_default_none() {
701 let toml = r#"
702id = "new"
703label = "New"
704"#;
705 let s: StateConfig = toml::from_str(toml).unwrap();
706 assert!(s.instructions.is_none());
707 }
708
709 #[test]
710 fn transition_config_new_fields() {
711 let toml = r#"
712to = "implemented"
713trigger = "manual"
714completion = "pr"
715focus_section = "Code review"
716context_section = "Problem"
717"#;
718 let t: TransitionConfig = toml::from_str(toml).unwrap();
719 assert_eq!(t.completion, CompletionStrategy::Pr);
720 assert_eq!(t.focus_section.as_deref(), Some("Code review"));
721 assert_eq!(t.context_section.as_deref(), Some("Problem"));
722 }
723
724 #[test]
725 fn transition_config_new_fields_default() {
726 let toml = r#"
727to = "ready"
728trigger = "manual"
729"#;
730 let t: TransitionConfig = toml::from_str(toml).unwrap();
731 assert_eq!(t.completion, CompletionStrategy::None);
732 assert!(t.focus_section.is_none());
733 assert!(t.context_section.is_none());
734 }
735
736 #[test]
737 fn workers_config_parses() {
738 let toml = r#"
739[project]
740name = "test"
741
742[tickets]
743dir = "tickets"
744
745[workers]
746container = "apm-worker:latest"
747
748[workers.keychain]
749ANTHROPIC_API_KEY = "anthropic-api-key"
750"#;
751 let config: Config = toml::from_str(toml).unwrap();
752 assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
753 assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
754 }
755
756 #[test]
757 fn workers_config_default() {
758 let toml = r#"
759[project]
760name = "test"
761
762[tickets]
763dir = "tickets"
764"#;
765 let config: Config = toml::from_str(toml).unwrap();
766 assert!(config.workers.container.is_none());
767 assert!(config.workers.keychain.is_empty());
768 assert_eq!(config.workers.command, "claude");
769 assert_eq!(config.workers.args, vec!["--print"]);
770 assert!(config.workers.model.is_none());
771 assert!(config.workers.env.is_empty());
772 }
773
774 #[test]
775 fn workers_config_all_fields() {
776 let toml = r#"
777[project]
778name = "test"
779
780[tickets]
781dir = "tickets"
782
783[workers]
784command = "codex"
785args = ["--full-auto"]
786model = "o3"
787
788[workers.env]
789CUSTOM_VAR = "value"
790"#;
791 let config: Config = toml::from_str(toml).unwrap();
792 assert_eq!(config.workers.command, "codex");
793 assert_eq!(config.workers.args, vec!["--full-auto"]);
794 assert_eq!(config.workers.model.as_deref(), Some("o3"));
795 assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
796 }
797
798 #[test]
799 fn local_config_parses() {
800 let toml = r#"
801[workers]
802command = "aider"
803model = "gpt-4"
804
805[workers.env]
806OPENAI_API_KEY = "sk-test"
807"#;
808 let local: LocalConfig = toml::from_str(toml).unwrap();
809 assert_eq!(local.workers.command.as_deref(), Some("aider"));
810 assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
811 assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
812 assert!(local.workers.args.is_none());
813 }
814
815 #[test]
816 fn merge_local_overrides_and_extends() {
817 let mut wc = WorkersConfig::default();
818 assert_eq!(wc.command, "claude");
819 assert_eq!(wc.args, vec!["--print"]);
820
821 let local = LocalWorkersOverride {
822 command: Some("aider".to_string()),
823 args: None,
824 model: Some("gpt-4".to_string()),
825 env: [("KEY".to_string(), "val".to_string())].into(),
826 };
827 wc.merge_local(&local);
828
829 assert_eq!(wc.command, "aider");
830 assert_eq!(wc.args, vec!["--print"]); assert_eq!(wc.model.as_deref(), Some("gpt-4"));
832 assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
833 }
834
835 #[test]
836 fn agents_skip_permissions_parses_and_defaults() {
837 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
838
839 let config: Config = toml::from_str(base).unwrap();
841 assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
842
843 let with_agents = format!("{base}[agents]\n");
845 let config: Config = toml::from_str(&with_agents).unwrap();
846 assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
847
848 let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
850 let config: Config = toml::from_str(&explicit_true).unwrap();
851 assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
852
853 let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
855 let config: Config = toml::from_str(&explicit_false).unwrap();
856 assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
857 }
858
859 #[test]
860 fn actionable_states_for_agent_includes_ready() {
861 let toml = r#"
862[project]
863name = "test"
864
865[tickets]
866dir = "tickets"
867
868[[workflow.states]]
869id = "ready"
870label = "Ready"
871actionable = ["agent"]
872
873[[workflow.states]]
874id = "in_progress"
875label = "In Progress"
876
877[[workflow.states]]
878id = "specd"
879label = "Specd"
880actionable = ["supervisor"]
881"#;
882 let config: Config = toml::from_str(toml).unwrap();
883 let states = config.actionable_states_for("agent");
884 assert!(states.contains(&"ready".to_string()));
885 assert!(!states.contains(&"specd".to_string()));
886 assert!(!states.contains(&"in_progress".to_string()));
887 }
888
889 #[test]
890 fn work_epic_parses() {
891 let toml = r#"
892[project]
893name = "test"
894
895[tickets]
896dir = "tickets"
897
898[work]
899epic = "ab12cd34"
900"#;
901 let config: Config = toml::from_str(toml).unwrap();
902 assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
903 }
904
905 #[test]
906 fn work_config_defaults_to_none() {
907 let toml = r#"
908[project]
909name = "test"
910
911[tickets]
912dir = "tickets"
913"#;
914 let config: Config = toml::from_str(toml).unwrap();
915 assert!(config.work.epic.is_none());
916 }
917
918 #[test]
919 fn sync_aggressive_defaults_to_true() {
920 let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
921
922 let config: Config = toml::from_str(base).unwrap();
924 assert!(config.sync.aggressive, "no [sync] section should default to true");
925
926 let with_sync = format!("{base}[sync]\n");
928 let config: Config = toml::from_str(&with_sync).unwrap();
929 assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
930
931 let explicit_false = format!("{base}[sync]\naggressive = false\n");
933 let config: Config = toml::from_str(&explicit_false).unwrap();
934 assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
935
936 let explicit_true = format!("{base}[sync]\naggressive = true\n");
938 let config: Config = toml::from_str(&explicit_true).unwrap();
939 assert!(config.sync.aggressive, "explicit aggressive = true should be true");
940 }
941
942 #[test]
943 fn collaborators_parses() {
944 let toml = r#"
945[project]
946name = "test"
947collaborators = ["alice", "bob"]
948
949[tickets]
950dir = "tickets"
951"#;
952 let config: Config = toml::from_str(toml).unwrap();
953 assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
954 }
955
956 #[test]
957 fn collaborators_defaults_empty() {
958 let toml = r#"
959[project]
960name = "test"
961
962[tickets]
963dir = "tickets"
964"#;
965 let config: Config = toml::from_str(toml).unwrap();
966 assert!(config.project.collaborators.is_empty());
967 }
968
969 #[test]
970 fn resolve_identity_returns_username_when_present() {
971 let tmp = tempfile::tempdir().unwrap();
972 let apm_dir = tmp.path().join(".apm");
973 std::fs::create_dir_all(&apm_dir).unwrap();
974 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
975 assert_eq!(resolve_identity(tmp.path()), "alice");
976 }
977
978 #[test]
979 fn resolve_identity_returns_unassigned_when_absent() {
980 let tmp = tempfile::tempdir().unwrap();
981 assert_eq!(resolve_identity(tmp.path()), "unassigned");
982 }
983
984 #[test]
985 fn resolve_identity_returns_unassigned_when_empty() {
986 let tmp = tempfile::tempdir().unwrap();
987 let apm_dir = tmp.path().join(".apm");
988 std::fs::create_dir_all(&apm_dir).unwrap();
989 std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
990 assert_eq!(resolve_identity(tmp.path()), "unassigned");
991 }
992
993 #[test]
994 fn resolve_identity_returns_unassigned_when_username_key_absent() {
995 let tmp = tempfile::tempdir().unwrap();
996 let apm_dir = tmp.path().join(".apm");
997 std::fs::create_dir_all(&apm_dir).unwrap();
998 std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
999 assert_eq!(resolve_identity(tmp.path()), "unassigned");
1000 }
1001
1002 #[test]
1003 fn local_config_username_parses() {
1004 let toml = r#"
1005username = "bob"
1006"#;
1007 let local: LocalConfig = toml::from_str(toml).unwrap();
1008 assert_eq!(local.username.as_deref(), Some("bob"));
1009 }
1010
1011 #[test]
1012 fn local_config_username_defaults_none() {
1013 let local: LocalConfig = toml::from_str("").unwrap();
1014 assert!(local.username.is_none());
1015 }
1016
1017 #[test]
1018 fn server_config_defaults() {
1019 let toml = r#"
1020[project]
1021name = "test"
1022
1023[tickets]
1024dir = "tickets"
1025"#;
1026 let config: Config = toml::from_str(toml).unwrap();
1027 assert_eq!(config.server.origin, "http://localhost:3000");
1028 }
1029
1030 #[test]
1031 fn server_config_custom_origin() {
1032 let toml = r#"
1033[project]
1034name = "test"
1035
1036[tickets]
1037dir = "tickets"
1038
1039[server]
1040origin = "https://apm.example.com"
1041"#;
1042 let config: Config = toml::from_str(toml).unwrap();
1043 assert_eq!(config.server.origin, "https://apm.example.com");
1044 }
1045
1046 #[test]
1047 fn git_host_config_parses() {
1048 let toml = r#"
1049[project]
1050name = "test"
1051
1052[tickets]
1053dir = "tickets"
1054
1055[git_host]
1056provider = "github"
1057repo = "owner/name"
1058"#;
1059 let config: Config = toml::from_str(toml).unwrap();
1060 assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1061 assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1062 }
1063
1064 #[test]
1065 fn git_host_config_absent_defaults_none() {
1066 let toml = r#"
1067[project]
1068name = "test"
1069
1070[tickets]
1071dir = "tickets"
1072"#;
1073 let config: Config = toml::from_str(toml).unwrap();
1074 assert!(config.git_host.provider.is_none());
1075 assert!(config.git_host.repo.is_none());
1076 }
1077
1078 #[test]
1079 fn local_config_github_token_parses() {
1080 let toml = r#"github_token = "ghp_abc123""#;
1081 let local: LocalConfig = toml::from_str(toml).unwrap();
1082 assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1083 }
1084
1085 #[test]
1086 fn local_config_github_token_absent_defaults_none() {
1087 let local: LocalConfig = toml::from_str("").unwrap();
1088 assert!(local.github_token.is_none());
1089 }
1090
1091 #[test]
1092 fn tickets_archive_dir_parses() {
1093 let toml = r#"
1094[project]
1095name = "test"
1096
1097[tickets]
1098dir = "tickets"
1099archive_dir = "archive/tickets"
1100"#;
1101 let config: Config = toml::from_str(toml).unwrap();
1102 assert_eq!(
1103 config.tickets.archive_dir.as_deref(),
1104 Some(std::path::Path::new("archive/tickets"))
1105 );
1106 }
1107
1108 #[test]
1109 fn tickets_archive_dir_absent_defaults_none() {
1110 let toml = r#"
1111[project]
1112name = "test"
1113
1114[tickets]
1115dir = "tickets"
1116"#;
1117 let config: Config = toml::from_str(toml).unwrap();
1118 assert!(config.tickets.archive_dir.is_none());
1119 }
1120
1121 #[test]
1122 fn agents_max_workers_per_epic_defaults_to_one() {
1123 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1124 let config: Config = toml::from_str(toml).unwrap();
1125 assert_eq!(config.agents.max_workers_per_epic, 1);
1126 }
1127
1128 #[test]
1129 fn blocked_epics_global_limit_one() {
1130 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1131 let config: Config = toml::from_str(toml).unwrap();
1132 let active = vec![Some("epicA".to_string())];
1134 let blocked = config.blocked_epics(&active);
1135 assert!(blocked.contains(&"epicA".to_string()));
1136 }
1137
1138 #[test]
1139 fn blocked_epics_global_limit_two() {
1140 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1141 let config: Config = toml::from_str(toml).unwrap();
1142 let active = vec![Some("epicA".to_string())];
1144 let blocked = config.blocked_epics(&active);
1145 assert!(!blocked.contains(&"epicA".to_string()));
1146 }
1147
1148 #[test]
1149 fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1150 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1151 let config: Config = toml::from_str(base).unwrap();
1152 assert_eq!(config.agents.max_workers_on_default, 1);
1153 let active: Vec<Option<String>> = vec![];
1155 assert!(!config.is_default_branch_blocked(&active));
1156 }
1157
1158 #[test]
1159 fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1160 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1161 let config: Config = toml::from_str(base).unwrap();
1162 let active = vec![None];
1164 assert!(config.is_default_branch_blocked(&active));
1165 }
1166
1167 #[test]
1168 fn default_branch_not_blocked_when_limit_zero() {
1169 let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1170 let config: Config = toml::from_str(toml).unwrap();
1171 let active = vec![None, None, None];
1173 assert!(!config.is_default_branch_blocked(&active));
1174 }
1175
1176 #[test]
1177 fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1178 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1179 let config: Config = toml::from_str(base).unwrap();
1180 let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1182 assert!(!config.is_default_branch_blocked(&active));
1183 }
1184
1185 #[test]
1186 fn prefers_apm_agent_name() {
1187 let _g = ENV_LOCK.lock().unwrap();
1188 std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1189 assert_eq!(resolve_caller_name(), "explicit-agent");
1190 std::env::remove_var("APM_AGENT_NAME");
1191 }
1192
1193 #[test]
1194 fn falls_back_to_user() {
1195 let _g = ENV_LOCK.lock().unwrap();
1196 std::env::remove_var("APM_AGENT_NAME");
1197 std::env::set_var("USER", "unix-user");
1198 std::env::remove_var("USERNAME");
1199 assert_eq!(resolve_caller_name(), "unix-user");
1200 std::env::remove_var("USER");
1201 }
1202
1203 #[test]
1204 fn defaults_to_apm() {
1205 let _g = ENV_LOCK.lock().unwrap();
1206 std::env::remove_var("APM_AGENT_NAME");
1207 std::env::remove_var("USER");
1208 std::env::remove_var("USERNAME");
1209 assert_eq!(resolve_caller_name(), "apm");
1210 }
1211}