Skip to main content

indusagi_core/
locate.rs

1//! Filesystem location resolution.
2//!
3//! Collapses the scattered `getProfileDir`/`getSettingsPath`/`getSessionsDir`
4//! helpers into a single [`Locator`]. Every location the app touches — the
5//! per-user profile directory, the global and per-project settings files, the
6//! sessions and logs directories, the credential store — is computed by one
7//! struct, deriving from two roots (home, cwd) and the [`BRAND`] naming
8//! constants. The two TS `Locator`s (`shell-app/locate` and `shell-app/config`)
9//! reconcile into this one.
10//!
11//! The path-returning methods are *pure* string joins; the `ensure_*` helpers
12//! are the only members that touch disk. `home` obeys the precedence
13//! `override → INDUSAGI_HOME → OS home`, so `INDUSAGI_HOME` relocates *all*
14//! per-user state without touching `$HOME`.
15
16use std::path::{Path, PathBuf};
17
18use crate::brand::{BRAND, Brand, env_name};
19use crate::env;
20
21/// Optional roots a [`Locator`] resolves against. Each omitted field falls back
22/// to the live process (`home` → `INDUSAGI_HOME`/OS home, `cwd` → current dir).
23#[derive(Clone, Debug, Default)]
24pub struct LocatorOverrides {
25    /// Root for per-user state. Defaults to `INDUSAGI_HOME` then the OS home.
26    pub home: Option<PathBuf>,
27    /// The working directory project paths resolve against. Defaults to
28    /// `std::env::current_dir()`.
29    pub cwd: Option<PathBuf>,
30}
31
32/// Resolves every filesystem location the application uses from a small set of
33/// roots plus the [`BRAND`] naming constants. Construct one per process (or one
34/// per sandbox in tests). Path methods are pure and cheap; the `ensure_*`
35/// methods are the only ones that touch disk.
36#[derive(Clone, Debug)]
37pub struct Locator {
38    home: PathBuf,
39    cwd: PathBuf,
40    brand: Brand,
41}
42
43impl Locator {
44    /// Construct a locator against the canonical [`BRAND`], with the given
45    /// `overrides` layered on the live-process defaults.
46    pub fn new(overrides: LocatorOverrides) -> Self {
47        Self::with_brand(overrides, BRAND)
48    }
49
50    /// Construct a locator against an explicit `brand` (injectable so an
51    /// alternate identity can be resolved without mutating the global).
52    pub fn with_brand(overrides: LocatorOverrides, brand: Brand) -> Self {
53        let home = Self::resolve_home(overrides.home, &brand);
54        let cwd = overrides
55            .cwd
56            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
57        Self { home, cwd, brand }
58    }
59
60    /// Home-root precedence: explicit override → `INDUSAGI_HOME` → OS home →
61    /// (last resort) `.`. The override wins only when present and non-empty.
62    fn resolve_home(override_home: Option<PathBuf>, brand: &Brand) -> PathBuf {
63        if let Some(h) = override_home
64            && !h.as_os_str().is_empty()
65        {
66            return h;
67        }
68        // `env::read_env` already trims and treats empty as absent.
69        let _ = brand; // brand kept for symmetry with env_name grammar below.
70        if let Some(from_env) = env::read_env("HOME") {
71            return PathBuf::from(from_env);
72        }
73        env::home_dir().unwrap_or_else(|| PathBuf::from("."))
74    }
75
76    /// The resolved home root (read-only accessor, mostly for tests).
77    pub fn home(&self) -> &Path {
78        &self.home
79    }
80
81    /// The resolved working directory (read-only accessor).
82    pub fn cwd(&self) -> &Path {
83        &self.cwd
84    }
85
86    // --- Roots ---------------------------------------------------------------
87
88    /// The per-user profile directory under the home root (e.g. `~/.indusagi`).
89    pub fn profile_dir(&self) -> PathBuf {
90        self.home.join(self.brand.profile_dir_name)
91    }
92
93    // --- Settings ------------------------------------------------------------
94
95    /// The global settings file inside the profile (e.g. `~/.indusagi/settings.json`).
96    pub fn settings_path(&self) -> PathBuf {
97        self.profile_dir().join(self.brand.settings_file_name)
98    }
99
100    /// The per-project settings file for a working directory. A relative `cwd`
101    /// is resolved against the locator's bound working directory.
102    pub fn project_settings_path(&self, cwd: Option<&Path>) -> PathBuf {
103        let root = match cwd {
104            Some(c) if c.is_absolute() => c.to_path_buf(),
105            Some(c) => self.cwd.join(c),
106            None => self.cwd.clone(),
107        };
108        root.join(self.brand.project_dir_name)
109            .join(self.brand.project_settings_file_name)
110    }
111
112    // --- Stores & directories ------------------------------------------------
113
114    /// Directory holding persisted conversation sessions (e.g. `~/.indusagi/sessions`).
115    pub fn sessions_dir(&self) -> PathBuf {
116        self.profile_dir().join(self.brand.sessions_dir_name)
117    }
118
119    /// The credential/auth store file inside the profile (e.g. `~/.indusagi/auth.json`).
120    pub fn auth_store_path(&self) -> PathBuf {
121        self.profile_dir().join(self.brand.auth_store_file_name)
122    }
123
124    /// Directory holding diagnostic and crash logs (e.g. `~/.indusagi/logs`).
125    pub fn logs_dir(&self) -> PathBuf {
126        self.profile_dir().join(self.brand.logs_dir_name)
127    }
128
129    // --- I/O helpers (the only members that touch disk) ----------------------
130
131    /// Ensure the profile directory exists; returns its path.
132    pub async fn ensure_profile_dir(&self) -> std::io::Result<PathBuf> {
133        self.ensure_dir(self.profile_dir()).await
134    }
135
136    /// Ensure the sessions directory exists; returns its path.
137    pub async fn ensure_sessions_dir(&self) -> std::io::Result<PathBuf> {
138        self.ensure_dir(self.sessions_dir()).await
139    }
140
141    /// Ensure the logs directory exists; returns its path.
142    pub async fn ensure_logs_dir(&self) -> std::io::Result<PathBuf> {
143        self.ensure_dir(self.logs_dir()).await
144    }
145
146    /// Ensure the parent directory of `file_path` exists, then return
147    /// `file_path` unchanged (use before writing settings/auth files).
148    pub async fn ensure_parent_of(&self, file_path: PathBuf) -> std::io::Result<PathBuf> {
149        if let Some(parent) = file_path.parent() {
150            self.ensure_dir(parent.to_path_buf()).await?;
151        }
152        Ok(file_path)
153    }
154
155    /// Create `dir` and any missing parents idempotently; returns its path.
156    pub async fn ensure_dir(&self, dir: PathBuf) -> std::io::Result<PathBuf> {
157        tokio::fs::create_dir_all(&dir).await?;
158        Ok(dir)
159    }
160}
161
162/// Confirm the branded HOME var name the locator consults, for any caller that
163/// wants to surface it in diagnostics.
164pub fn home_env_var() -> String {
165    env_name("HOME")
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn sandbox(home: &str, cwd: &str) -> Locator {
173        Locator::new(LocatorOverrides {
174            home: Some(PathBuf::from(home)),
175            cwd: Some(PathBuf::from(cwd)),
176        })
177    }
178
179    #[test]
180    fn override_home_drives_every_state_path() {
181        let loc = sandbox("/tmp/box", "/work/project");
182        assert_eq!(loc.profile_dir(), PathBuf::from("/tmp/box/.indusagi"));
183        assert_eq!(
184            loc.settings_path(),
185            PathBuf::from("/tmp/box/.indusagi/settings.json")
186        );
187        assert_eq!(
188            loc.auth_store_path(),
189            PathBuf::from("/tmp/box/.indusagi/auth.json")
190        );
191        assert_eq!(
192            loc.sessions_dir(),
193            PathBuf::from("/tmp/box/.indusagi/sessions")
194        );
195        assert_eq!(loc.logs_dir(), PathBuf::from("/tmp/box/.indusagi/logs"));
196    }
197
198    #[test]
199    fn project_settings_resolve_relative_against_cwd() {
200        let loc = sandbox("/tmp/box", "/work/project");
201        // Absolute cwd is used verbatim.
202        assert_eq!(
203            loc.project_settings_path(Some(Path::new("/abs/repo"))),
204            PathBuf::from("/abs/repo/.indusagi/settings.json")
205        );
206        // Relative cwd resolves against the bound working directory.
207        assert_eq!(
208            loc.project_settings_path(Some(Path::new("sub"))),
209            PathBuf::from("/work/project/sub/.indusagi/settings.json")
210        );
211        // None uses the bound cwd.
212        assert_eq!(
213            loc.project_settings_path(None),
214            PathBuf::from("/work/project/.indusagi/settings.json")
215        );
216    }
217
218    #[test]
219    fn home_env_var_is_branded() {
220        assert_eq!(home_env_var(), "INDUSAGI_HOME");
221    }
222
223    #[tokio::test]
224    async fn ensure_dir_creates_nested_paths() {
225        let tmp = std::env::temp_dir().join(format!("indusagi-loc-test-{}", crate::ids::new_id()));
226        let loc = Locator::new(LocatorOverrides {
227            home: Some(tmp.clone()),
228            cwd: None,
229        });
230        let sessions = loc.ensure_sessions_dir().await.unwrap();
231        assert!(sessions.is_dir());
232        assert_eq!(sessions, tmp.join(".indusagi/sessions"));
233        // Cleanup.
234        let _ = tokio::fs::remove_dir_all(&tmp).await;
235    }
236}