1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct PathResolver {
10 pub home_env: &'static str,
12 pub data_env: &'static str,
14 pub project_env: &'static str,
16 pub config_dir_name: &'static str,
18 pub data_dir_name: &'static str,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ResolvedPaths {
25 pub config_dir: PathBuf,
26 pub data_dir: PathBuf,
27 pub project_dir: PathBuf,
28}
29
30impl PathResolver {
31 pub fn resolve(&self) -> Result<ResolvedPaths> {
32 Ok(ResolvedPaths::from_dirs(
33 self.config_dir()?,
34 self.data_dir()?,
35 self.project_dir()?,
36 ))
37 }
38
39 fn config_dir(&self) -> Result<PathBuf> {
40 if let Some(path) = env_path(self.home_env) {
41 return Ok(path);
42 }
43
44 Ok(user_home()?.join(self.config_dir_name))
45 }
46
47 fn data_dir(&self) -> Result<PathBuf> {
48 if let Some(path) = env_path(self.data_env) {
49 return Ok(path);
50 }
51
52 if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
53 let trimmed = xdg.trim();
54 if !trimmed.is_empty() {
55 return Ok(Path::new(trimmed).join(self.data_dir_name));
56 }
57 }
58
59 Ok(user_home()?.join(".local").join("share").join(self.data_dir_name))
60 }
61
62 fn project_dir(&self) -> Result<PathBuf> {
63 if let Some(path) = env_path(self.project_env) {
64 return Ok(path);
65 }
66
67 std::env::current_dir().map_err(Into::into)
68 }
69}
70
71impl ResolvedPaths {
72 pub fn from_dirs(config_dir: PathBuf, data_dir: PathBuf, project_dir: PathBuf) -> Self {
73 Self {
74 config_dir,
75 data_dir,
76 project_dir,
77 }
78 }
79}
80
81pub trait AppPaths {
83 fn config_dir(&self) -> &PathBuf;
84 fn data_dir(&self) -> &PathBuf;
85
86 fn settings_path(&self) -> PathBuf {
87 self.config_dir().join("settings.json")
88 }
89
90 fn trust_path(&self) -> PathBuf {
91 self.config_dir().join("trust.json")
92 }
93
94 fn bundled_dir(&self) -> PathBuf {
95 self.config_dir().join("bundled")
96 }
97
98 fn bundled_manifest_path(&self) -> PathBuf {
99 self.bundled_dir().join("manifest.json")
100 }
101
102 fn prompts_dir(&self) -> PathBuf {
103 self.config_dir().join("prompts")
104 }
105
106 fn providers_dir(&self) -> PathBuf {
107 self.config_dir().join("providers")
108 }
109
110 fn sessions_dir(&self) -> PathBuf {
111 self.config_dir().join("sessions")
112 }
113
114 fn skills_dir(&self) -> PathBuf {
115 self.config_dir().join("skills")
116 }
117
118 fn worktrees_dir(&self) -> PathBuf {
119 self.config_dir().join("worktrees")
120 }
121
122 fn attachments_dir(&self) -> PathBuf {
123 self.data_dir().join("attachments")
124 }
125
126 fn downloads_dir(&self) -> PathBuf {
127 self.data_dir().join("downloads")
128 }
129
130 fn logs_dir(&self) -> PathBuf {
131 self.data_dir().join("logs")
132 }
133
134 fn vendor_dir(&self) -> PathBuf {
135 self.data_dir().join("vendor")
136 }
137
138 fn models_dir(&self) -> PathBuf {
140 self.data_dir().join("models")
141 }
142
143 fn metadata_db_path(&self) -> PathBuf {
144 self.data_dir().join("metadata.db")
145 }
146
147 fn version_path(&self) -> PathBuf {
148 self.data_dir().join("version.json")
149 }
150
151 fn bundled_content_dirs(&self) -> [PathBuf; 4] {
152 let bundled = self.bundled_dir();
153 [
154 bundled.join("agents"),
155 bundled.join("personas"),
156 bundled.join("skills"),
157 bundled.join("user-guide"),
158 ]
159 }
160
161 fn standard_required_dirs(&self) -> Vec<PathBuf> {
162 let mut dirs = self.bundled_content_dirs().into_iter().collect::<Vec<_>>();
163 dirs.extend([
164 self.prompts_dir(),
165 self.providers_dir(),
166 self.sessions_dir(),
167 self.skills_dir(),
168 self.worktrees_dir(),
169 self.attachments_dir(),
170 self.downloads_dir(),
171 self.logs_dir(),
172 self.vendor_dir(),
173 self.models_dir(),
174 ]);
175 dirs
176 }
177}
178
179fn env_path(name: &str) -> Option<PathBuf> {
180 let value = std::env::var(name).ok()?;
181 let trimmed = value.trim();
182 if trimmed.is_empty() {
183 None
184 } else {
185 Some(PathBuf::from(trimmed))
186 }
187}
188
189fn user_home() -> Result<PathBuf> {
190 std::env::var_os("HOME").map(PathBuf::from).context("HOME is not set")
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 const TEST_RESOLVER: PathResolver = PathResolver {
198 home_env: "TEST_AGENT_HOME",
199 data_env: "TEST_AGENT_DATA",
200 project_env: "TEST_AGENT_PROJECT",
201 config_dir_name: ".test-agent",
202 data_dir_name: "test-agent",
203 };
204
205 struct TestPaths {
206 config_dir: PathBuf,
207 data_dir: PathBuf,
208 }
209
210 impl AppPaths for TestPaths {
211 fn config_dir(&self) -> &PathBuf {
212 &self.config_dir
213 }
214
215 fn data_dir(&self) -> &PathBuf {
216 &self.data_dir
217 }
218 }
219
220 #[test]
221 fn resolves_from_explicit_dirs() {
222 let paths = ResolvedPaths::from_dirs(PathBuf::from("/cfg"), PathBuf::from("/data"), PathBuf::from("/repo"));
223
224 assert_eq!(paths.config_dir, PathBuf::from("/cfg"));
225 assert_eq!(paths.data_dir, PathBuf::from("/data"));
226 assert_eq!(paths.project_dir, PathBuf::from("/repo"));
227 }
228
229 #[test]
230 fn resolver_exposes_static_names() {
231 assert_eq!(TEST_RESOLVER.config_dir_name, ".test-agent");
232 assert_eq!(TEST_RESOLVER.data_dir_name, "test-agent");
233 }
234
235 #[test]
236 fn app_paths_builds_expected_file_paths() {
237 let paths = TestPaths {
238 config_dir: PathBuf::from("/cfg"),
239 data_dir: PathBuf::from("/data"),
240 };
241
242 assert_eq!(paths.metadata_db_path(), PathBuf::from("/data/metadata.db"));
243 assert_eq!(
244 paths.bundled_manifest_path(),
245 PathBuf::from("/cfg/bundled/manifest.json")
246 );
247 assert_eq!(paths.standard_required_dirs().len(), 14);
248 }
249}