Skip to main content

elph_core/utils/
path.rs

1//! Generic home, data, and project directory resolution.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6
7/// Environment and naming knobs for an application layout.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct PathResolver {
10    /// Override env var for the config/home directory (e.g. `ELPH_HOME`).
11    pub home_env: &'static str,
12    /// Override env var for the data directory (e.g. `ELPH_DATA_DIR`).
13    pub data_env: &'static str,
14    /// Override env var for the project directory (e.g. `ELPH_PROJECT_DIR`).
15    pub project_env: &'static str,
16    /// Config directory name under `$HOME` (e.g. `.elph`).
17    pub config_dir_name: &'static str,
18    /// Data directory name under XDG data home (e.g. `elph`).
19    pub data_dir_name: &'static str,
20}
21
22/// Resolved config, data, and project directories.
23#[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
81/// Common config/data path helpers shared by Elph applications.
82pub 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 metadata_db_path(&self) -> PathBuf {
139        self.data_dir().join("metadata.db")
140    }
141
142    fn version_path(&self) -> PathBuf {
143        self.data_dir().join("version.json")
144    }
145
146    fn bundled_content_dirs(&self) -> [PathBuf; 4] {
147        let bundled = self.bundled_dir();
148        [
149            bundled.join("agents"),
150            bundled.join("personas"),
151            bundled.join("skills"),
152            bundled.join("user-guide"),
153        ]
154    }
155
156    fn standard_required_dirs(&self) -> Vec<PathBuf> {
157        let mut dirs = self.bundled_content_dirs().into_iter().collect::<Vec<_>>();
158        dirs.extend([
159            self.prompts_dir(),
160            self.providers_dir(),
161            self.sessions_dir(),
162            self.skills_dir(),
163            self.worktrees_dir(),
164            self.attachments_dir(),
165            self.downloads_dir(),
166            self.logs_dir(),
167            self.vendor_dir(),
168        ]);
169        dirs
170    }
171}
172
173fn env_path(name: &str) -> Option<PathBuf> {
174    let value = std::env::var(name).ok()?;
175    let trimmed = value.trim();
176    if trimmed.is_empty() {
177        None
178    } else {
179        Some(PathBuf::from(trimmed))
180    }
181}
182
183fn user_home() -> Result<PathBuf> {
184    std::env::var_os("HOME").map(PathBuf::from).context("HOME is not set")
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const TEST_RESOLVER: PathResolver = PathResolver {
192        home_env: "TEST_AGENT_HOME",
193        data_env: "TEST_AGENT_DATA",
194        project_env: "TEST_AGENT_PROJECT",
195        config_dir_name: ".test-agent",
196        data_dir_name: "test-agent",
197    };
198
199    struct TestPaths {
200        config_dir: PathBuf,
201        data_dir: PathBuf,
202    }
203
204    impl AppPaths for TestPaths {
205        fn config_dir(&self) -> &PathBuf {
206            &self.config_dir
207        }
208
209        fn data_dir(&self) -> &PathBuf {
210            &self.data_dir
211        }
212    }
213
214    #[test]
215    fn resolves_from_explicit_dirs() {
216        let paths = ResolvedPaths::from_dirs(PathBuf::from("/cfg"), PathBuf::from("/data"), PathBuf::from("/repo"));
217
218        assert_eq!(paths.config_dir, PathBuf::from("/cfg"));
219        assert_eq!(paths.data_dir, PathBuf::from("/data"));
220        assert_eq!(paths.project_dir, PathBuf::from("/repo"));
221    }
222
223    #[test]
224    fn resolver_exposes_static_names() {
225        assert_eq!(TEST_RESOLVER.config_dir_name, ".test-agent");
226        assert_eq!(TEST_RESOLVER.data_dir_name, "test-agent");
227    }
228
229    #[test]
230    fn app_paths_builds_expected_file_paths() {
231        let paths = TestPaths {
232            config_dir: PathBuf::from("/cfg"),
233            data_dir: PathBuf::from("/data"),
234        };
235
236        assert_eq!(paths.metadata_db_path(), PathBuf::from("/data/metadata.db"));
237        assert_eq!(
238            paths.bundled_manifest_path(),
239            PathBuf::from("/cfg/bundled/manifest.json")
240        );
241        assert_eq!(paths.standard_required_dirs().len(), 13);
242    }
243}