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() };
339 let tmpdir = env_fn("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
340 Some(PathBuf::from(tmpdir).join(format!("localgpt{}-{}", profile_suffix, uid)))
341 }
342
343 #[cfg(not(unix))]
344 {
345 env_fn("TEMP").ok().map(|t| {
346 let user = env_fn("USERNAME").unwrap_or_else(|_| "user".into());
347 PathBuf::from(t).join(format!("localgpt{}-{}", profile_suffix, user))
348 })
349 }
350}
351
352fn create_dir_with_mode(path: &Path) -> Result<()> {
354 std::fs::create_dir_all(path)
355 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
356
357 #[cfg(all(unix, not(target_os = "ios"), not(target_os = "android")))]
358 {
359 use std::os::unix::fs::PermissionsExt;
360 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
362 }
363
364 Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::collections::HashMap;
371
372 fn make_env(
374 map: HashMap<&str, &str>,
375 ) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> {
376 move |key: &str| {
377 map.get(key)
378 .map(|v| v.to_string())
379 .ok_or(std::env::VarError::NotPresent)
380 }
381 }
382
383 #[test]
384 fn default_paths_are_xdg_compliant() {
385 let env: HashMap<&str, &str> = HashMap::new();
386 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
387
388 assert!(
390 paths.config_dir.ends_with("localgpt"),
391 "config_dir: {:?}",
392 paths.config_dir
393 );
394 assert!(
395 paths.data_dir.ends_with("localgpt"),
396 "data_dir: {:?}",
397 paths.data_dir
398 );
399 assert!(
400 paths.state_dir.ends_with("localgpt"),
401 "state_dir: {:?}",
402 paths.state_dir
403 );
404 assert!(
405 paths.cache_dir.ends_with("localgpt"),
406 "cache_dir: {:?}",
407 paths.cache_dir
408 );
409 assert!(paths.workspace.ends_with("workspace"));
410 }
411
412 #[test]
413 fn localgpt_env_vars_override_xdg() {
414 let mut env: HashMap<&str, &str> = HashMap::new();
415 env.insert(LOCALGPT_CONFIG_DIR, "/custom/config");
416 env.insert(LOCALGPT_DATA_DIR, "/custom/data");
417 env.insert(LOCALGPT_STATE_DIR, "/custom/state");
418 env.insert(LOCALGPT_CACHE_DIR, "/custom/cache");
419
420 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
421 assert_eq!(paths.config_dir, PathBuf::from("/custom/config"));
422 assert_eq!(paths.data_dir, PathBuf::from("/custom/data"));
423 assert_eq!(paths.state_dir, PathBuf::from("/custom/state"));
424 assert_eq!(paths.cache_dir, PathBuf::from("/custom/cache"));
425 }
426
427 #[test]
428 fn relative_paths_are_ignored() {
429 let mut env: HashMap<&str, &str> = HashMap::new();
430 env.insert(LOCALGPT_CONFIG_DIR, "relative/path");
431
432 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
433 assert!(paths.config_dir.is_absolute());
435 assert_ne!(paths.config_dir, PathBuf::from("relative/path"));
436 }
437
438 #[test]
439 fn workspace_override_independent_of_data_dir() {
440 let mut env: HashMap<&str, &str> = HashMap::new();
441 env.insert(LOCALGPT_WORKSPACE, "/projects/my-workspace");
442
443 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
444 assert_eq!(paths.workspace, PathBuf::from("/projects/my-workspace"));
445 assert!(paths.data_dir.ends_with("localgpt"));
447 assert!(!paths.data_dir.to_string_lossy().contains("my-workspace"));
448 }
449
450 #[test]
451 fn profile_suffixes_all_directories() {
452 let mut env: HashMap<&str, &str> = HashMap::new();
453 env.insert(LOCALGPT_PROFILE, "work");
454
455 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
456
457 assert!(
459 paths.config_dir.ends_with("localgpt-work"),
460 "config_dir: {:?}",
461 paths.config_dir
462 );
463 assert!(
464 paths.data_dir.ends_with("localgpt-work"),
465 "data_dir: {:?}",
466 paths.data_dir
467 );
468 assert!(
469 paths.state_dir.ends_with("localgpt-work"),
470 "state_dir: {:?}",
471 paths.state_dir
472 );
473 assert!(
474 paths.cache_dir.ends_with("localgpt-work"),
475 "cache_dir: {:?}",
476 paths.cache_dir
477 );
478 assert!(
480 paths.workspace.ends_with("workspace"),
481 "workspace: {:?}",
482 paths.workspace
483 );
484 assert!(
485 paths.workspace.to_string_lossy().contains("localgpt-work"),
486 "workspace should be under localgpt-work: {:?}",
487 paths.workspace
488 );
489 }
490
491 #[test]
492 fn profile_default_no_suffix() {
493 let mut env: HashMap<&str, &str> = HashMap::new();
494 env.insert(LOCALGPT_PROFILE, "default");
495
496 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
497
498 assert!(paths.config_dir.ends_with("localgpt"));
500 assert!(paths.data_dir.ends_with("localgpt"));
501 assert!(paths.workspace.ends_with("workspace"));
502 }
503
504 #[test]
505 fn workspace_override_independent_of_profile() {
506 let mut env: HashMap<&str, &str> = HashMap::new();
507 env.insert(LOCALGPT_PROFILE, "work");
508 env.insert(LOCALGPT_WORKSPACE, "/custom/workspace");
509
510 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
511
512 assert_eq!(paths.workspace, PathBuf::from("/custom/workspace"));
514 assert!(paths.data_dir.ends_with("localgpt-work"));
516 }
517
518 #[test]
519 fn convenience_accessors() {
520 let env: HashMap<&str, &str> = HashMap::new();
521 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
522
523 assert!(paths.config_file().ends_with("config.toml"));
524 assert!(paths.device_key().ends_with("localgpt.device.key"));
525 assert!(paths.audit_log().ends_with("localgpt.audit.jsonl"));
526 assert!(paths.search_index("main").ends_with("memory/main.sqlite"));
527 assert!(paths.sessions_dir("main").ends_with("agents/main/sessions"));
528 assert!(paths.logs_dir().ends_with("logs"));
529 assert!(paths.managed_skills_dir().ends_with("skills"));
530 assert!(paths.embedding_cache_dir().ends_with("embeddings"));
531 assert!(paths.pairing_file().ends_with("telegram_paired_user.json"));
532 }
533
534 #[test]
535 fn empty_env_vars_ignored() {
536 let mut env: HashMap<&str, &str> = HashMap::new();
537 env.insert(LOCALGPT_CONFIG_DIR, "");
538
539 let paths = Paths::resolve_with_env(make_env(env)).unwrap();
540 assert!(paths.config_dir.is_absolute());
542 assert!(paths.config_dir.ends_with("localgpt"));
543 }
544}