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