Skip to main content

apm_core/wrapper/
mod.rs

1pub mod builtin;
2pub mod custom;
3pub mod path_guard;
4pub mod hook_config;
5pub use builtin::ClaudeWrapper;
6pub use custom::{WrapperKind, Manifest};
7pub use path_guard::PathGuard;
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12pub const CONTRACT_VERSION: u32 = 1;
13
14pub struct WrapperContext {
15    pub worker_name: String,
16    pub ticket_id: String,
17    pub ticket_branch: String,
18    pub worktree_path: PathBuf,
19    pub system_prompt_file: PathBuf,
20    pub user_message_file: PathBuf,
21    pub skip_permissions: bool,
22    pub profile: String,
23    pub role_prefix: Option<String>,
24    pub options: HashMap<String, String>,
25    pub model: Option<String>,
26    pub log_path: PathBuf,
27    pub container: Option<String>,
28    pub extra_env: HashMap<String, String>,
29    pub root: PathBuf,
30    pub keychain: HashMap<String, String>,
31    pub current_state: String,
32    /// Override for the wrapper-specific binary (e.g. for ClaudeWrapper, the
33    /// claude binary path). Honoured by built-ins that shell out to a fixed
34    /// binary; legacy `[workers].command` flows in here.
35    pub command: Option<String>,
36}
37
38pub trait Wrapper {
39    fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result<std::process::Child>;
40}
41
42pub fn resolve_builtin(name: &str) -> Option<Box<dyn Wrapper>> {
43    match name {
44        "claude" => Some(Box::new(builtin::ClaudeWrapper)),
45        "mock-happy" => Some(Box::new(builtin::MockHappyWrapper)),
46        "mock-sad" => Some(Box::new(builtin::MockSadWrapper)),
47        "mock-random" => Some(Box::new(builtin::MockRandomWrapper)),
48        "debug" => Some(Box::new(builtin::DebugWrapper)),
49        _ => None,
50    }
51}
52
53pub fn list_builtin_names() -> &'static [&'static str] {
54    &["claude", "mock-happy", "mock-sad", "mock-random", "debug"]
55}
56
57pub fn resolve_wrapper(root: &Path, name: &str) -> anyhow::Result<Option<WrapperKind>> {
58    if let Some(script_path) = custom::find_script(root, name) {
59        let manifest = custom::parse_manifest(root, name)?;
60        return Ok(Some(WrapperKind::Custom { script_path, manifest }));
61    }
62    if resolve_builtin(name).is_some() {
63        return Ok(Some(WrapperKind::Builtin(name.to_owned())));
64    }
65    Ok(None)
66}
67
68pub fn write_temp_file(prefix: &str, content: &str) -> anyhow::Result<PathBuf> {
69    let path = std::env::temp_dir().join(format!("apm-{prefix}-{:04x}.txt", rand_u16()));
70    std::fs::write(&path, content)?;
71    Ok(path)
72}
73
74pub(crate) fn rand_u16() -> u16 {
75    use std::time::{SystemTime, UNIX_EPOCH};
76    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
77}
78
79pub(crate) fn resolve_apm_cli_bin() -> String {
80    std::env::current_exe()
81        .and_then(|p| p.canonicalize())
82        .ok()
83        .map(|exe| resolve_cli_bin_from_exe(&exe))
84        .map(|p| p.to_string_lossy().into_owned())
85        .unwrap_or_default()
86}
87
88fn resolve_cli_bin_from_exe(exe: &std::path::Path) -> std::path::PathBuf {
89    let candidate = exe
90        .parent()
91        .map(|dir| dir.join("apm"))
92        .filter(|p| p.is_file() && *p != exe);
93    candidate.unwrap_or_else(|| exe.to_path_buf())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    // --- resolve_cli_bin_from_exe ---
101
102    #[test]
103    fn resolve_cli_bin_from_exe_uses_sibling_apm_when_running_as_apm_server() {
104        use std::os::unix::fs::PermissionsExt;
105        let dir = tempfile::tempdir().unwrap();
106        let apm_server = dir.path().join("apm-server");
107        std::fs::write(&apm_server, "#!/bin/sh").unwrap();
108        std::fs::set_permissions(&apm_server, std::fs::Permissions::from_mode(0o755)).unwrap();
109        let apm = dir.path().join("apm");
110        std::fs::write(&apm, "#!/bin/sh").unwrap();
111        std::fs::set_permissions(&apm, std::fs::Permissions::from_mode(0o755)).unwrap();
112        let result = resolve_cli_bin_from_exe(&apm_server);
113        assert_eq!(
114            result.file_stem().and_then(|s| s.to_str()),
115            Some("apm"),
116            "APM_BIN must point to the apm CLI binary, not apm-server: {result:?}"
117        );
118    }
119
120    #[test]
121    fn resolve_cli_bin_from_exe_no_change_when_already_apm() {
122        use std::os::unix::fs::PermissionsExt;
123        let dir = tempfile::tempdir().unwrap();
124        let apm = dir.path().join("apm");
125        std::fs::write(&apm, "#!/bin/sh").unwrap();
126        std::fs::set_permissions(&apm, std::fs::Permissions::from_mode(0o755)).unwrap();
127        let result = resolve_cli_bin_from_exe(&apm);
128        assert_eq!(result, apm);
129    }
130
131    #[test]
132    fn resolve_cli_bin_from_exe_falls_back_when_no_sibling_apm() {
133        let dir = tempfile::tempdir().unwrap();
134        let apm_server = dir.path().join("apm-server");
135        std::fs::write(&apm_server, "#!/bin/sh").unwrap();
136        // No sibling apm file — must fall back to the exe itself
137        let result = resolve_cli_bin_from_exe(&apm_server);
138        assert_eq!(result, apm_server);
139    }
140
141    #[test]
142    fn resolve_builtin_claude_returns_some() {
143        assert!(resolve_builtin("claude").is_some());
144    }
145
146    #[test]
147    fn resolve_builtin_unknown_returns_none() {
148        assert!(resolve_builtin("bogus").is_none());
149        assert!(resolve_builtin("").is_none());
150    }
151
152    #[test]
153    fn resolve_builtin_mock_happy_returns_some() {
154        assert!(resolve_builtin("mock-happy").is_some());
155    }
156
157    #[test]
158    fn resolve_builtin_mock_sad_returns_some() {
159        assert!(resolve_builtin("mock-sad").is_some());
160    }
161
162    #[test]
163    fn resolve_builtin_mock_random_returns_some() {
164        assert!(resolve_builtin("mock-random").is_some());
165    }
166
167    #[test]
168    fn resolve_builtin_debug_returns_some() {
169        assert!(resolve_builtin("debug").is_some());
170    }
171}