Skip to main content

agent_berth/
paths.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context as _, Result};
5
6const APP: &str = "agent-berth";
7const PIPE: &str = "agent-berth";
8
9#[derive(Debug, Clone)]
10pub struct Context {
11    pub home: PathBuf,
12    pub xdg_config_home: PathBuf,
13    pub xdg_runtime_dir: PathBuf,
14    pub state_dir: PathBuf,
15    pub claude_config_dir: PathBuf,
16    pub codex_home: PathBuf,
17    pub grok_home: PathBuf,
18    pub pi_dir: PathBuf,
19    pub appdata: Option<PathBuf>,
20    pub berth_bin: PathBuf,
21    pub socket_override: Option<String>,
22}
23
24impl Context {
25    pub fn from_env() -> Result<Self> {
26        let home = home_dir()?;
27        let xdg_config_home = env_path("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config"));
28        let xdg_runtime_dir = env_path("XDG_RUNTIME_DIR").unwrap_or_else(|| {
29            #[cfg(windows)]
30            {
31                env_path("TEMP").unwrap_or_else(|| home.join("AppData/Local/Temp"))
32            }
33            #[cfg(not(windows))]
34            {
35                std::env::temp_dir()
36            }
37        });
38        let state_dir = env_path("XDG_STATE_HOME")
39            .map(|p| p.join(APP))
40            .or_else(|| env_path("LOCALAPPDATA").map(|p| p.join(APP)))
41            .unwrap_or_else(|| home.join(".local/state").join(APP));
42        Ok(Self {
43            claude_config_dir: env_path("CLAUDE_CONFIG_DIR")
44                .unwrap_or_else(|| home.join(".claude")),
45            codex_home: env_path("CODEX_HOME").unwrap_or_else(|| home.join(".codex")),
46            grok_home: env_path("GROK_HOME").unwrap_or_else(|| home.join(".grok")),
47            pi_dir: env_path("PI_CODING_AGENT_DIR")
48                .unwrap_or_else(|| home.join(".pi").join("agent")),
49            appdata: env_path("APPDATA"),
50            berth_bin: env::current_exe().unwrap_or_else(|_| PathBuf::from(APP)),
51            socket_override: env::var("AGENT_BERTH_SOCK").ok(),
52            home,
53            xdg_config_home,
54            xdg_runtime_dir,
55            state_dir,
56        })
57    }
58
59    pub fn db_path(&self) -> PathBuf {
60        self.state_dir.join("state.redb")
61    }
62
63    pub fn json_legacy_path(&self) -> PathBuf {
64        self.state_dir.join("state.json")
65    }
66
67    pub fn pid_path(&self) -> PathBuf {
68        self.state_dir.join("server.pid")
69    }
70
71    pub fn socket_path(&self) -> PathBuf {
72        if let Some(value) = &self.socket_override {
73            return PathBuf::from(value);
74        }
75        self.xdg_runtime_dir.join("agent-berth.sock")
76    }
77
78    pub fn pipe_name(&self) -> String {
79        self.socket_override
80            .clone()
81            .unwrap_or_else(|| PIPE.to_string())
82    }
83
84    pub fn endpoint_display(&self) -> String {
85        #[cfg(windows)]
86        {
87            format!(r"\\.\pipe\{}", self.pipe_name())
88        }
89        #[cfg(unix)]
90        {
91            self.socket_path().display().to_string()
92        }
93    }
94
95    pub fn opencode_plugin_dir(&self) -> PathBuf {
96        self.xdg_config_home.join("opencode").join("plugins")
97    }
98
99    pub fn systemd_unit_path(&self) -> PathBuf {
100        self.xdg_config_home
101            .join("systemd")
102            .join("user")
103            .join("agent-berth.service")
104    }
105
106    pub fn quote_bin(&self) -> String {
107        quote_path(&self.berth_bin)
108    }
109
110    pub fn notify_command(&self, provider: &str) -> String {
111        format!("{} notify --provider {provider}", self.quote_bin())
112    }
113}
114
115pub fn home_dir() -> Result<PathBuf> {
116    env_path("HOME")
117        .or_else(|| env_path("USERPROFILE"))
118        .context("HOME or USERPROFILE is not set")
119}
120
121pub fn env_path(key: &str) -> Option<PathBuf> {
122    env::var_os(key)
123        .filter(|v| !v.is_empty())
124        .map(PathBuf::from)
125}
126
127pub fn quote_path(path: &Path) -> String {
128    let text = path.display().to_string();
129    // Hook commands run under bash (claude), cmd (grok), or codex's own
130    // command splitting. An unquoted forward-slash path works in all of them:
131    // bash mangles unquoted backslashes, and codex cannot parse quoted paths.
132    // Paths containing spaces are not supported on Windows.
133    if cfg!(windows) {
134        return text.replace('\\', "/");
135    }
136    if text.chars().any(|ch| ch.is_whitespace() || ch == '"') {
137        format!("\"{}\"", text.replace('"', "\\\""))
138    } else {
139        text
140    }
141}
142
143pub fn is_current_dir(cwd: &str) -> bool {
144    if cwd.is_empty() {
145        return false;
146    }
147    let Ok(current) = env::current_dir() else {
148        return false;
149    };
150    normalize(&current) == normalize(Path::new(cwd))
151}
152
153fn normalize(path: &Path) -> PathBuf {
154    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
155}
156
157pub fn on_path(name: &str) -> bool {
158    let Some(path) = env::var_os("PATH") else {
159        return false;
160    };
161    let exts: &[&str] = if cfg!(windows) {
162        &["", ".exe", ".cmd", ".bat", ".com"]
163    } else {
164        &[""]
165    };
166    env::split_paths(&path).any(|dir| {
167        exts.iter().any(|ext| {
168            let candidate = dir.join(format!("{name}{ext}"));
169            candidate.is_file()
170        })
171    })
172}
173
174#[cfg(test)]
175impl Context {
176    pub fn for_test(root: &Path, bin: &Path) -> Self {
177        Self {
178            home: root.to_path_buf(),
179            xdg_config_home: root.join("config"),
180            xdg_runtime_dir: root.join("run"),
181            state_dir: root.join("state"),
182            claude_config_dir: root.join("claude"),
183            codex_home: root.join("codex"),
184            grok_home: root.join("grok"),
185            pi_dir: root.join("pi"),
186            appdata: Some(root.join("appdata")),
187            berth_bin: bin.to_path_buf(),
188            socket_override: Some(
189                #[cfg(windows)]
190                format!("agent-berth-test-{}", std::process::id()),
191                #[cfg(unix)]
192                root.join("run")
193                    .join("agent-berth.sock")
194                    .display()
195                    .to_string(),
196            ),
197        }
198    }
199}
200
201#[cfg(test)]
202#[path = "paths_tests.rs"]
203mod tests;