indusagi-core 0.1.0

Cross-cutting primitives every indusagi crate depends on: cancellation, env registry, brand, locator, canonical-JSON, version, ids, errors, re-iterable channel.
Documentation
//! Filesystem location resolution.
//!
//! Collapses the scattered `getProfileDir`/`getSettingsPath`/`getSessionsDir`
//! helpers into a single [`Locator`]. Every location the app touches — the
//! per-user profile directory, the global and per-project settings files, the
//! sessions and logs directories, the credential store — is computed by one
//! struct, deriving from two roots (home, cwd) and the [`BRAND`] naming
//! constants. The two TS `Locator`s (`shell-app/locate` and `shell-app/config`)
//! reconcile into this one.
//!
//! The path-returning methods are *pure* string joins; the `ensure_*` helpers
//! are the only members that touch disk. `home` obeys the precedence
//! `override → INDUSAGI_HOME → OS home`, so `INDUSAGI_HOME` relocates *all*
//! per-user state without touching `$HOME`.

use std::path::{Path, PathBuf};

use crate::brand::{BRAND, Brand, env_name};
use crate::env;

/// Optional roots a [`Locator`] resolves against. Each omitted field falls back
/// to the live process (`home` → `INDUSAGI_HOME`/OS home, `cwd` → current dir).
#[derive(Clone, Debug, Default)]
pub struct LocatorOverrides {
    /// Root for per-user state. Defaults to `INDUSAGI_HOME` then the OS home.
    pub home: Option<PathBuf>,
    /// The working directory project paths resolve against. Defaults to
    /// `std::env::current_dir()`.
    pub cwd: Option<PathBuf>,
}

/// Resolves every filesystem location the application uses from a small set of
/// roots plus the [`BRAND`] naming constants. Construct one per process (or one
/// per sandbox in tests). Path methods are pure and cheap; the `ensure_*`
/// methods are the only ones that touch disk.
#[derive(Clone, Debug)]
pub struct Locator {
    home: PathBuf,
    cwd: PathBuf,
    brand: Brand,
}

impl Locator {
    /// Construct a locator against the canonical [`BRAND`], with the given
    /// `overrides` layered on the live-process defaults.
    pub fn new(overrides: LocatorOverrides) -> Self {
        Self::with_brand(overrides, BRAND)
    }

    /// Construct a locator against an explicit `brand` (injectable so an
    /// alternate identity can be resolved without mutating the global).
    pub fn with_brand(overrides: LocatorOverrides, brand: Brand) -> Self {
        let home = Self::resolve_home(overrides.home, &brand);
        let cwd = overrides
            .cwd
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
        Self { home, cwd, brand }
    }

    /// Home-root precedence: explicit override → `INDUSAGI_HOME` → OS home →
    /// (last resort) `.`. The override wins only when present and non-empty.
    fn resolve_home(override_home: Option<PathBuf>, brand: &Brand) -> PathBuf {
        if let Some(h) = override_home
            && !h.as_os_str().is_empty()
        {
            return h;
        }
        // `env::read_env` already trims and treats empty as absent.
        let _ = brand; // brand kept for symmetry with env_name grammar below.
        if let Some(from_env) = env::read_env("HOME") {
            return PathBuf::from(from_env);
        }
        env::home_dir().unwrap_or_else(|| PathBuf::from("."))
    }

    /// The resolved home root (read-only accessor, mostly for tests).
    pub fn home(&self) -> &Path {
        &self.home
    }

    /// The resolved working directory (read-only accessor).
    pub fn cwd(&self) -> &Path {
        &self.cwd
    }

    // --- Roots ---------------------------------------------------------------

    /// The per-user profile directory under the home root (e.g. `~/.indusagi`).
    pub fn profile_dir(&self) -> PathBuf {
        self.home.join(self.brand.profile_dir_name)
    }

    // --- Settings ------------------------------------------------------------

    /// The global settings file inside the profile (e.g. `~/.indusagi/settings.json`).
    pub fn settings_path(&self) -> PathBuf {
        self.profile_dir().join(self.brand.settings_file_name)
    }

    /// The per-project settings file for a working directory. A relative `cwd`
    /// is resolved against the locator's bound working directory.
    pub fn project_settings_path(&self, cwd: Option<&Path>) -> PathBuf {
        let root = match cwd {
            Some(c) if c.is_absolute() => c.to_path_buf(),
            Some(c) => self.cwd.join(c),
            None => self.cwd.clone(),
        };
        root.join(self.brand.project_dir_name)
            .join(self.brand.project_settings_file_name)
    }

    // --- Stores & directories ------------------------------------------------

