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