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 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(¤t) == 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;