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