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