Skip to main content

apm_core/
config.rs

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    /// Warnings generated during load (e.g. conflicting split/monolithic files).
178    #[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    /// Who can actively pick up / act on tickets in this state.
274    /// Values: "agent", "supervisor", "engineer", "any".
275    /// Drives `apm next`, `apm start`, and `apm list --actionable`.
276    #[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    /// Short label shown in the review prompt (e.g. "Approve for implementation")
288    #[serde(default)]
289    pub label: String,
290    /// Guidance shown in the editor header (e.g. "Add requests in ### Amendment requests")
291    #[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        // git_host is the identity authority — do not fall back to local.toml
431        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    // No git_host — use local.toml username (local-only dev)
445    if let Some(ref u) = local.username {
446        if !u.is_empty() {
447            return u.clone();
448        }
449    }
450    "unassigned".to_string()
451}
452
453/// Returns the caller identity for this process.
454///
455/// This value is used in two places:
456/// - Recorded as the acting party in ticket history entries.
457/// - Compared against a ticket's `owner` field when filtering candidates
458///   in `pick_next()` / `sorted_actionable()`. Tickets owned by another
459///   identity are excluded from the pick set.
460///
461/// Resolution order: `APM_AGENT_NAME` env var → `USER` → `USERNAME` → `"apm"`.
462pub 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    /// Returns epic IDs that have reached the global `max_workers_per_epic` limit
515    /// given the currently active worker epic assignments.
516    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    /// Returns true when the default-branch worker slot is full.
529    /// A value of 0 for `max_workers_on_default` means no additional cap.
530    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    /// States where `actor` can actively pick up / act on tickets.
539    /// Matches "any" as a wildcard in addition to the literal actor name.
540    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"]); // unchanged
831        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        // absent → false
840        let config: Config = toml::from_str(base).unwrap();
841        assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
842
843        // [agents] section without the key → still false
844        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        // explicit true
849        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        // explicit false
854        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        // no [sync] section
923        let config: Config = toml::from_str(base).unwrap();
924        assert!(config.sync.aggressive, "no [sync] section should default to true");
925
926        // [sync] section with no aggressive key
927        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        // explicit false
932        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        // explicit true
937        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        // limit=1, one active worker in epic A → epic A is blocked
1133        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        // limit=2, one active worker in epic A → epic A is NOT blocked
1143        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        // limit=1, 0 active non-epic workers → not blocked
1154        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        // limit=1, 1 active non-epic worker → blocked
1163        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        // limit=0, any number of active non-epic workers → not blocked
1172        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        // limit=1, all active workers are epic-linked → not blocked
1181        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}