    /// Directory holding persisted conversation sessions (e.g. `~/.indusagi/sessions`).
    pub fn sessions_dir(&self) -> PathBuf {
        self.profile_dir().join(self.brand.sessions_dir_name)
    }

    /// The credential/auth store file inside the profile (e.g. `~/.indusagi/auth.json`).
    pub fn auth_store_path(&self) -> PathBuf {
        self.profile_dir().join(self.brand.auth_store_file_name)
    }

    /// Directory holding diagnostic and crash logs (e.g. `~/.indusagi/logs`).
    pub fn logs_dir(&self) -> PathBuf {
        self.profile_dir().join(self.brand.logs_dir_name)
    }

    // --- I/O helpers (the only members that touch disk) ----------------------

    /// Ensure the profile directory exists; returns its path.
    pub async fn ensure_profile_dir(&self) -> std::io::Result<PathBuf> {
        self.ensure_dir(self.profile_dir()).await
    }

    /// Ensure the sessions directory exists; returns its path.
    pub async fn ensure_sessions_dir(&self) -> std::io::Result<PathBuf> {
        self.ensure_dir(self.sessions_dir()).await
    }

    /// Ensure the logs directory exists; returns its path.
    pub async fn ensure_logs_dir(&self) -> std::io::Result<PathBuf> {
        self.ensure_dir(self.logs_dir()).await
    }

    /// Ensure the parent directory of `file_path` exists, then return
    /// `file_path` unchanged (use before writing settings/auth files).
    pub async fn ensure_parent_of(&self, file_path: PathBuf) -> std::io::Result<PathBuf> {
        if let Some(parent) = file_path.parent() {
            self.ensure_dir(parent.to_path_buf()).await?;
        }
        Ok(file_path)
    }

    /// Create `dir` and any missing parents idempotently; returns its path.
    pub async fn ensure_dir(&self, dir: PathBuf) -> std::io::Result<PathBuf> {
        tokio::fs::create_dir_all(&dir).await?;
        Ok(dir)
    }
}

/// Confirm the branded HOME var name the locator consults, for any caller that
/// wants to surface it in diagnostics.
pub fn home_env_var() -> String {
    env_name("HOME")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sandbox(home: &str, cwd: &str) -> Locator {
        Locator::new(LocatorOverrides {
            home: Some(PathBuf::from(home)),
            cwd: Some(PathBuf::from(cwd)),
        })
    }

    #[test]
    fn override_home_drives_every_state_path() {
        let loc = sandbox("/tmp/box", "/work/project");
        assert_eq!(loc.profile_dir(), PathBuf::from("/tmp/box/.indusagi"));
        assert_eq!(
            loc.settings_path(),
            PathBuf::from("/tmp/box/.indusagi/settings.json")
        );
        assert_eq!(
            loc.auth_store_path(),
            PathBuf::from("/tmp/box/.indusagi/auth.json")
        );
        assert_eq!(
            loc.sessions_dir(),
            PathBuf::from("/tmp/box/.indusagi/sessions")
        );
        assert_eq!(loc.logs_dir(), PathBuf::from("/tmp/box/.indusagi/logs"));
    }

    #[test]
    fn project_settings_resolve_relative_against_cwd() {
        let loc = sandbox("/tmp/box", "/work/project");
        // Absolute cwd is used verbatim.
        assert_eq!(
            loc.project_settings_path(Some(Path::new("/abs/repo"))),
            PathBuf::from("/abs/repo/.indusagi/settings.json")
        );
        // Relative cwd resolves against the bound working directory.
        assert_eq!(
            loc.project_settings_path(Some(Path::new("sub"))),
            PathBuf::from("/work/project/sub/.indusagi/settings.json")
        );
        // None uses the bound cwd.
        assert_eq!(
            loc.project_settings_path(None),
            PathBuf::from("/work/project/.indusagi/settings.json")
        );
    }

    #[test]
    fn home_env_var_is_branded() {
        assert_eq!(home_env_var(), "INDUSAGI_HOME");
    }

    #[tokio::test]
    async fn ensure_dir_creates_nested_paths() {
        let tmp = std::env::temp_dir().join(format!("indusagi-loc-test-{}", crate::ids::new_id()));
        let loc = Locator::new(LocatorOverrides {
            home: Some(tmp.clone()),
            cwd: None,
        });
        let sessions = loc.ensure_sessions_dir().await.unwrap();
        assert!(sessions.is_dir());
        assert_eq!(sessions, tmp.join(".indusagi/sessions"));
        // Cleanup.
        let _ = tokio::fs::remove_dir_all(&tmp).await;
    }
}