Skip to main content

apm_core/
config.rs

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    /// Warnings generated during load (e.g. conflicting split/monolithic files).
185    #[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    /// Who can actively pick up / act on tickets in this state.
281    /// Values: "agent", "supervisor", "engineer", "any".
282    /// Drives `apm next`, `apm start`, and `apm list --actionable`.
283    #[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    /// Short label shown in the review prompt (e.g. "Approve for implementation")
295    #[serde(default)]
296    pub label: String,
297    /// Guidance shown in the editor header (e.g. "Add requests in ### Amendment requests")
298    #[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        // git_host is the identity authority — do not fall back to local.toml
428        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    // No git_host — use local.toml username (local-only dev)
442    if let Some(ref u) = local.username {
443        if !u.is_empty() {
444            return u.clone();
445        }
446    }
447    "unassigned".to_string()
448}
449
450/// Returns the caller identity for this process.
451///
452/// This value is used in two places:
453/// - Recorded as the acting party in ticket history entries.
454/// - Compared against a ticket's `owner` field when filtering candidates
455///   in `pick_next()` / `sorted_actionable()`. Tickets owned by another
456///   identity are excluded from the pick set.
457///
458/// Resolution order: `APM_AGENT_NAME` env var → `USER` → `USERNAME` → `"apm"`.
459pub 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    /// Returns epic IDs that have reached their `max_workers` limit
516    /// given the currently active worker epic assignments.
517    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    /// States where `actor` can actively pick up / act on tickets.
536    /// Matches "any" as a wildcard in addition to the literal actor name.
537    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"]); // unchanged
842        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        // absent → false
851        let config: Config = toml::from_str(base).unwrap();
852        assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
853
854        // [agents] section without the key → still false
855        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        // explicit true
860        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        // explicit false
865        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        // no [sync] section
934        let config: Config = toml::from_str(base).unwrap();
935        assert!(config.sync.aggressive, "no [sync] section should default to true");
936
937        // [sync] section with no aggressive key
938        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        // explicit false
943        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        // explicit true
948        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}