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