algocline-app 0.44.4

algocline application layer — execution orchestration, package management
Documentation
use std::path::{Path, PathBuf};
use std::sync::Arc;

use algocline_core::AppDir;

// ─── Application Config ─────────────────────────────────────────

/// How the log directory was resolved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogDirSource {
    /// `ALC_LOG_DIR` environment variable.
    EnvVar,
    /// `~/.algocline/logs` (home-based default).
    Home,
    /// `$XDG_STATE_HOME/algocline/logs` or `~/.local/state/algocline/logs`.
    StateDir,
    /// Current working directory fallback.
    CurrentDir,
    /// No writable directory found — file logging disabled.
    None,
}

impl std::fmt::Display for LogDirSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EnvVar => write!(f, "ALC_LOG_DIR"),
            Self::Home => write!(f, "~/.algocline/logs"),
            Self::StateDir => write!(f, "state_dir"),
            Self::CurrentDir => write!(f, "current_dir"),
            Self::None => write!(f, "none (stderr only)"),
        }
    }
}

/// Application-wide configuration resolved from environment variables.
///
/// Log directory resolution order:
/// 1. `ALC_LOG_DIR` env var (explicit override)
/// 2. `~/.algocline/logs` (home-based default)
/// 3. `$XDG_STATE_HOME/algocline/logs` or `~/.local/state/algocline/logs`
/// 4. Current working directory (sandbox fallback)
/// 5. `None` — stderr-only mode (no file logging)
///
/// Application root directory resolution:
/// 1. `ALC_HOME` env var (explicit override — same pattern as `CARGO_HOME` /
///    `RUSTUP_HOME`).
/// 2. `~/.algocline/` — home-based default.
/// 3. `./.algocline/` — fallback when `HOME` is not available (unusual).
///
/// - `ALC_LOG_LEVEL`: `full` (default) or `off`.
/// - `ALC_PROMPT_PREVIEW_CHARS`: char count for `alc_status(pending_filter="preview")`
///   prompt truncation. Falls back to
///   [`algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS`] when unset or
///   unparseable. Setting `0` yields empty previews — if you want no
///   prompt at all, use the `"meta"` preset instead.
#[derive(Clone, Debug)]
pub struct AppConfig {
    /// Resolved log directory, or `None` if no writable directory is available.
    pub log_dir: Option<PathBuf>,
    pub log_dir_source: LogDirSource,
    pub log_enabled: bool,
    /// Char count for `alc_status` prompt_preview truncation.
    pub prompt_preview_chars: usize,
    /// Resolved application root directory (`$ALC_HOME` or `~/.algocline/`).
    ///
    /// Wrapped in [`Arc`] so it can be shared across Service-layer
    /// subsystems without cloning the underlying [`PathBuf`]. Exposed via
    /// [`AppConfig::app_dir`] for read access; the `pub(super)` visibility
    /// is only so that in-crate tests can use `..Default::default()` on
    /// the struct literal (Service-layer production code MUST use the
    /// accessor).
    pub(super) app_dir: Arc<AppDir>,
}

impl AppConfig {
    /// Build from environment variables (single resolution point).
    pub fn from_env() -> Self {
        let app_dir = Arc::new(Self::resolve_app_dir());
        let (log_dir, log_dir_source) = Self::resolve_log_dir(&app_dir);

        let log_enabled = std::env::var("ALC_LOG_LEVEL")
            .map(|v| v.to_lowercase() != "off")
            .unwrap_or(true);

        let prompt_preview_chars = std::env::var("ALC_PROMPT_PREVIEW_CHARS")
            .ok()
            .and_then(|s| s.parse::<usize>().ok())
            .unwrap_or(algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS);

        Self {
            log_dir,
            log_dir_source,
            log_enabled,
            prompt_preview_chars,
            app_dir,
        }
    }

    /// Shared handle to the resolved application root directory.
    pub fn app_dir(&self) -> Arc<AppDir> {
        Arc::clone(&self.app_dir)
    }

    /// Override the application root directory — intended for tests that
    /// need to redirect every `~/.algocline/` access to a `tempdir`.
    pub fn with_app_dir(mut self, root: PathBuf) -> Self {
        self.app_dir = Arc::new(AppDir::new(root));
        self
    }

    /// Disable file logging — sets `log_dir = None`, `log_dir_source =
    /// LogDirSource::None`, and `log_enabled = false`. Builder-chain
    /// shorthand used by tests that want a quiet `AppService` rooted at
    /// a tempdir without touching the developer's real log paths.
    #[cfg(test)]
    pub(crate) fn with_log_disabled(mut self) -> Self {
        self.log_dir = None;
        self.log_dir_source = LogDirSource::None;
        self.log_enabled = false;
        self
    }

