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