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