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 app_dir = Arc::new(Self::resolve_app_dir());
79 let (log_dir, log_dir_source) = Self::resolve_log_dir(&app_dir);
80
81 let log_enabled = std::env::var("ALC_LOG_LEVEL")
82 .map(|v| v.to_lowercase() != "off")
83 .unwrap_or(true);
84
85 let prompt_preview_chars = std::env::var("ALC_PROMPT_PREVIEW_CHARS")
86 .ok()
87 .and_then(|s| s.parse::<usize>().ok())
88 .unwrap_or(algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS);
89
90 Self {
91 log_dir,
92 log_dir_source,
93 log_enabled,
94 prompt_preview_chars,
95 app_dir,
96 }
97 }
98
99 /// Shared handle to the resolved application root directory.
100 pub fn app_dir(&self) -> Arc<AppDir> {
101 Arc::clone(&self.app_dir)
102 }
103
104 /// Override the application root directory — intended for tests that
105 /// need to redirect every `~/.algocline/` access to a `tempdir`.
106 pub fn with_app_dir(mut self, root: PathBuf) -> Self {
107 self.app_dir = Arc::new(AppDir::new(root));
108 self
109 }
110
111 /// Disable file logging — sets `log_dir = None`, `log_dir_source =
112 /// LogDirSource::None`, and `log_enabled = false`. Builder-chain
113 /// shorthand used by tests that want a quiet `AppService` rooted at
114 /// a tempdir without touching the developer's real log paths.
115 #[cfg(test)]
116 pub(crate) fn with_log_disabled(mut self) -> Self {
117 self.log_dir = None;
118 self.log_dir_source = LogDirSource::None;
119 self.log_enabled = false;
120 self
121 }
122
123 /// Resolve the user's home directory for facilities that legitimately
124 /// need real `~` access (e.g. `~/.ssh/` key detection in
125 /// `gh_credentials::check_ssh_keys`).
126 ///
127 /// This is the single Service-layer HOME-read site aside from
128 /// [`Self::resolve_app_dir`] itself. Every other caller in
129 /// `crates/algocline-app/src/service/**` MUST route through this fn
130 /// (or `resolve_app_dir` / `AppDir`) so `check-invariants` Inv-1
131 /// keeps a single-source-of-truth whitelist. Returns `None` on
132 /// sandboxes where `HOME` cannot be resolved (mirroring
133 /// `resolve_app_dir`'s fallback path).
134 pub fn resolve_home() -> Option<PathBuf> {
135 dirs::home_dir()
136 }
137
138 /// Resolve the application root directory.
139 ///
140 /// 1. `ALC_HOME` env var (explicit override — highest priority).
141 /// 2. `~/.algocline/` — home-based default.
142 /// 3. `./.algocline/` — fallback when `HOME` is unavailable; matches
143 /// the sandbox-friendly fallback used for log directories.
144 fn resolve_app_dir() -> AppDir {
145 if let Ok(path) = std::env::var("ALC_HOME") {
146 if !path.is_empty() {
147 return AppDir::new(PathBuf::from(path));
148 }
149 }
150 let root = dirs::home_dir()
151 .map(|h| h.join(".algocline"))
152 .unwrap_or_else(|| PathBuf::from(".algocline"));
153 AppDir::new(root)
154 }
155
156 /// Resolve log directory with fallback chain.
157 ///
158 /// Tries each candidate in order, creating the directory if needed via
159 /// [`ensure_dir`](Self::ensure_dir). Returns `(Some(path), source)` on
160 /// the first writable candidate, or `(None, LogDirSource::None)` if every
161 /// candidate fails.
162 ///
163 /// ## Fallback order
164 ///
165 /// 1. `ALC_LOG_DIR` env var — explicit user/operator override.
166 /// 2. `{app_dir}/logs` — derived from the resolved [`AppDir`] so it
167 /// honors `ALC_HOME` when set; defaults to `~/.algocline/logs`
168 /// when no override is in effect.
169 /// 3. `$XDG_STATE_HOME/algocline/logs` (or `~/.local/state/…`).
170 /// 4. `<cwd>/algocline-logs` — **sandbox fallback**.
171 /// In containerised / sandbox environments (Docker, CI runners,
172 /// restricted shells) the home directory and XDG paths may not
173 /// exist or may be read-only. The current working directory is
174 /// often the only writable location available, so we fall back
175 /// to it to preserve file logging in those environments.
176 /// 5. `None` — no writable directory found; file logging is disabled
177 /// and the server operates in stderr-only tracing mode.
178 fn resolve_log_dir(app_dir: &AppDir) -> (Option<PathBuf>, LogDirSource) {
179 // 1. ALC_LOG_DIR env (explicit override — highest priority)
180 if let Ok(dir) = std::env::var("ALC_LOG_DIR") {
181 let path = PathBuf::from(dir);
182 if Self::ensure_dir(&path) {
183 return (Some(path), LogDirSource::EnvVar);
184 }
185 }
186
187 // 2. {app_dir}/logs — honors ALC_HOME via AppDir
188 let path = app_dir.logs_dir();
189 if Self::ensure_dir(&path) {
190 return (Some(path), LogDirSource::Home);
191 }
192
193 // 3. state_dir (XDG_STATE_HOME or ~/.local/state)
194 if let Some(state) = dirs::state_dir() {
195 let path = state.join("algocline").join("logs");
196 if Self::ensure_dir(&path) {
197 return (Some(path), LogDirSource::StateDir);
198 }
199 }
200
201 // 4. Current working directory (sandbox fallback — see doc above)
202 if let Ok(cwd) = std::env::current_dir() {
203 let path = cwd.join("algocline-logs");
204 if Self::ensure_dir(&path) {
205 return (Some(path), LogDirSource::CurrentDir);
206 }
207 }
208
209 // 5. No writable directory — stderr-only
210 (None, LogDirSource::None)
211 }
212
213 /// Try to create the directory. Returns true if it exists and is writable.
214 fn ensure_dir(path: &Path) -> bool {
215 std::fs::create_dir_all(path).is_ok() && path.is_dir()
216 }
217}
218
219#[cfg(test)]
220impl Default for AppConfig {
221 /// Test-only default. `app_dir` is rooted at a freshly created tempdir
222 /// whose handle is leaked (`mem::forget`); the OS reclaims the directory
223 /// when the test binary exits. This keeps every test isolated from cwd
224 /// and from other tests without requiring callers to remember to chain
225 /// `with_app_dir(tempdir)` after `AppConfig::default()` /
226 /// `..Default::default()`.
227 ///
228 /// The trait impl is `#[cfg(test)]`-gated so a misuse in production
229 /// code won't compile. Production code constructs [`AppConfig`] via
230 /// [`AppConfig::from_env`] which resolves `ALC_HOME` / `~/.algocline/`
231 /// properly.
232 fn default() -> Self {
233 let tmp = tempfile::tempdir().expect("AppConfig::default tempdir");
234 let root = tmp.path().to_path_buf();
235 // Leak the handle so the dir survives for the test duration.
236 std::mem::forget(tmp);
237 Self {
238 log_dir: None,
239 log_dir_source: LogDirSource::None,
240 log_enabled: false,
241 prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
242 app_dir: Arc::new(AppDir::new(root)),
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::super::test_support::with_env_var;
250 use super::*;
251
252 #[test]
253 fn app_dir_env_overrides_home() {
254 with_env_var("ALC_HOME", "/tmp/alc-home-override", || {
255 let dir = AppConfig::resolve_app_dir();
256 assert_eq!(dir.root(), Path::new("/tmp/alc-home-override"));
257 });
258 }
259
260 #[test]
261 fn with_app_dir_overrides_resolved() {
262 let cfg = AppConfig::default().with_app_dir(PathBuf::from("/tmp/alt"));
263 assert_eq!(cfg.app_dir().root(), Path::new("/tmp/alt"));
264 }
265
266 #[test]
267 fn app_dir_handle_is_shared() {
268 let cfg = AppConfig::default();
269 let a = cfg.app_dir();
270 let b = cfg.app_dir();
271 assert_eq!(Arc::strong_count(&a), 3); // cfg + a + b
272 assert_eq!(a.root(), b.root());
273 }
274}