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