1use anyhow::{Context, Result};
23#[cfg(unix)]
24use libc::getuid;
25use std::path::{Path, PathBuf};
26
27use crate::env::{
28 LOCALGPT_CACHE_DIR, LOCALGPT_CONFIG_DIR, LOCALGPT_DATA_DIR, LOCALGPT_PROFILE,
29 LOCALGPT_STATE_DIR, LOCALGPT_WORKSPACE,
30};
31
32pub const DEFAULT_CONFIG_DIR_STR: &str = "~/.config/localgpt";
34pub const DEFAULT_DATA_DIR_STR: &str = "~/.local/share/localgpt";
35pub const DEFAULT_STATE_DIR_STR: &str = "~/.local/state/localgpt";
36pub const DEFAULT_CACHE_DIR_STR: &str = "~/.cache/localgpt";
37
38#[derive(Debug, Clone)]
43pub struct Paths {
44 pub config_dir: PathBuf,
46
47 pub data_dir: PathBuf,
49
50 pub workspace: PathBuf,
53
54 pub state_dir: PathBuf,
56
57 pub cache_dir: PathBuf,
59
60 pub runtime_dir: Option<PathBuf>,
63}
64
65impl Paths {
66 pub fn resolve() -> Result<Self> {
68 Self::resolve_with_env(|key| std::env::var(key))
69 }
70
71 pub fn resolve_with_env<F>(env_fn: F) -> Result<Self>
73 where
74 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
75 {
76 use etcetera::BaseStrategy;
77
78 let strategy = etcetera::choose_base_strategy()
79 .map_err(|e| anyhow::anyhow!("Failed to determine base directories: {}", e))?;
80
81 let suffix = profile_suffix(&env_fn);
83
84 let config_dir = env_or(&env_fn, LOCALGPT_CONFIG_DIR, || {
85 strategy.config_dir().join(format!("localgpt{}", suffix))
86 });
87
88 let data_dir = env_or(&env_fn, LOCALGPT_DATA_DIR, || {
89 strategy.data_dir().join(format!("localgpt{}", suffix))
90 });
91
92 let state_dir = env_or(&env_fn, LOCALGPT_STATE_DIR, || {
93 let base_state = strategy.state_dir().unwrap_or_else(|| strategy.data_dir());
96 base_state.join(format!("localgpt{}", suffix))
97 });
98
99 let cache_dir = env_or(&env_fn, LOCALGPT_CACHE_DIR, || {
100 strategy.cache_dir().join(format!("localgpt{}", suffix))
101 });
102
103 let workspace = resolve_workspace(&env_fn, &data_dir);
105
106 let runtime_dir = resolve_runtime_dir(&env_fn, &suffix);
108
109 Ok(Self {
110 config_dir,
111 data_dir,
112 workspace,
113 state_dir,
114 cache_dir,
115 runtime_dir,
116 })
117 }
118
119 pub fn config_file(&self) -> PathBuf {
123 self.config_dir.join("config.toml")
124 }
125
126 pub fn device_key(&self) -> PathBuf {
128 self.data_dir.join("localgpt.device.key")
129 }
130
131 pub fn audit_log(&self) -> PathBuf {
133 self.state_dir.join("localgpt.audit.jsonl")
134 }
135
136 pub fn last_heartbeat(&self) -> PathBuf {
137 self.state_dir.join("last_heartbeat")
138 }
139
140 pub fn search_index(&self, agent_id: &str) -> PathBuf {
142 self.cache_dir
143 .join("memory")
144 .join(format!("{}.sqlite", agent_id))
145 }
146
147 pub fn sessions_dir(&self, agent_id: &str) -> PathBuf {
149 self.state_dir
150 .join("agents")
151 .join(agent_id)
152 .join("sessions")
153 }
154
155 pub fn logs_dir(&self) -> PathBuf {
157 self.state_dir.join("logs")
158 }
159
160 pub fn locks_dir(&self) -> PathBuf {
162 self.runtime_dir
163 .as_ref()
164 .unwrap_or(&self.state_dir)
165 .join("locks")
166 }
167
168 pub fn pid_file(&self) -> PathBuf {
170 self.locks_dir().join("daemon.pid")
171 }
172
173 pub fn workspace_lock(&self) -> PathBuf {
175 self.locks_dir().join("workspace.lock")
176 }
177
178 pub fn pairing_file(&self) -> PathBuf {
180 self.state_dir.join("telegram_paired_user.json")
181 }
182
183 pub fn bridge_socket_name(&self) -> String {
185 #[cfg(unix)]
186 {
187 self.locks_dir()
188 .join("bridge.sock")
189 .to_string_lossy()
190 .to_string()
191 }
192 #[cfg(windows)]
193 {
194 "localgpt-bridge".to_string()
195 }
196 }
197
198 pub fn managed_skills_dir(&self) -> PathBuf {
200 self.data_dir.join("skills")
201 }
202
203 pub fn embedding_cache_dir(&self) -> PathBuf {
205 self.cache_dir.join("embeddings")
206 }
207
208 pub fn from_root(root: impl Into<PathBuf>) -> Self {
213 let root = root.into();
214 Self {
215 config_dir: root.join("config"),
216 data_dir: root.join("data"),
217 workspace: root.join("data").join("workspace"),
218 state_dir: root.join("state"),
219 cache_dir: root.join("cache"),
220 runtime_dir: None,
221 }
222 }
223
224 pub fn ensure_dirs(&self) -> Result<()> {
226 let logs_dir = self.logs_dir();
227 let locks_dir = self.locks_dir();
228 let mut dirs = vec![
229 &self.config_dir,
230 &self.data_dir,
231 &self.state_dir,
232 &self.cache_dir,
233 &self.workspace,
234 &logs_dir,
235 &locks_dir,
236 ];
237
238 if let Some(ref runtime) = self.runtime_dir {
239 dirs.push(runtime);
240 }
241
242 for dir in dirs {
243 create_dir_with_mode(dir)?;
244 }
245
246 Ok(())
247 }
248}
249
250impl Default for Paths {
251 fn default() -> Self {
252 Self::resolve().unwrap_or_else(|_| {
253 let home = etcetera::home_dir().unwrap_or_else(|_| PathBuf::from("."));
255 Self {
256 config_dir: home.join(".config").join("localgpt"),
257 data_dir: home.join(".local").join("share").join("localgpt"),
258 workspace: home
259 .join(".local")
260 .join("share")
261 .join("localgpt")
262 .join("workspace"),
263 state_dir: home.join(".local").join("state").join("localgpt"),
264 cache_dir: home.join(".cache").join("localgpt"),
265 runtime_dir: None,
266 }
267 })
268 }
269}
270
271fn env_or<F>(env_fn: &F, var: &str, default: impl FnOnce() -> PathBuf) -> PathBuf
273where
274 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
275{
276 env_fn(var)
277 .ok()
278 .filter(|v| !v.is_empty())
279 .map(PathBuf::from)
280 .filter(|p| p.is_absolute()) .unwrap_or_else(default)
282}
283
284fn profile_suffix<F>(env_fn: &F) -> String
287where
288 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
289{
290 if let Ok(profile) = env_fn(LOCALGPT_PROFILE) {
291 let trimmed = profile.trim().to_lowercase();
292 if !trimmed.is_empty() && trimmed != "default" {
293 return format!("-{}", trimmed);
294 }
295 }
296 String::new()
297}
298
299fn resolve_workspace<F>(env_fn: &F, data_dir: &Path) -> PathBuf
301where
302 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
303{
304 if let Ok(ws) = env_fn(LOCALGPT_WORKSPACE) {
306 let trimmed = ws.trim();
307 if !trimmed.is_empty() {
308 let expanded = shellexpand::tilde(trimmed);
309 let path = PathBuf::from(expanded.to_string());
310 if path.is_absolute() {
311 return path;
312 }
313 }
314 }
315
316 data_dir.join("workspace")
318}
319
320fn resolve_runtime_dir<F>(env_fn: &F, profile_suffix: &str) -> Option<PathBuf>
322where
323 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
324{
325 if let Ok(dir) = env_fn("XDG_RUNTIME_DIR")
327 && !dir.is_empty()
328 {
329 let path = PathBuf::from(&dir);
330 if path.is_absolute() {
331 return Some(path.join(format!("localgpt{}", profile_suffix)));
332 }
333 }
334
335 #[cfg(unix)]
337 {
338 let uid = unsafe { getuid() };
341 let tmpdir = env_fn("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
342 Some(PathBuf::from(tmpdir).join(format!("localgpt{}-{}", profile_suffix, uid)))
343 }
344
345 #[cfg(not(unix))]
346 {
347 env_fn("TEMP").ok().map(|t| {
348 let user = env_fn("USERNAME").unwrap_or_else(|_| "user".into());
349 PathBuf::from(t).join(format!("localgpt{}-{}", profile_suffix, user))
350 })
351 }
352}
353
354fn create_dir_with_mode(path: &Path) -> Result<()> {
356 std::fs::create_dir_all(path)
357 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
358
359 #[cfg(all(unix, not(target_os = "ios"), not(target_os = "android")))]
360 {
361 use std::os::unix::fs::PermissionsExt;
362 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
364 }
365
366 Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use std::collections::HashMap;
373
374 fn make_env(
376 map: HashMap<&str, &str>,
377 ) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> {
378 move |key: &str| {
379 map.get(key)
380 .map(|v| v.to_string())
381 .ok_or(std::env::VarError::NotPresent)
382 }
383 }
384
385 #[test]
386 fn default_paths_are_xdg_compliant() {
387 let env: HashMap<&str, &str> = HashMap::new();
388 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
389
390 assert!(
392 paths.config_dir.ends_with("localgpt"),
393 "config_dir: {:?}",
394 paths.config_dir
395 );
396 assert!(
397 paths.data_dir.ends_with("localgpt"),
398 "data_dir: {:?}",
399 paths.data_dir
400 );
401 assert!(
402 paths.state_dir.ends_with("localgpt"),
403 "state_dir: {:?}",
404 paths.state_dir
405 );
406 assert!(
407 paths.cache_dir.ends_with("localgpt"),
408 "cache_dir: {:?}",
409 paths.cache_dir
410 );
411 assert!(paths.workspace.ends_with("workspace"));
412 }
413
414 #[test]
415 fn localgpt_env_vars_override_xdg() {
416 let mut env: HashMap<&str, &str> = HashMap::new();
417 env.insert(LOCALGPT_CONFIG_DIR, "/custom/config");
418 env.insert(LOCALGPT_DATA_DIR, "/custom/data");
419 env.insert(LOCALGPT_STATE_DIR, "/custom/state");
420 env.insert(LOCALGPT_CACHE_DIR, "/custom/cache");
421
422 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
423 assert_eq!(paths.config_dir, PathBuf::from("/custom/config"));
424 assert_eq!(paths.data_dir, PathBuf::from("/custom/data"));
425 assert_eq!(paths.state_dir, PathBuf::from("/custom/state"));
426 assert_eq!(paths.cache_dir, PathBuf::from("/custom/cache"));
427 }
428
429 #[test]
430 fn relative_paths_are_ignored() {
431 let mut env: HashMap<&str, &str> = HashMap::new();
432 env.insert(LOCALGPT_CONFIG_DIR, "relative/path");
433
434 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
435 assert!(paths.config_dir.is_absolute());
437 assert_ne!(paths.config_dir, PathBuf::from("relative/path"));
438 }
439
440 #[test]
441 fn workspace_override_independent_of_data_dir() {
442 let mut env: HashMap<&str, &str> = HashMap::new();
443 env.insert(LOCALGPT_WORKSPACE, "/projects/my-workspace");
444
445 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
446 assert_eq!(paths.workspace, PathBuf::from("/projects/my-workspace"));
447 assert!(paths.data_dir.ends_with("localgpt"));
449 assert!(!paths.data_dir.to_string_lossy().contains("my-workspace"));
450 }
451
452 #[test]
453 fn profile_suffixes_all_directories() {
454 let mut env: HashMap<&str, &str> = HashMap::new();
455 env.insert(LOCALGPT_PROFILE, "work");
456
457 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
458
459 assert!(
461 paths.config_dir.ends_with("localgpt-work"),
462 "config_dir: {:?}",
463 paths.config_dir
464 );
465 assert!(
466 paths.data_dir.ends_with("localgpt-work"),
467 "data_dir: {:?}",
468 paths.data_dir
469 );
470 assert!(
471 paths.state_dir.ends_with("localgpt-work"),
472 "state_dir: {:?}",
473 paths.state_dir
474 );
475 assert!(
476 paths.cache_dir.ends_with("localgpt-work"),
477 "cache_dir: {:?}",
478 paths.cache_dir
479 );
480 assert!(
482 paths.workspace.ends_with("workspace"),
483 "workspace: {:?}",
484 paths.workspace
485 );
486 assert!(
487 paths.workspace.to_string_lossy().contains("localgpt-work"),
488 "workspace should be under localgpt-work: {:?}",
489 paths.workspace
490 );
491 }
492
493 #[test]
494 fn profile_default_no_suffix() {
495 let mut env: HashMap<&str, &str> = HashMap::new();
496 env.insert(LOCALGPT_PROFILE, "default");
497
498 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
499
500 assert!(paths.config_dir.ends_with("localgpt"));
502 assert!(paths.data_dir.ends_with("localgpt"));
503 assert!(paths.workspace.ends_with("workspace"));
504 }
505
506 #[test]
507 fn workspace_override_independent_of_profile() {
508 let mut env: HashMap<&str, &str> = HashMap::new();
509 env.insert(LOCALGPT_PROFILE, "work");
510 env.insert(LOCALGPT_WORKSPACE, "/custom/workspace");
511
512 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
513
514 assert_eq!(paths.workspace, PathBuf::from("/custom/workspace"));
516 assert!(paths.data_dir.ends_with("localgpt-work"));
518 }
519
520 #[test]
521 fn convenience_accessors() {
522 let env: HashMap<&str, &str> = HashMap::new();
523 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
524
525 assert!(paths.config_file().ends_with("config.toml"));
526 assert!(paths.device_key().ends_with("localgpt.device.key"));
527 assert!(paths.audit_log().ends_with("localgpt.audit.jsonl"));
528 assert!(paths.search_index("main").ends_with("memory/main.sqlite"));
529 assert!(paths.sessions_dir("main").ends_with("agents/main/sessions"));
530 assert!(paths.logs_dir().ends_with("logs"));
531 assert!(paths.managed_skills_dir().ends_with("skills"));
532 assert!(paths.embedding_cache_dir().ends_with("embeddings"));
533 assert!(paths.pairing_file().ends_with("telegram_paired_user.json"));
534 }
535
536 #[test]
537 fn empty_env_vars_ignored() {
538 let mut env: HashMap<&str, &str> = HashMap::new();
539 env.insert(LOCALGPT_CONFIG_DIR, "");
540
541 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
542 assert!(paths.config_dir.is_absolute());
544 assert!(paths.config_dir.ends_with("localgpt"));
545 }
546}