    /// Resolve the application root directory.
    ///
    /// 1. `ALC_HOME` env var (explicit override — highest priority).
    /// 2. `~/.algocline/` — home-based default.
    /// 3. `./.algocline/` — fallback when `HOME` is unavailable; matches
    ///    the sandbox-friendly fallback used for log directories.
    fn resolve_app_dir() -> AppDir {
        if let Ok(path) = std::env::var("ALC_HOME") {
            if !path.is_empty() {
                return AppDir::new(PathBuf::from(path));
            }
        }
        let root = dirs::home_dir()
            .map(|h| h.join(".algocline"))
            .unwrap_or_else(|| PathBuf::from(".algocline"));
        AppDir::new(root)
    }

    /// Resolve log directory with fallback chain.
    ///
    /// Tries each candidate in order, creating the directory if needed via
    /// [`ensure_dir`](Self::ensure_dir). Returns `(Some(path), source)` on
    /// the first writable candidate, or `(None, LogDirSource::None)` if every
    /// candidate fails.
    ///
    /// ## Fallback order
    ///
    /// 1. `ALC_LOG_DIR` env var — explicit user/operator override.
    /// 2. `{app_dir}/logs` — derived from the resolved [`AppDir`] so it
    ///    honors `ALC_HOME` when set; defaults to `~/.algocline/logs`
    ///    when no override is in effect.
    /// 3. `$XDG_STATE_HOME/algocline/logs` (or `~/.local/state/…`).
    /// 4. `<cwd>/algocline-logs` — **sandbox fallback**.
    ///    In containerised / sandbox environments (Docker, CI runners,
    ///    restricted shells) the home directory and XDG paths may not
    ///    exist or may be read-only. The current working directory is
    ///    often the only writable location available, so we fall back
    ///    to it to preserve file logging in those environments.
    /// 5. `None` — no writable directory found; file logging is disabled
    ///    and the server operates in stderr-only tracing mode.
    fn resolve_log_dir(app_dir: &AppDir) -> (Option<PathBuf>, LogDirSource) {
        // 1. ALC_LOG_DIR env (explicit override — highest priority)
        if let Ok(dir) = std::env::var("ALC_LOG_DIR") {
            let path = PathBuf::from(dir);
            if Self::ensure_dir(&path) {
                return (Some(path), LogDirSource::EnvVar);
            }
        }

        // 2. {app_dir}/logs — honors ALC_HOME via AppDir
        let path = app_dir.logs_dir();
        if Self::ensure_dir(&path) {
            return (Some(path), LogDirSource::Home);
        }

        // 3. state_dir (XDG_STATE_HOME or ~/.local/state)
        if let Some(state) = dirs::state_dir() {
            let path = state.join("algocline").join("logs");
            if Self::ensure_dir(&path) {
                return (Some(path), LogDirSource::StateDir);
            }
        }

        // 4. Current working directory (sandbox fallback — see doc above)
        if let Ok(cwd) = std::env::current_dir() {
            let path = cwd.join("algocline-logs");
            if Self::ensure_dir(&path) {
                return (Some(path), LogDirSource::CurrentDir);
            }
        }

        // 5. No writable directory — stderr-only
        (None, LogDirSource::None)
    }

    /// Try to create the directory. Returns true if it exists and is writable.
    fn ensure_dir(path: &Path) -> bool {
        std::fs::create_dir_all(path).is_ok() && path.is_dir()
    }
}

#[cfg(test)]
impl Default for AppConfig {
    /// Test-only default. `app_dir` is rooted at a freshly created tempdir
    /// whose handle is leaked (`mem::forget`); the OS reclaims the directory
    /// when the test binary exits. This keeps every test isolated from cwd
    /// and from other tests without requiring callers to remember to chain
    /// `with_app_dir(tempdir)` after `AppConfig::default()` /
    /// `..Default::default()`.
    ///
    /// The trait impl is `#[cfg(test)]`-gated so a misuse in production
    /// code won't compile. Production code constructs [`AppConfig`] via
    /// [`AppConfig::from_env`] which resolves `ALC_HOME` / `~/.algocline/`
    /// properly.
    fn default() -> Self {
        let tmp = tempfile::tempdir().expect("AppConfig::default tempdir");
        let root = tmp.path().to_path_buf();
        // Leak the handle so the dir survives for the test duration.
        std::mem::forget(tmp);
        Self {
            log_dir: None,
            log_dir_source: LogDirSource::None,
            log_enabled: false,
            prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
            app_dir: Arc::new(AppDir::new(root)),
        }
    }
}

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

    #[test]
    fn app_dir_env_overrides_home() {
        with_env_var("ALC_HOME", "/tmp/alc-home-override", || {
            let dir = AppConfig::resolve_app_dir();
            assert_eq!(dir.root(), Path::new("/tmp/alc-home-override"));
        });
    }

    #[test]
    fn with_app_dir_overrides_resolved() {
        let cfg = AppConfig::default().with_app_dir(PathBuf::from("/tmp/alt"));
        assert_eq!(cfg.app_dir().root(), Path::new("/tmp/alt"));
    }

    #[test]
    fn app_dir_handle_is_shared() {
        let cfg = AppConfig::default();
        let a = cfg.app_dir();
        let b = cfg.app_dir();
        assert_eq!(Arc::strong_count(&a), 3); // cfg + a + b
        assert_eq!(a.root(), b.root());
    }
}