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
121/// Short label for a working directory, normalizing git worktree layouts to
122/// `name` or `name (worktree)` so a repo and its worktrees sort together.
123pub fn worktree_label(path: &str) -> String {
124    worktree_label_with_home(path, home_dir().ok().as_deref())
125}
126
127pub(crate) fn worktree_label_with_home(path: &str, home: Option<&Path>) -> String {
128    let normalized = path.replace('\\', "/");
129    let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
130    let Some(&dir) = components.last() else {
131        return if normalized.contains('/') { "/" } else { "-" }.into();
132    };
133    let Some(wt) = components[..components.len() - 1]
134        .iter()
135        .rposition(|c| is_worktrees_dir(c))
136    else {
137        return dir.into();
138    };
139    let name = components[wt];
140    if name.len() > ".worktrees".len() && name.ends_with(".worktrees") {
141        let base = &name[..name.len() - ".worktrees".len()];
142        return format!("{base} ({dir})");
143    }
144    if is_central_worktrees(&components, wt, home) {
145        // $HOME/.something/worktrees/<group>/<dir>
146        return if components.len() - 2 > wt {
147            format!("{dir} ({})", components[components.len() - 2])
148        } else {
149            dir.into()
150        };
151    }
152    // Worktree folder nested inside the main checkout, e.g. .claude/worktrees.
153    match components[..wt].iter().rev().find(|c| !c.starts_with('.')) {
154        Some(main) => format!("{main} ({dir})"),
155        None => dir.into(),
156    }
157}
158
159fn is_worktrees_dir(component: &str) -> bool {
160    component == "worktrees" || component.ends_with(".worktrees")
161}
162
163fn is_central_worktrees(components: &[&str], wt: usize, home: Option<&Path>) -> bool {
164    let Some(home) = home else { return false };
165    let home = home.display().to_string().replace('\\', "/");
166    let home_components: Vec<&str> = home.split('/').filter(|c| !c.is_empty()).collect();
167    wt > home_components.len()
168        && components.len() > home_components.len()
169        && components[..home_components.len()] == home_components[..]
170        && components[home_components.len()].starts_with('.')
171}
172
173pub fn env_path(key: &str) -> Option<PathBuf> {
174    env::var_os(key)
175        .filter(|v| !v.is_empty())
176        .map(PathBuf::from)
177}
178
179pub fn quote_path(path: &Path) -> String {
180    let text = path.display().to_string();
181    // Hook commands run under bash (claude), cmd (grok), or codex's own
182    // command splitting. An unquoted forward-slash path works in all of them:
183    // bash mangles unquoted backslashes, and codex cannot parse quoted paths.
184    // Paths containing spaces are not supported on Windows.
185    if cfg!(windows) {
186        return text.replace('\\', "/");
187    }
188    if text.chars().any(|ch| ch.is_whitespace() || ch == '"') {
189        format!("\"{}\"", text.replace('"', "\\\""))
190    } else {
191        text
192    }
193}
194
195pub fn is_current_dir(cwd: &str) -> bool {
196    if cwd.is_empty() {
197        return false;
198    }
199    let Ok(current) = env::current_dir() else {
200        return false;
201    };
202    normalize(&current) == normalize(Path::new(cwd))
203}
204
205fn normalize(path: &Path) -> PathBuf {
206    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
207}
208
209pub fn on_path(name: &str) -> bool {
210    let Some(path) = env::var_os("PATH") else {
211        return false;
212    };
213    let exts: &[&str] = if cfg!(windows) {
214        &["", ".exe", ".cmd", ".bat", ".com"]
215    } else {
216        &[""]
217    };
218    env::split_paths(&path).any(|dir| {
219        exts.iter().any(|ext| {
220            let candidate = dir.join(format!("{name}{ext}"));
221            candidate.is_file()
222        })
223    })
224}
225
226#[cfg(test)]
227impl Context {
228    pub fn for_test(root: &Path, bin: &Path) -> Self {
229        Self {
230            home: root.to_path_buf(),
231            xdg_config_home: root.join("config"),
232            xdg_runtime_dir: root.join("run"),
233            state_dir: root.join("state"),
234            claude_config_dir: root.join("claude"),
235            codex_home: root.join("codex"),
236            grok_home: root.join("grok"),
237            pi_dir: root.join("pi"),
238            appdata: Some(root.join("appdata")),
239            berth_bin: bin.to_path_buf(),
240            socket_override: Some(
241                #[cfg(windows)]
242                format!("agent-berth-test-{}", std::process::id()),
243                #[cfg(unix)]
244                root.join("run")
245                    .join("agent-berth.sock")
246                    .display()
247                    .to_string(),
248            ),
249        }
250    }
251}
252
253#[cfg(test)]
254#[path = "paths_tests.rs"]
255mod tests;