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 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 return if components.len() - 2 > wt {
147 format!("{dir} ({})", components[components.len() - 2])
148 } else {
149 dir.into()
150 };
151 }
152 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 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(¤t) == 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;