Skip to main content

apm_core/
config.rs

1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::Deserialize;
4use std::path::{Path, PathBuf};
5
6/// `free` — free-form prose. `tasks` — checkbox list (`- [ ] item`); supports `apm spec --mark` and `apm spec --add-task`. `qa` — question/answer pairs.
7#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
8#[serde(rename_all = "lowercase")]
9pub enum SectionType {
10    Free,
11    Tasks,
12    Qa,
13}
14
15/// A single section in the ticket template.
16#[derive(Debug, Clone, Deserialize, JsonSchema)]
17pub struct TicketSection {
18    /// Display name of the section (e.g. "Problem", "Approach").
19    pub name: String,
20    /// Rendering mode — `tasks` sections support `apm spec --mark` and `apm spec --add-task`; `free` is prose; `qa` is question/answer pairs.
21    #[serde(rename = "type")]
22    pub type_: SectionType,
23    /// Whether the section must be non-empty before the ticket can transition out of in_design.
24    #[serde(default)]
25    pub required: bool,
26    /// Hint text pre-filled into an empty section when a new ticket is created.
27    #[serde(default)]
28    pub placeholder: Option<String>,
29}
30
31/// Configuration for the sections that appear on every ticket, in order.
32/// Defined in `.apm/ticket.toml` as `[[ticket.sections]]` blocks.
33#[derive(Debug, Deserialize, Default, JsonSchema)]
34pub struct TicketConfig {
35    #[serde(default)]
36    pub sections: Vec<TicketSection>,
37}
38
39/// Determines how a worker's branch is integrated as part of a state transition.
40/// `pr`: open PR, fires on open not merge. `merge`: merge to target_branch directly.
41/// `pull`: pull upstream into ticket branch. `pr_or_epic_merge`: recommended default — PR
42/// on main, merge to epic branch when ticket belongs to an epic. `none`: no integration.
43#[derive(Debug, Clone, PartialEq, Deserialize, Default, JsonSchema)]
44#[serde(rename_all = "lowercase")]
45pub enum CompletionStrategy {
46    Pr,
47    Merge,
48    Pull,
49    #[serde(rename = "pr_or_epic_merge")]
50    PrOrEpicMerge,
51    #[default]
52    None,
53}
54
55#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
56pub struct LoggingConfig {
57    /// When true, apm writes a debug log file for each run.
58    #[serde(default)]
59    pub enabled: bool,
60    /// Path to the log file written when logging is enabled.
61    pub file: Option<std::path::PathBuf>,
62}
63
64#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
65#[serde(default)]
66pub struct GitHostConfig {
67    /// Git host provider; currently only `github` is supported.
68    pub provider: Option<String>,
69    /// Repository path in `owner/name` form used for PR creation and collaborator lookup.
70    pub repo: Option<String>,
71    /// Environment variable name that holds the git host API token.
72    pub token_env: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76pub struct WorkersConfig {
77    /// Docker image used to run worker agents; omit for local execution.
78    pub container: Option<String>,
79    /// Map of secret names to keychain item names resolved at worker launch time.
80    #[serde(default)]
81    pub keychain: std::collections::HashMap<String, String>,
82    /// Executable used to run worker agents.
83    #[serde(default = "default_command")]
84    pub command: String,
85    /// Default arguments passed to the worker command.
86    #[serde(default = "default_args")]
87    pub args: Vec<String>,
88    /// AI model override passed to the worker command; empty means use the command default.
89    #[serde(default)]
90    pub model: Option<String>,
91    /// Environment variables injected into every worker process.
92    #[serde(default)]
93    pub env: std::collections::HashMap<String, String>,
94}
95
96impl Default for WorkersConfig {
97    fn default() -> Self {
98        Self {
99            container: None,
100            keychain: std::collections::HashMap::new(),
101            command: default_command(),
102            args: default_args(),
103            model: None,
104            env: std::collections::HashMap::new(),
105        }
106    }
107}
108
109fn default_command() -> String { "claude".to_string() }
110fn default_args() -> Vec<String> { vec!["--print".to_string()] }
111
112#[derive(Debug, Clone, Deserialize, Default, JsonSchema)]
113pub struct WorkerProfileConfig {
114    /// Override the worker command for this profile.
115    pub command: Option<String>,
116    /// Override the worker command arguments for this profile.
117    pub args: Option<Vec<String>>,
118    /// Override the AI model for this profile.
119    pub model: Option<String>,
120    /// Extra environment variables merged into the worker environment for this profile.
121    #[serde(default)]
122    pub env: std::collections::HashMap<String, String>,
123    /// Override the Docker image for this profile.
124    pub container: Option<String>,
125    /// Additional instructions prepended to the worker prompt for this profile.
126    pub instructions: Option<String>,
127    /// Role label prepended to the worker identity string for this profile.
128    pub role_prefix: Option<String>,
129}
130
131#[derive(Debug, Deserialize, Default, JsonSchema)]
132pub struct WorkConfig {
133    /// Default epic ID assigned when creating tickets with `apm new`.
134    #[serde(default)]
135    pub epic: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize, JsonSchema)]
139pub struct ServerConfig {
140    /// Public-facing origin URL of the apm server, used in PR descriptions.
141    #[serde(default = "default_server_origin")]
142    pub origin: String,
143    /// Internal URL the apm CLI uses to reach the apm server.
144    #[serde(default = "default_server_url")]
145    pub url: String,
146}
147
148fn default_server_origin() -> String {
149    "http://localhost:3000".to_string()
150}
151
152fn default_server_url() -> String {
153    "http://127.0.0.1:3000".to_string()
154}
155
156impl Default for ServerConfig {
157    fn default() -> Self {
158        Self { origin: default_server_origin(), url: default_server_url() }
159    }
160}
161
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct ContextConfig {
164    /// Maximum number of sibling tickets included in worker context bundles.
165    #[serde(default = "default_epic_sibling_cap")]
166    pub epic_sibling_cap: usize,
167    /// Maximum byte size of the context bundle injected into worker prompts.
168    #[serde(default = "default_epic_byte_cap")]
169    pub epic_byte_cap: usize,
170}
171
172fn default_epic_sibling_cap() -> usize { 20 }
173fn default_epic_byte_cap() -> usize { 8192 }
174
175impl Default for ContextConfig {
176    fn default() -> Self {
177        Self {
178            epic_sibling_cap: default_epic_sibling_cap(),
179            epic_byte_cap: default_epic_byte_cap(),
180        }
181    }
182}
183
184#[derive(Debug, Deserialize, JsonSchema)]
185pub struct Config {
186    pub project: ProjectConfig,
187    #[serde(default)]
188    pub ticket: TicketConfig,
189    #[serde(default)]
190    pub tickets: TicketsConfig,
191    #[serde(default)]
192    pub workflow: WorkflowConfig,
193    #[serde(default)]
194    pub agents: AgentsConfig,
195    #[serde(default)]
196    pub worktrees: WorktreesConfig,
197    #[serde(default)]
198    pub sync: SyncConfig,
199    #[serde(default)]
200    pub logging: LoggingConfig,
201    #[serde(default)]
202    pub workers: WorkersConfig,
203    #[serde(default)]
204    pub work: WorkConfig,
205    #[serde(default)]
206    pub server: ServerConfig,
207    #[serde(default)]
208    pub git_host: GitHostConfig,
209    #[serde(default)]
210    pub worker_profiles: std::collections::HashMap<String, WorkerProfileConfig>,
211    #[serde(default)]
212    pub context: ContextConfig,
213    /// Warnings generated during load (e.g. conflicting split/monolithic files).
214    #[serde(skip)]
215    pub load_warnings: Vec<String>,
216}
217
218#[derive(Deserialize)]
219pub(crate) struct WorkflowFile {
220    pub(crate) workflow: WorkflowConfig,
221}
222
223#[derive(Deserialize)]
224pub(crate) struct TicketFile {
225    pub(crate) ticket: TicketConfig,
226}
227
228#[derive(Debug, Clone, Deserialize, JsonSchema)]
229pub struct SyncConfig {
230    /// When true, `apm sync` fetches all remote branches before checking state.
231    #[serde(default = "default_true")]
232    pub aggressive: bool,
233}
234
235impl Default for SyncConfig {
236    fn default() -> Self {
237        Self { aggressive: true }
238    }
239}
240
241#[derive(Debug, Deserialize, JsonSchema)]
242pub struct ProjectConfig {
243    /// Project name shown in prompts and the APM dashboard.
244    pub name: String,
245    /// Optional description of the project's purpose.
246    #[serde(default)]
247    pub description: String,
248    /// Git branch used as the integration target for non-epic tickets.
249    #[serde(default = "default_branch_main")]
250    pub default_branch: String,
251    /// Usernames allowed to own and work on tickets.
252    #[serde(default)]
253    pub collaborators: Vec<String>,
254}
255
256fn default_branch_main() -> String {
257    "main".to_string()
258}
259
260#[derive(Debug, Deserialize, JsonSchema)]
261pub struct TicketsConfig {
262    /// Directory (relative to project root) where ticket files are stored.
263    pub dir: PathBuf,
264    #[serde(default)]
265    pub sections: Vec<String>,
266    /// Optional directory where closed tickets are moved on `apm close`.
267    #[serde(default)]
268    pub archive_dir: Option<PathBuf>,
269}
270
271impl Default for TicketsConfig {
272    fn default() -> Self {
273        Self {
274            dir: PathBuf::from("tickets"),
275            sections: Vec::new(),
276            archive_dir: None,
277        }
278    }
279}
280
281/// Defines the ticket state machine and prioritization weights. Loaded from `.apm/workflow.toml` or the `[workflow]` section of `apm.toml`.
282#[derive(Debug, Deserialize, Default, JsonSchema)]
283pub struct WorkflowConfig {
284    /// Ordered list of ticket states. Users define their own state IDs and transition graph.
285    #[serde(default)]
286    pub states: Vec<StateConfig>,
287    /// Weights used to rank tickets in `apm next` and `apm list`.
288    #[serde(default)]
289    pub prioritization: PrioritizationConfig,
290}
291
292/// Controls when reaching the parent state satisfies `depends_on` relationships on other tickets.
293#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
294#[serde(untagged)]
295pub enum SatisfiesDeps {
296    /// `false` = this state never satisfies dependencies; `true` = it always does.
297    Bool(bool),
298    /// Satisfies only dependencies annotated with this string tag via `dep_requires`.
299    Tag(String),
300}
301
302impl Default for SatisfiesDeps {
303    fn default() -> Self { SatisfiesDeps::Bool(false) }
304}
305
306/// A single state in the workflow state machine.
307#[derive(Debug, Deserialize, JsonSchema)]
308pub struct StateConfig {
309    /// Unique state identifier (e.g. `new`, `in_progress`). Used in ticket frontmatter and transition targets.
310    pub id: String,
311    /// Human-readable name shown in `apm list` and review prompts.
312    pub label: String,
313    /// Optional longer explanation of what this state means.
314    #[serde(default)]
315    pub description: String,
316    /// When `true`, tickets in this state are considered done; no further transitions are expected.
317    #[serde(default)]
318    pub terminal: bool,
319    /// When `true`, a worker finishing in this state is considered complete (used by the dispatcher to release the worker slot).
320    #[serde(default)]
321    pub worker_end: bool,
322    /// Whether reaching this state satisfies `depends_on` relationships. `false` = never, `true` = always, a string tag = satisfies deps tagged with that string.
323    #[serde(default)]
324    pub satisfies_deps: SatisfiesDeps,
325    /// Optional string tag that must appear in a dependency's `satisfies_deps` for it to count as satisfied.
326    #[serde(default)]
327    pub dep_requires: Option<String>,
328    /// List of outgoing transitions from this state.
329    #[serde(default)]
330    pub transitions: Vec<TransitionConfig>,
331    /// Roles that can actively pick up / act on tickets in this state. Valid values: `agent`, `supervisor`, `engineer`, `any`. Drives `apm next`, `apm start`, and `apm list --actionable`.
332    #[serde(default)]
333    pub actionable: Vec<String>,
334    /// Optional extra instructions injected into the worker prompt when a ticket enters this state.
335    #[serde(default)]
336    pub instructions: Option<String>,
337}
338
339/// A directed edge in the state machine: from the parent state to `to`.
340#[derive(Debug, Clone, Deserialize, JsonSchema)]
341pub struct TransitionConfig {
342    /// Target state ID after this transition fires.
343    pub to: String,
344    /// Event or command that fires this transition (e.g. `close`, `approve`).
345    #[serde(default)]
346    pub trigger: String,
347    /// Short label shown in the review prompt (e.g. `Approve for implementation`).
348    #[serde(default)]
349    pub label: String,
350    /// Guidance shown in the editor header (e.g. `Add requests in ### Amendment requests`).
351    #[serde(default)]
352    pub hint: String,
353    /// How the worker's branch is integrated before or after this transition. See `CompletionStrategy`.
354    #[serde(default)]
355    pub completion: CompletionStrategy,
356    /// Markdown section heading the agent should focus on when acting on this transition.
357    #[serde(default)]
358    pub focus_section: Option<String>,
359    /// Markdown section heading included as extra context for the agent.
360    #[serde(default)]
361    pub context_section: Option<String>,
362    /// Optional warning message shown to the supervisor before the transition is confirmed.
363    #[serde(default)]
364    pub warning: Option<String>,
365    /// Worker profile to use for the agent spawned by this transition. References a key in `[worker_profiles]`.
366    #[serde(default)]
367    pub profile: Option<String>,
368    #[serde(default)]
369    pub on_failure: Option<String>,
370}
371
372/// Weights used to compute the priority score for ticket selection in `apm next`.
373#[derive(Debug, Deserialize, Default, JsonSchema)]
374pub struct PrioritizationConfig {
375    /// Multiplier applied to the ticket's `priority` field. Default: 10.0.
376    #[serde(default = "default_priority_weight")]
377    pub priority_weight: f64,
378    /// Multiplier applied to the ticket's `effort` field (negative favours low-effort). Default: -2.0.
379    #[serde(default = "default_effort_weight")]
380    pub effort_weight: f64,
381    /// Multiplier applied to the ticket's `risk` field (negative favours low-risk). Default: -1.0.
382    #[serde(default = "default_risk_weight")]
383    pub risk_weight: f64,
384}
385
386fn default_priority_weight() -> f64 { 10.0 }
387fn default_effort_weight() -> f64 { -2.0 }
388fn default_risk_weight() -> f64 { -1.0 }
389
390#[derive(Debug, Deserialize, JsonSchema)]
391pub struct AgentsConfig {
392    /// Maximum number of worker agents allowed to run simultaneously.
393    #[serde(default = "default_max_concurrent")]
394    pub max_concurrent: usize,
395    /// Maximum workers allowed to work on the same epic at once.
396    #[serde(default = "default_max_workers_per_epic")]
397    pub max_workers_per_epic: usize,
398    /// Maximum workers allowed to target the default branch simultaneously.
399    #[serde(default = "default_max_workers_on_default")]
400    pub max_workers_on_default: usize,
401    /// Path to an instructions file injected into every worker prompt.
402    #[serde(default)]
403    pub instructions: Option<PathBuf>,
404    /// When true, workers may file side-note tickets during implementation.
405    #[serde(default = "default_true")]
406    pub side_tickets: bool,
407    /// When true, workers skip Claude Code permission prompts.
408    #[serde(default)]
409    pub skip_permissions: bool,
410}
411
412fn default_max_concurrent() -> usize { 3 }
413fn default_max_workers_per_epic() -> usize { 1 }
414fn default_max_workers_on_default() -> usize { 1 }
415fn default_true() -> bool { true }
416
417#[derive(Debug, Deserialize, JsonSchema)]
418pub struct WorktreesConfig {
419    /// Directory (relative to project root) where git worktrees are created.
420    pub dir: PathBuf,
421    /// Additional directories created inside each worker worktree.
422    #[serde(default)]
423    pub agent_dirs: Vec<String>,
424}
425
426impl Default for WorktreesConfig {
427    fn default() -> Self {
428        Self {
429            dir: PathBuf::from("../worktrees"),
430            agent_dirs: Vec::new(),
431        }
432    }
433}
434
435impl Default for AgentsConfig {
436    fn default() -> Self {
437        Self {
438            max_concurrent: default_max_concurrent(),
439            max_workers_per_epic: default_max_workers_per_epic(),
440            max_workers_on_default: default_max_workers_on_default(),
441            instructions: None,
442            side_tickets: true,
443            skip_permissions: false,
444        }
445    }
446}
447
448#[derive(Debug, Deserialize, Default)]
449pub struct LocalConfig {
450    #[serde(default)]
451    pub workers: LocalWorkersOverride,
452    #[serde(default)]
453    pub username: Option<String>,
454    #[serde(default)]
455    pub github_token: Option<String>,
456}
457
458#[derive(Debug, Deserialize, Default)]
459pub struct LocalWorkersOverride {
460    pub command: Option<String>,
461    pub args: Option<Vec<String>>,
462    pub model: Option<String>,
463    #[serde(default)]
464    pub env: std::collections::HashMap<String, String>,
465}
466
467impl LocalConfig {
468    pub fn load(root: &Path) -> Self {
469        let local_path = root.join(".apm").join("local.toml");
470        std::fs::read_to_string(&local_path)
471            .ok()
472            .and_then(|s| toml::from_str(&s).ok())
473            .unwrap_or_default()
474    }
475}
476
477fn effective_github_token(local: &LocalConfig, git_host: &GitHostConfig) -> Option<String> {
478    if let Some(ref t) = local.github_token {
479        if !t.is_empty() {
480            return Some(t.clone());
481        }
482    }
483    if let Some(ref env_var) = git_host.token_env {
484        if let Ok(t) = std::env::var(env_var) {
485            if !t.is_empty() {
486                return Some(t);
487            }
488        }
489    }
490    std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty())
491}
492
493pub fn resolve_identity(repo_root: &Path) -> String {
494    let local_path = repo_root.join(".apm").join("local.toml");
495    let local: LocalConfig = std::fs::read_to_string(&local_path)
496        .ok()
497        .and_then(|s| toml::from_str(&s).ok())
498        .unwrap_or_default();
499
500    let config_path = repo_root.join(".apm").join("config.toml");
501    let config: Option<Config> = std::fs::read_to_string(&config_path)
502        .ok()
503        .and_then(|s| toml::from_str(&s).ok());
504
505    let git_host = config.as_ref().map(|c| &c.git_host).cloned().unwrap_or_default();
506    if git_host.provider.is_some() {
507        // git_host is the identity authority — do not fall back to local.toml
508        if git_host.provider.as_deref() == Some("github") {
509            if let Some(login) = crate::github::gh_username() {
510                return login;
511            }
512            if let Some(token) = effective_github_token(&local, &git_host) {
513                if let Ok(login) = crate::github::fetch_authenticated_user(&token) {
514                    return login;
515                }
516            }
517        }
518        return "unassigned".to_string();
519    }
520
521    // No git_host — use local.toml username (local-only dev)
522    if let Some(ref u) = local.username {
523        if !u.is_empty() {
524            return u.clone();
525        }
526    }
527    "unassigned".to_string()
528}
529
530/// Returns the caller identity for this process.
531///
532/// This value is used in two places:
533/// - Recorded as the acting party in ticket history entries.
534/// - Compared against a ticket's `owner` field when filtering candidates
535///   in `pick_next()` / `sorted_actionable()`. Tickets owned by another
536///   identity are excluded from the pick set.
537///
538/// Resolution order: `APM_AGENT_NAME` env var → `USER` → `USERNAME` → `"apm"`.
539pub fn resolve_caller_name() -> String {
540    std::env::var("APM_AGENT_NAME")
541        .or_else(|_| std::env::var("USER"))
542        .or_else(|_| std::env::var("USERNAME"))
543        .unwrap_or_else(|_| "apm".to_string())
544}
545
546pub fn try_github_username(git_host: &GitHostConfig) -> Option<String> {
547    if git_host.provider.as_deref() != Some("github") {
548        return None;
549    }
550    if let Some(login) = crate::github::gh_username() {
551        return Some(login);
552    }
553    let local = LocalConfig::default();
554    let token = effective_github_token(&local, git_host)?;
555    crate::github::fetch_authenticated_user(&token).ok()
556}
557
558pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec<String>, Vec<String>) {
559    let mut warnings = Vec::new();
560    if config.git_host.provider.as_deref() == Some("github") {
561        if let Some(ref repo) = config.git_host.repo {
562            if let Some(token) = effective_github_token(local, &config.git_host) {
563                match crate::github::fetch_repo_collaborators(&token, repo) {
564                    Ok(logins) => return (logins, warnings),
565                    Err(e) => warnings.push(format!("apm: GitHub collaborators fetch failed: {e:#}")),
566                }
567            }
568        }
569    }
570    (config.project.collaborators.clone(), warnings)
571}
572
573impl WorkersConfig {
574    pub fn merge_local(&mut self, local: &LocalWorkersOverride) {
575        if let Some(ref cmd) = local.command {
576            self.command = cmd.clone();
577        }
578        if let Some(ref args) = local.args {
579            self.args = args.clone();
580        }
581        if let Some(ref model) = local.model {
582            self.model = Some(model.clone());
583        }
584        for (k, v) in &local.env {
585            self.env.insert(k.clone(), v.clone());
586        }
587    }
588}
589
590impl Config {
591    /// Returns epic IDs that have reached the global `max_workers_per_epic` limit
592    /// given the currently active worker epic assignments.
593    pub fn blocked_epics(&self, active_epic_ids: &[Option<String>]) -> Vec<String> {
594        let limit = self.agents.max_workers_per_epic;
595        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
596        for eid in active_epic_ids.iter().filter_map(|e| e.as_deref()) {
597            *counts.entry(eid).or_insert(0) += 1;
598        }
599        counts.into_iter()
600            .filter(|(_, count)| *count >= limit)
601            .map(|(eid, _)| eid.to_string())
602            .collect()
603    }
604
605    /// Returns true when the default-branch worker slot is full.
606    /// A value of 0 for `max_workers_on_default` means no additional cap.
607    pub fn is_default_branch_blocked(&self, active_epic_ids: &[Option<String>]) -> bool {
608        if self.agents.max_workers_on_default == 0 {
609            return false;
610        }
611        let count = active_epic_ids.iter().filter(|e| e.is_none()).count();
612        count >= self.agents.max_workers_on_default
613    }
614
615    /// States where `actor` can actively pick up / act on tickets.
616    /// Matches "any" as a wildcard in addition to the literal actor name.
617    pub fn actionable_states_for(&self, actor: &str) -> Vec<String> {
618        self.workflow.states.iter()
619            .filter(|s| s.actionable.iter().any(|a| a == actor || a == "any"))
620            .map(|s| s.id.clone())
621            .collect()
622    }
623
624    pub fn terminal_state_ids(&self) -> std::collections::HashSet<String> {
625        let mut ids: std::collections::HashSet<String> = self.workflow.states.iter()
626            .filter(|s| s.terminal)
627            .map(|s| s.id.clone())
628            .collect();
629        ids.insert("closed".to_string());
630        ids
631    }
632
633    pub fn find_section(&self, name: &str) -> Option<&TicketSection> {
634        self.ticket.sections.iter()
635            .find(|s| s.name.eq_ignore_ascii_case(name))
636    }
637
638    pub fn has_section(&self, name: &str) -> bool {
639        self.find_section(name).is_some()
640    }
641
642    pub fn load(repo_root: &Path) -> Result<Self> {
643        let apm_dir = repo_root.join(".apm");
644        let apm_dir_config = apm_dir.join("config.toml");
645        let path = if apm_dir_config.exists() {
646            apm_dir_config
647        } else {
648            repo_root.join("apm.toml")
649        };
650        let contents = std::fs::read_to_string(&path)
651            .with_context(|| format!("cannot read {}", path.display()))?;
652        let mut config: Config = toml::from_str(&contents)
653            .with_context(|| format!("cannot parse {}", path.display()))?;
654
655        let workflow_path = apm_dir.join("workflow.toml");
656        if workflow_path.exists() {
657            let wf_contents = std::fs::read_to_string(&workflow_path)
658                .with_context(|| format!("cannot read {}", workflow_path.display()))?;
659            let wf: WorkflowFile = toml::from_str(&wf_contents)
660                .with_context(|| format!("cannot parse {}", workflow_path.display()))?;
661            if !config.workflow.states.is_empty() {
662                config.load_warnings.push(
663                    "both .apm/workflow.toml and [workflow] in config.toml exist; workflow.toml takes precedence".into()
664                );
665            }
666            config.workflow = wf.workflow;
667        }
668
669        let ticket_path = apm_dir.join("ticket.toml");
670        if ticket_path.exists() {
671            let tk_contents = std::fs::read_to_string(&ticket_path)
672                .with_context(|| format!("cannot read {}", ticket_path.display()))?;
673            let tk: TicketFile = toml::from_str(&tk_contents)
674                .with_context(|| format!("cannot parse {}", ticket_path.display()))?;
675            if !config.ticket.sections.is_empty() {
676                config.load_warnings.push(
677                    "both .apm/ticket.toml and [[ticket.sections]] in config.toml exist; ticket.toml takes precedence".into()
678                );
679            }
680            config.ticket = tk.ticket;
681        }
682
683        let local_path = apm_dir.join("local.toml");
684        if local_path.exists() {
685            let local_contents = std::fs::read_to_string(&local_path)
686                .with_context(|| format!("cannot read {}", local_path.display()))?;
687            let local: LocalConfig = toml::from_str(&local_contents)
688                .with_context(|| format!("cannot parse {}", local_path.display()))?;
689            config.workers.merge_local(&local.workers);
690        }
691
692        Ok(config)
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use std::sync::Mutex;
700
701    static ENV_LOCK: Mutex<()> = Mutex::new(());
702
703    #[test]
704    fn ticket_section_full_parse() {
705        let toml = r#"
706name        = "Problem"
707type        = "free"
708required    = true
709placeholder = "What is broken or missing?"
710"#;
711        let s: TicketSection = toml::from_str(toml).unwrap();
712        assert_eq!(s.name, "Problem");
713        assert_eq!(s.type_, SectionType::Free);
714        assert!(s.required);
715        assert_eq!(s.placeholder.as_deref(), Some("What is broken or missing?"));
716    }
717
718    #[test]
719    fn ticket_section_minimal_parse() {
720        let toml = r#"
721name = "Open questions"
722type = "qa"
723"#;
724        let s: TicketSection = toml::from_str(toml).unwrap();
725        assert_eq!(s.name, "Open questions");
726        assert_eq!(s.type_, SectionType::Qa);
727        assert!(!s.required);
728        assert!(s.placeholder.is_none());
729    }
730
731    #[test]
732    fn section_type_all_variants() {
733        #[derive(Deserialize)]
734        struct W { t: SectionType }
735        let free: W = toml::from_str("t = \"free\"").unwrap();
736        assert_eq!(free.t, SectionType::Free);
737        let tasks: W = toml::from_str("t = \"tasks\"").unwrap();
738        assert_eq!(tasks.t, SectionType::Tasks);
739        let qa: W = toml::from_str("t = \"qa\"").unwrap();
740        assert_eq!(qa.t, SectionType::Qa);
741    }
742
743    #[test]
744    fn completion_strategy_all_variants() {
745        #[derive(Deserialize)]
746        struct W { c: CompletionStrategy }
747        let pr: W = toml::from_str("c = \"pr\"").unwrap();
748        assert_eq!(pr.c, CompletionStrategy::Pr);
749        let merge: W = toml::from_str("c = \"merge\"").unwrap();
750        assert_eq!(merge.c, CompletionStrategy::Merge);
751        let pull: W = toml::from_str("c = \"pull\"").unwrap();
752        assert_eq!(pull.c, CompletionStrategy::Pull);
753        let none: W = toml::from_str("c = \"none\"").unwrap();
754        assert_eq!(none.c, CompletionStrategy::None);
755        let prem: W = toml::from_str("c = \"pr_or_epic_merge\"").unwrap();
756        assert_eq!(prem.c, CompletionStrategy::PrOrEpicMerge);
757    }
758
759    #[test]
760    fn completion_strategy_default() {
761        assert_eq!(CompletionStrategy::default(), CompletionStrategy::None);
762    }
763
764    #[test]
765    fn state_config_with_instructions() {
766        let toml = r#"
767id           = "in_progress"
768label        = "In Progress"
769instructions = "apm.worker.md"
770"#;
771        let s: StateConfig = toml::from_str(toml).unwrap();
772        assert_eq!(s.id, "in_progress");
773        assert_eq!(s.instructions.as_deref(), Some("apm.worker.md"));
774    }
775
776    #[test]
777    fn state_config_instructions_default_none() {
778        let toml = r#"
779id    = "new"
780label = "New"
781"#;
782        let s: StateConfig = toml::from_str(toml).unwrap();
783        assert!(s.instructions.is_none());
784    }
785
786    #[test]
787    fn transition_config_new_fields() {
788        let toml = r#"
789to              = "implemented"
790trigger         = "manual"
791completion      = "pr"
792focus_section   = "Code review"
793context_section = "Problem"
794"#;
795        let t: TransitionConfig = toml::from_str(toml).unwrap();
796        assert_eq!(t.completion, CompletionStrategy::Pr);
797        assert_eq!(t.focus_section.as_deref(), Some("Code review"));
798        assert_eq!(t.context_section.as_deref(), Some("Problem"));
799    }
800
801    #[test]
802    fn transition_config_new_fields_default() {
803        let toml = r#"
804to      = "ready"
805trigger = "manual"
806"#;
807        let t: TransitionConfig = toml::from_str(toml).unwrap();
808        assert_eq!(t.completion, CompletionStrategy::None);
809        assert!(t.focus_section.is_none());
810        assert!(t.context_section.is_none());
811    }
812
813    #[test]
814    fn workers_config_parses() {
815        let toml = r#"
816[project]
817name = "test"
818
819[tickets]
820dir = "tickets"
821
822[workers]
823container = "apm-worker:latest"
824
825[workers.keychain]
826ANTHROPIC_API_KEY = "anthropic-api-key"
827"#;
828        let config: Config = toml::from_str(toml).unwrap();
829        assert_eq!(config.workers.container.as_deref(), Some("apm-worker:latest"));
830        assert_eq!(config.workers.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), Some("anthropic-api-key"));
831    }
832
833    #[test]
834    fn workers_config_default() {
835        let toml = r#"
836[project]
837name = "test"
838
839[tickets]
840dir = "tickets"
841"#;
842        let config: Config = toml::from_str(toml).unwrap();
843        assert!(config.workers.container.is_none());
844        assert!(config.workers.keychain.is_empty());
845        assert_eq!(config.workers.command, "claude");
846        assert_eq!(config.workers.args, vec!["--print"]);
847        assert!(config.workers.model.is_none());
848        assert!(config.workers.env.is_empty());
849    }
850
851    #[test]
852    fn workers_config_all_fields() {
853        let toml = r#"
854[project]
855name = "test"
856
857[tickets]
858dir = "tickets"
859
860[workers]
861command = "codex"
862args = ["--full-auto"]
863model = "o3"
864
865[workers.env]
866CUSTOM_VAR = "value"
867"#;
868        let config: Config = toml::from_str(toml).unwrap();
869        assert_eq!(config.workers.command, "codex");
870        assert_eq!(config.workers.args, vec!["--full-auto"]);
871        assert_eq!(config.workers.model.as_deref(), Some("o3"));
872        assert_eq!(config.workers.env.get("CUSTOM_VAR").map(|s| s.as_str()), Some("value"));
873    }
874
875    #[test]
876    fn local_config_parses() {
877        let toml = r#"
878[workers]
879command = "aider"
880model = "gpt-4"
881
882[workers.env]
883OPENAI_API_KEY = "sk-test"
884"#;
885        let local: LocalConfig = toml::from_str(toml).unwrap();
886        assert_eq!(local.workers.command.as_deref(), Some("aider"));
887        assert_eq!(local.workers.model.as_deref(), Some("gpt-4"));
888        assert_eq!(local.workers.env.get("OPENAI_API_KEY").map(|s| s.as_str()), Some("sk-test"));
889        assert!(local.workers.args.is_none());
890    }
891
892    #[test]
893    fn merge_local_overrides_and_extends() {
894        let mut wc = WorkersConfig::default();
895        assert_eq!(wc.command, "claude");
896        assert_eq!(wc.args, vec!["--print"]);
897
898        let local = LocalWorkersOverride {
899            command: Some("aider".to_string()),
900            args: None,
901            model: Some("gpt-4".to_string()),
902            env: [("KEY".to_string(), "val".to_string())].into(),
903        };
904        wc.merge_local(&local);
905
906        assert_eq!(wc.command, "aider");
907        assert_eq!(wc.args, vec!["--print"]); // unchanged
908        assert_eq!(wc.model.as_deref(), Some("gpt-4"));
909        assert_eq!(wc.env.get("KEY").map(|s| s.as_str()), Some("val"));
910    }
911
912    #[test]
913    fn agents_skip_permissions_parses_and_defaults() {
914        let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
915
916        // absent → false
917        let config: Config = toml::from_str(base).unwrap();
918        assert!(!config.agents.skip_permissions, "absent skip_permissions should default to false");
919
920        // [agents] section without the key → still false
921        let with_agents = format!("{base}[agents]\n");
922        let config: Config = toml::from_str(&with_agents).unwrap();
923        assert!(!config.agents.skip_permissions, "[agents] without skip_permissions should default to false");
924
925        // explicit true
926        let explicit_true = format!("{base}[agents]\nskip_permissions = true\n");
927        let config: Config = toml::from_str(&explicit_true).unwrap();
928        assert!(config.agents.skip_permissions, "explicit skip_permissions = true should be true");
929
930        // explicit false
931        let explicit_false = format!("{base}[agents]\nskip_permissions = false\n");
932        let config: Config = toml::from_str(&explicit_false).unwrap();
933        assert!(!config.agents.skip_permissions, "explicit skip_permissions = false should be false");
934    }
935
936    #[test]
937    fn actionable_states_for_agent_includes_ready() {
938        let toml = r#"
939[project]
940name = "test"
941
942[tickets]
943dir = "tickets"
944
945[[workflow.states]]
946id = "ready"
947label = "Ready"
948actionable = ["agent"]
949
950[[workflow.states]]
951id = "in_progress"
952label = "In Progress"
953
954[[workflow.states]]
955id = "specd"
956label = "Specd"
957actionable = ["supervisor"]
958"#;
959        let config: Config = toml::from_str(toml).unwrap();
960        let states = config.actionable_states_for("agent");
961        assert!(states.contains(&"ready".to_string()));
962        assert!(!states.contains(&"specd".to_string()));
963        assert!(!states.contains(&"in_progress".to_string()));
964    }
965
966    #[test]
967    fn work_epic_parses() {
968        let toml = r#"
969[project]
970name = "test"
971
972[tickets]
973dir = "tickets"
974
975[work]
976epic = "ab12cd34"
977"#;
978        let config: Config = toml::from_str(toml).unwrap();
979        assert_eq!(config.work.epic.as_deref(), Some("ab12cd34"));
980    }
981
982    #[test]
983    fn work_config_defaults_to_none() {
984        let toml = r#"
985[project]
986name = "test"
987
988[tickets]
989dir = "tickets"
990"#;
991        let config: Config = toml::from_str(toml).unwrap();
992        assert!(config.work.epic.is_none());
993    }
994
995    #[test]
996    fn sync_aggressive_defaults_to_true() {
997        let base = "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n";
998
999        // no [sync] section
1000        let config: Config = toml::from_str(base).unwrap();
1001        assert!(config.sync.aggressive, "no [sync] section should default to true");
1002
1003        // [sync] section with no aggressive key
1004        let with_sync = format!("{base}[sync]\n");
1005        let config: Config = toml::from_str(&with_sync).unwrap();
1006        assert!(config.sync.aggressive, "[sync] without aggressive key should default to true");
1007
1008        // explicit false
1009        let explicit_false = format!("{base}[sync]\naggressive = false\n");
1010        let config: Config = toml::from_str(&explicit_false).unwrap();
1011        assert!(!config.sync.aggressive, "explicit aggressive = false should be false");
1012
1013        // explicit true
1014        let explicit_true = format!("{base}[sync]\naggressive = true\n");
1015        let config: Config = toml::from_str(&explicit_true).unwrap();
1016        assert!(config.sync.aggressive, "explicit aggressive = true should be true");
1017    }
1018
1019    #[test]
1020    fn collaborators_parses() {
1021        let toml = r#"
1022[project]
1023name = "test"
1024collaborators = ["alice", "bob"]
1025
1026[tickets]
1027dir = "tickets"
1028"#;
1029        let config: Config = toml::from_str(toml).unwrap();
1030        assert_eq!(config.project.collaborators, vec!["alice", "bob"]);
1031    }
1032
1033    #[test]
1034    fn collaborators_defaults_empty() {
1035        let toml = r#"
1036[project]
1037name = "test"
1038
1039[tickets]
1040dir = "tickets"
1041"#;
1042        let config: Config = toml::from_str(toml).unwrap();
1043        assert!(config.project.collaborators.is_empty());
1044    }
1045
1046    #[test]
1047    fn resolve_identity_returns_username_when_present() {
1048        let tmp = tempfile::tempdir().unwrap();
1049        let apm_dir = tmp.path().join(".apm");
1050        std::fs::create_dir_all(&apm_dir).unwrap();
1051        std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1052        assert_eq!(resolve_identity(tmp.path()), "alice");
1053    }
1054
1055    #[test]
1056    fn resolve_identity_returns_unassigned_when_absent() {
1057        let tmp = tempfile::tempdir().unwrap();
1058        assert_eq!(resolve_identity(tmp.path()), "unassigned");
1059    }
1060
1061    #[test]
1062    fn resolve_identity_returns_unassigned_when_empty() {
1063        let tmp = tempfile::tempdir().unwrap();
1064        let apm_dir = tmp.path().join(".apm");
1065        std::fs::create_dir_all(&apm_dir).unwrap();
1066        std::fs::write(apm_dir.join("local.toml"), "username = \"\"\n").unwrap();
1067        assert_eq!(resolve_identity(tmp.path()), "unassigned");
1068    }
1069
1070    #[test]
1071    fn resolve_identity_returns_unassigned_when_username_key_absent() {
1072        let tmp = tempfile::tempdir().unwrap();
1073        let apm_dir = tmp.path().join(".apm");
1074        std::fs::create_dir_all(&apm_dir).unwrap();
1075        std::fs::write(apm_dir.join("local.toml"), "[workers]\ncommand = \"claude\"\n").unwrap();
1076        assert_eq!(resolve_identity(tmp.path()), "unassigned");
1077    }
1078
1079    #[test]
1080    fn local_config_username_parses() {
1081        let toml = r#"
1082username = "bob"
1083"#;
1084        let local: LocalConfig = toml::from_str(toml).unwrap();
1085        assert_eq!(local.username.as_deref(), Some("bob"));
1086    }
1087
1088    #[test]
1089    fn local_config_username_defaults_none() {
1090        let local: LocalConfig = toml::from_str("").unwrap();
1091        assert!(local.username.is_none());
1092    }
1093
1094    #[test]
1095    fn server_config_defaults() {
1096        let toml = r#"
1097[project]
1098name = "test"
1099
1100[tickets]
1101dir = "tickets"
1102"#;
1103        let config: Config = toml::from_str(toml).unwrap();
1104        assert_eq!(config.server.origin, "http://localhost:3000");
1105    }
1106
1107    #[test]
1108    fn server_config_custom_origin() {
1109        let toml = r#"
1110[project]
1111name = "test"
1112
1113[tickets]
1114dir = "tickets"
1115
1116[server]
1117origin = "https://apm.example.com"
1118"#;
1119        let config: Config = toml::from_str(toml).unwrap();
1120        assert_eq!(config.server.origin, "https://apm.example.com");
1121    }
1122
1123    #[test]
1124    fn git_host_config_parses() {
1125        let toml = r#"
1126[project]
1127name = "test"
1128
1129[tickets]
1130dir = "tickets"
1131
1132[git_host]
1133provider = "github"
1134repo = "owner/name"
1135"#;
1136        let config: Config = toml::from_str(toml).unwrap();
1137        assert_eq!(config.git_host.provider.as_deref(), Some("github"));
1138        assert_eq!(config.git_host.repo.as_deref(), Some("owner/name"));
1139    }
1140
1141    #[test]
1142    fn git_host_config_absent_defaults_none() {
1143        let toml = r#"
1144[project]
1145name = "test"
1146
1147[tickets]
1148dir = "tickets"
1149"#;
1150        let config: Config = toml::from_str(toml).unwrap();
1151        assert!(config.git_host.provider.is_none());
1152        assert!(config.git_host.repo.is_none());
1153    }
1154
1155    #[test]
1156    fn local_config_github_token_parses() {
1157        let toml = r#"github_token = "ghp_abc123""#;
1158        let local: LocalConfig = toml::from_str(toml).unwrap();
1159        assert_eq!(local.github_token.as_deref(), Some("ghp_abc123"));
1160    }
1161
1162    #[test]
1163    fn local_config_github_token_absent_defaults_none() {
1164        let local: LocalConfig = toml::from_str("").unwrap();
1165        assert!(local.github_token.is_none());
1166    }
1167
1168    #[test]
1169    fn tickets_archive_dir_parses() {
1170        let toml = r#"
1171[project]
1172name = "test"
1173
1174[tickets]
1175dir = "tickets"
1176archive_dir = "archive/tickets"
1177"#;
1178        let config: Config = toml::from_str(toml).unwrap();
1179        assert_eq!(
1180            config.tickets.archive_dir.as_deref(),
1181            Some(std::path::Path::new("archive/tickets"))
1182        );
1183    }
1184
1185    #[test]
1186    fn tickets_archive_dir_absent_defaults_none() {
1187        let toml = r#"
1188[project]
1189name = "test"
1190
1191[tickets]
1192dir = "tickets"
1193"#;
1194        let config: Config = toml::from_str(toml).unwrap();
1195        assert!(config.tickets.archive_dir.is_none());
1196    }
1197
1198    #[test]
1199    fn agents_max_workers_per_epic_defaults_to_one() {
1200        let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1201        let config: Config = toml::from_str(toml).unwrap();
1202        assert_eq!(config.agents.max_workers_per_epic, 1);
1203    }
1204
1205    #[test]
1206    fn blocked_epics_global_limit_one() {
1207        let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1208        let config: Config = toml::from_str(toml).unwrap();
1209        // limit=1, one active worker in epic A → epic A is blocked
1210        let active = vec![Some("epicA".to_string())];
1211        let blocked = config.blocked_epics(&active);
1212        assert!(blocked.contains(&"epicA".to_string()));
1213    }
1214
1215    #[test]
1216    fn blocked_epics_global_limit_two() {
1217        let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_per_epic = 2\n";
1218        let config: Config = toml::from_str(toml).unwrap();
1219        // limit=2, one active worker in epic A → epic A is NOT blocked
1220        let active = vec![Some("epicA".to_string())];
1221        let blocked = config.blocked_epics(&active);
1222        assert!(!blocked.contains(&"epicA".to_string()));
1223    }
1224
1225    #[test]
1226    fn default_branch_not_blocked_when_no_active_non_epic_workers() {
1227        let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1228        let config: Config = toml::from_str(base).unwrap();
1229        assert_eq!(config.agents.max_workers_on_default, 1);
1230        // limit=1, 0 active non-epic workers → not blocked
1231        let active: Vec<Option<String>> = vec![];
1232        assert!(!config.is_default_branch_blocked(&active));
1233    }
1234
1235    #[test]
1236    fn default_branch_blocked_when_one_active_non_epic_worker_and_limit_one() {
1237        let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1238        let config: Config = toml::from_str(base).unwrap();
1239        // limit=1, 1 active non-epic worker → blocked
1240        let active = vec![None];
1241        assert!(config.is_default_branch_blocked(&active));
1242    }
1243
1244    #[test]
1245    fn default_branch_not_blocked_when_limit_zero() {
1246        let toml = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[agents]\nmax_workers_on_default = 0\n";
1247        let config: Config = toml::from_str(toml).unwrap();
1248        // limit=0, any number of active non-epic workers → not blocked
1249        let active = vec![None, None, None];
1250        assert!(!config.is_default_branch_blocked(&active));
1251    }
1252
1253    #[test]
1254    fn default_branch_not_blocked_when_all_workers_are_epic_linked() {
1255        let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n";
1256        let config: Config = toml::from_str(base).unwrap();
1257        // limit=1, all active workers are epic-linked → not blocked
1258        let active = vec![Some("epicA".to_string()), Some("epicB".to_string())];
1259        assert!(!config.is_default_branch_blocked(&active));
1260    }
1261
1262    #[test]
1263    fn prefers_apm_agent_name() {
1264        let _g = ENV_LOCK.lock().unwrap();
1265        std::env::set_var("APM_AGENT_NAME", "explicit-agent");
1266        assert_eq!(resolve_caller_name(), "explicit-agent");
1267        std::env::remove_var("APM_AGENT_NAME");
1268    }
1269
1270    #[test]
1271    fn falls_back_to_user() {
1272        let _g = ENV_LOCK.lock().unwrap();
1273        std::env::remove_var("APM_AGENT_NAME");
1274        std::env::set_var("USER", "unix-user");
1275        std::env::remove_var("USERNAME");
1276        assert_eq!(resolve_caller_name(), "unix-user");
1277        std::env::remove_var("USER");
1278    }
1279
1280    #[test]
1281    fn defaults_to_apm() {
1282        let _g = ENV_LOCK.lock().unwrap();
1283        std::env::remove_var("APM_AGENT_NAME");
1284        std::env::remove_var("USER");
1285        std::env::remove_var("USERNAME");
1286        assert_eq!(resolve_caller_name(), "apm");
1287    }
1288}