Skip to main content

localgpt_core/
paths.rs

1//! XDG Base Directory Specification compliant path resolution with profile isolation.
2//!
3//! Every directory is resolved through a three-level fallback:
4//! 1. LocalGPT-specific env var (LOCALGPT_CONFIG_DIR, etc.)
5//! 2. XDG env var (XDG_CONFIG_HOME, etc.) via `etcetera`
6//! 3. Platform default (~/.config, etc.)
7//!
8//! All paths are absolute. Relative paths from env vars are ignored per XDG spec.
9//!
10//! # Profile Isolation
11//!
12//! When `LOCALGPT_PROFILE` is set (e.g., "work"), ALL directories get a `-{profile}` suffix
13//! for complete isolation, following OpenClaw's model:
14//!
15//! ```text
16//! default profile:  ~/.config/localgpt/, ~/.local/share/localgpt/workspace/
17//! work profile:     ~/.config/localgpt-work/, ~/.local/share/localgpt-work/workspace/
18//! ```
19//!
20//! This provides complete isolation: separate config, sessions, cache, workspace per profile.
21
22use 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
32// XDG path constants for documentation and defaults
33pub 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/// Resolved directory paths for the entire application.
39///
40/// Created once at startup, threaded through Config.
41/// All paths are absolute.
42#[derive(Debug, Clone)]
43pub struct Paths {
44    /// Config directory: config.toml lives here
45    pub config_dir: PathBuf,
46
47    /// Data directory root: contains workspace/ and localgpt.device.key
48    pub data_dir: PathBuf,
49
50    /// Workspace: markdown files, knowledge, skills.
51    /// May be overridden independently via LOCALGPT_WORKSPACE.
52    pub workspace: PathBuf,
53
54    /// State directory: sessions, audit log, logs
55    pub state_dir: PathBuf,
56
57    /// Cache directory: search index, embedding models
58    pub cache_dir: PathBuf,
59
60    /// Runtime directory: PID file, sockets.
61    /// None if no suitable runtime directory is available.
62    pub runtime_dir: Option<PathBuf>,
63}
64
65impl Paths {
66    /// Resolve all paths using real environment variables.
67    pub fn resolve() -> Result<Self> {
68        Self::resolve_with_env(|key| std::env::var(key))
69    }
70
71    /// Resolve paths with a custom env var lookup (for testing).
72    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        // Get profile suffix once - applies to ALL directories for complete isolation
82        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            // etcetera's choose_base_strategy gives XDG paths on all platforms.
94            // state_dir() returns data_dir() as fallback on platforms without XDG_STATE_HOME.
95            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        // Workspace: independent override via LOCALGPT_WORKSPACE, or default under data_dir
104        let workspace = resolve_workspace(&env_fn, &data_dir);
105
106        // Runtime: XDG_RUNTIME_DIR or platform fallback (with profile suffix)
107        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    // ── Convenience accessors for specific files ──
120
121    /// Config file: config_dir/config.toml
122    pub fn config_file(&self) -> PathBuf {
123        self.config_dir.join("config.toml")
124    }
125
126    /// Device key: data_dir/localgpt.device.key
127    pub fn device_key(&self) -> PathBuf {
128        self.data_dir.join("localgpt.device.key")
129    }
130
131    /// Audit log: state_dir/localgpt.audit.jsonl
132    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    /// Search index for a specific agent: cache_dir/memory/{agent_id}.sqlite
141    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    /// Sessions directory for a specific agent
148    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    /// Logs directory
156    pub fn logs_dir(&self) -> PathBuf {
157        self.state_dir.join("logs")
158    }
159
160    /// Locks directory (for PID and lock files)
161    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    /// PID file
169    pub fn pid_file(&self) -> PathBuf {
170        self.locks_dir().join("daemon.pid")
171    }
172
173    /// Workspace lock file
174    pub fn workspace_lock(&self) -> PathBuf {
175        self.locks_dir().join("workspace.lock")
176    }
177
178    /// Telegram pairing file
179    pub fn pairing_file(&self) -> PathBuf {
180        self.state_dir.join("telegram_paired_user.json")
181    }
182
183    /// Bridge socket name (Full path on Unix, pipe name on Windows)
184    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    /// Managed skills directory: data_dir/skills
199    pub fn managed_skills_dir(&self) -> PathBuf {
200        self.data_dir.join("skills")
201    }
202
203    /// Embedding cache directory: cache_dir/embeddings
204    pub fn embedding_cache_dir(&self) -> PathBuf {
205        self.cache_dir.join("embeddings")
206    }
207
208    /// Create Paths with all directories rooted under a single base path.
209    ///
210    /// Mobile apps use this to point everything at their app-specific
211    /// document or library directory.
212    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    /// Create all directories with appropriate permissions.
225    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            // Emergency fallback — should never happen in practice
254            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
271/// Resolve an env var with fallback. Ignores empty and relative paths per XDG spec.
272fn 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()) // XDG spec: ignore relative paths
281        .unwrap_or_else(default)
282}
283
284/// Get the profile suffix for directory names.
285/// Returns empty string for default/empty profile, "-{profile}" otherwise.
286fn 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
299/// Resolve workspace path with LOCALGPT_WORKSPACE override or default under data_dir.
300fn resolve_workspace<F>(env_fn: &F, data_dir: &Path) -> PathBuf
301where
302    F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
303{
304    // Direct workspace override takes precedence
305    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    // Default workspace under data_dir (which already has profile suffix)
317    data_dir.join("workspace")
318}
319
320/// Resolve runtime directory.
321fn 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    // Try XDG_RUNTIME_DIR first
326    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    // Fallback: $TMPDIR/localgpt-{profile}-{$UID|user} on Unix/Windows
336    #[cfg(unix)]
337    {
338        // SAFETY: getuid() is always safe — no arguments, no preconditions,
339        // simply returns the real user ID of the calling process.
340        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
354/// Create a directory with mode 0700 per XDG spec.
355fn 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        // iOS/Android sandbox doesn't allow chmod - ignore silently
363        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    /// Helper: build an env_fn from a HashMap
375    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        // Should end with the expected XDG suffixes
391        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        // Should fall back to XDG default, not use relative path
436        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        // data_dir should still be at XDG default (not derived from workspace)
448        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        // All directories should have -work suffix for complete isolation
460        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        // Workspace is just "workspace" under profile's data_dir (no double suffix)
481        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        // "default" profile should not add suffix
501        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        // Workspace override takes precedence
515        assert_eq!(paths.workspace, PathBuf::from("/custom/workspace"));
516        // But other dirs still have profile suffix
517        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        // Should use XDG default, not empty string
543        assert!(paths.config_dir.is_absolute());
544        assert!(paths.config_dir.ends_with("localgpt"));
545    }
546}