Skip to main content

apm_core/
config.rs

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