Skip to main content

algocline_app/service/
config.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::AppDir;
5
6// ─── Application Config ─────────────────────────────────────────
7
8/// How the log directory was resolved.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum LogDirSource {
11    /// `ALC_LOG_DIR` environment variable.
12    EnvVar,
13    /// `~/.algocline/logs` (home-based default).
14    Home,
15    /// `$XDG_STATE_HOME/algocline/logs` or `~/.local/state/algocline/logs`.
16    StateDir,
17    /// Current working directory fallback.
18    CurrentDir,
19    /// No writable directory found — file logging disabled.
20    None,
21}
22
23impl std::fmt::Display for LogDirSource {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::EnvVar => write!(f, "ALC_LOG_DIR"),
27            Self::Home => write!(f, "~/.algocline/logs"),
28            Self::StateDir => write!(f, "state_dir"),
29            Self::CurrentDir => write!(f, "current_dir"),
30            Self::None => write!(f, "none (stderr only)"),
31        }
32    }
33}
34
35/// Application-wide configuration resolved from environment variables.
36///
37/// Log directory resolution order:
38/// 1. `ALC_LOG_DIR` env var (explicit override)
39/// 2. `~/.algocline/logs` (home-based default)
40/// 3. `$XDG_STATE_HOME/algocline/logs` or `~/.local/state/algocline/logs`
41/// 4. Current working directory (sandbox fallback)
42/// 5. `None` — stderr-only mode (no file logging)
43///
44/// Application root directory resolution:
45/// 1. `ALC_HOME` env var (explicit override — same pattern as `CARGO_HOME` /
46///    `RUSTUP_HOME`).
47/// 2. `~/.algocline/` — home-based default.
48/// 3. `./.algocline/` — fallback when `HOME` is not available (unusual).
49///
50/// - `ALC_LOG_LEVEL`: `full` (default) or `off`.
51/// - `ALC_PROMPT_PREVIEW_CHARS`: char count for `alc_status(pending_filter="preview")`
52///   prompt truncation. Falls back to
53///   [`algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS`] when unset or
54///   unparseable. Setting `0` yields empty previews — if you want no
55///   prompt at all, use the `"meta"` preset instead.
56#[derive(Clone, Debug)]
57pub struct AppConfig {
58    /// Resolved log directory, or `None` if no writable directory is available.
59    pub log_dir: Option<PathBuf>,
60    pub log_dir_source: LogDirSource,
61    pub log_enabled: bool,
62    /// Char count for `alc_status` prompt_preview truncation.
63    pub prompt_preview_chars: usize,
64    /// Resolved application root directory (`$ALC_HOME` or `~/.algocline/`).
65    ///
66    /// Wrapped in [`Arc`] so it can be shared across Service-layer
67    /// subsystems without cloning the underlying [`PathBuf`]. Exposed via
68    /// [`AppConfig::app_dir`] for read access; the `pub(super)` visibility
69    /// is only so that in-crate tests can use `..Default::default()` on
70    /// the struct literal (Service-layer production code MUST use the
71    /// accessor).
72    pub(super) app_dir: Arc<AppDir>,
73}
74
75impl AppConfig {
76    /// Build from environment variables (single resolution point).
77    pub fn from_env() -> Self {
78        let (log_dir, log_dir_source) = Self::resolve_log_dir();
79
80        let log_enabled = std::env::var("ALC_LOG_LEVEL")
81            .map(|v| v.to_lowercase() != "off")
82            .unwrap_or(true);
83
84        let prompt_preview_chars = std::env::var("ALC_PROMPT_PREVIEW_CHARS")
85            .ok()
86            .and_then(|s| s.parse::<usize>().ok())
87            .unwrap_or(algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS);
88
89        let app_dir = Arc::new(Self::resolve_app_dir());
90
91        Self {
92            log_dir,
93            log_dir_source,
94            log_enabled,
95            prompt_preview_chars,
96            app_dir,
97        }
98    }
99
100    /// Shared handle to the resolved application root directory.
101    pub fn app_dir(&self) -> Arc<AppDir> {
102        Arc::clone(&self.app_dir)
103    }
104
105    /// Override the application root directory — intended for tests that
106    /// need to redirect every `~/.algocline/` access to a `tempdir`.
107    pub fn with_app_dir(mut self, root: PathBuf) -> Self {
108        self.app_dir = Arc::new(AppDir::new(root));
109        self
110    }
111
112    /// Disable file logging — sets `log_dir = None`, `log_dir_source =
113    /// LogDirSource::None`, and `log_enabled = false`. Builder-chain
114    /// shorthand used by tests that want a quiet `AppService` rooted at
115    /// a tempdir without touching the developer's real log paths.
116    #[cfg(test)]
117    pub(crate) fn with_log_disabled(mut self) -> Self {
118        self.log_dir = None;
119        self.log_dir_source = LogDirSource::None;
120        self.log_enabled = false;
121        self
122    }
123
124    /// Resolve the application root directory.
125    ///
126    /// 1. `ALC_HOME` env var (explicit override — highest priority).
127    /// 2. `~/.algocline/` — home-based default.
128    /// 3. `./.algocline/` — fallback when `HOME` is unavailable; matches
129    ///    the sandbox-friendly fallback used for log directories.
130    fn resolve_app_dir() -> AppDir {
131        if let Ok(path) = std::env::var("ALC_HOME") {
132            if !path.is_empty() {
133                return AppDir::new(PathBuf::from(path));
134            }
135        }
136        let root = dirs::home_dir()
137            .map(|h| h.join(".algocline"))
138            .unwrap_or_else(|| PathBuf::from(".algocline"));
139        AppDir::new(root)
140    }
141
142    /// Resolve log directory with fallback chain.
143    ///
144    /// Tries each candidate in order, creating the directory if needed via
145    /// [`ensure_dir`](Self::ensure_dir). Returns `(Some(path), source)` on
146    /// the first writable candidate, or `(None, LogDirSource::None)` if every
147    /// candidate fails.
148    ///
149    /// ## Fallback order
150    ///
151    /// 1. `ALC_LOG_DIR` env var — explicit user/operator override.
152    /// 2. `~/.algocline/logs` — home-based default (most common).
153    /// 3. `$XDG_STATE_HOME/algocline/logs` (or `~/.local/state/…`).
154    /// 4. `<cwd>/algocline-logs` — **sandbox fallback**.
155    ///    In containerised / sandbox environments (Docker, CI runners,
156    ///    restricted shells) the home directory and XDG paths may not
157    ///    exist or may be read-only. The current working directory is
158    ///    often the only writable location available, so we fall back
159    ///    to it to preserve file logging in those environments.
160    /// 5. `None` — no writable directory found; file logging is disabled
161    ///    and the server operates in stderr-only tracing mode.
162    fn resolve_log_dir() -> (Option<PathBuf>, LogDirSource) {
163        // 1. ALC_LOG_DIR env (explicit override — highest priority)
164        if let Ok(dir) = std::env::var("ALC_LOG_DIR") {
165            let path = PathBuf::from(dir);
166            if Self::ensure_dir(&path) {
167                return (Some(path), LogDirSource::EnvVar);
168            }
169        }
170
171        // 2. ~/.algocline/logs (home-based default)
172        if let Some(home) = dirs::home_dir() {
173            let path = home.join(".algocline").join("logs");
174            if Self::ensure_dir(&path) {
175                return (Some(path), LogDirSource::Home);
176            }
177        }
178
179        // 3. state_dir (XDG_STATE_HOME or ~/.local/state)
180        if let Some(state) = dirs::state_dir() {
181            let path = state.join("algocline").join("logs");
182            if Self::ensure_dir(&path) {
183                return (Some(path), LogDirSource::StateDir);
184            }
185        }
186
187        // 4. Current working directory (sandbox fallback — see doc above)
188        if let Ok(cwd) = std::env::current_dir() {
189            let path = cwd.join("algocline-logs");
190            if Self::ensure_dir(&path) {
191                return (Some(path), LogDirSource::CurrentDir);
192            }
193        }
194
195        // 5. No writable directory — stderr-only
196        (None, LogDirSource::None)
197    }
198
199    /// Try to create the directory. Returns true if it exists and is writable.
200    fn ensure_dir(path: &Path) -> bool {
201        std::fs::create_dir_all(path).is_ok() && path.is_dir()
202    }
203}
204
205#[cfg(test)]
206impl Default for AppConfig {
207    /// Test-only default. `app_dir` points to a relative `./.algocline/`
208    /// — do not rely on this in production code; it would clobber
209    /// whatever the cwd happens to be. Production code constructs
210    /// [`AppConfig`] via [`AppConfig::from_env`] which resolves
211    /// `ALC_HOME` / `~/.algocline/` properly. The trait impl is
212    /// `#[cfg(test)]`-gated so a misuse in production won't compile.
213    fn default() -> Self {
214        Self {
215            log_dir: None,
216            log_dir_source: LogDirSource::None,
217            log_enabled: false,
218            prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
219            app_dir: Arc::new(AppDir::new(PathBuf::from(".algocline"))),
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn app_dir_env_overrides_home() {
230        let prev = std::env::var("ALC_HOME").ok();
231        std::env::set_var("ALC_HOME", "/tmp/alc-home-override");
232        let dir = AppConfig::resolve_app_dir();
233        assert_eq!(dir.root(), Path::new("/tmp/alc-home-override"));
234        match prev {
235            Some(v) => std::env::set_var("ALC_HOME", v),
236            None => std::env::remove_var("ALC_HOME"),
237        }
238    }
239
240    #[test]
241    fn with_app_dir_overrides_resolved() {
242        let cfg = AppConfig::default().with_app_dir(PathBuf::from("/tmp/alt"));
243        assert_eq!(cfg.app_dir().root(), Path::new("/tmp/alt"));
244    }
245
246    #[test]
247    fn app_dir_handle_is_shared() {
248        let cfg = AppConfig::default();
249        let a = cfg.app_dir();
250        let b = cfg.app_dir();
251        assert_eq!(Arc::strong_count(&a), 3); // cfg + a + b
252        assert_eq!(a.root(), b.root());
253    }
254}