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 application root directory.
124 ///
125 /// 1. `ALC_HOME` env var (explicit override — highest priority).
126 /// 2. `~/.algocline/` — home-based default.
127 /// 3. `./.algocline/` — fallback when `HOME` is unavailable; matches
128 /// the sandbox-friendly fallback used for log directories.
129 fn resolve_app_dir() -> AppDir {
130 if let Ok(path) = std::env::var("ALC_HOME") {
131 if !path.is_empty() {
132 return AppDir::new(PathBuf::from(path));
133 }
134 }
135 let root = dirs::home_dir()
136 .map(|h| h.join(".algocline"))
137 .unwrap_or_else(|| PathBuf::from(".algocline"));
138 AppDir::new(root)
139 }
140
141 /// Resolve log directory with fallback chain.
142 ///
143 /// Tries each candidate in order, creating the directory if needed via
144 /// [`ensure_dir`](Self::ensure_dir). Returns `(Some(path), source)` on
145 /// the first writable candidate, or `(None, LogDirSource::None)` if every
146 /// candidate fails.
147 ///
148 /// ## Fallback order
149 ///
150 /// 1. `ALC_LOG_DIR` env var — explicit user/operator override.
151 /// 2. `{app_dir}/logs` — derived from the resolved [`AppDir`] so it
152 /// honors `ALC_HOME` when set; defaults to `~/.algocline/logs`
153 /// when no override is in effect.
154 /// 3. `$XDG_STATE_HOME/algocline/logs` (or `~/.local/state/…`).
155 /// 4. `<cwd>/algocline-logs` — **sandbox fallback**.
156 /// In containerised / sandbox environments (Docker, CI runners,
157 /// restricted shells) the home directory and XDG paths may not
158 /// exist or may be read-only. The current working directory is
159 /// often the only writable location available, so we fall back
160 /// to it to preserve file logging in those environments.
161 /// 5. `None` — no writable directory found; file logging is disabled
162 /// and the server operates in stderr-only tracing mode.
163 fn resolve_log_dir(app_dir: &AppDir) -> (Option<PathBuf>, LogDirSource) {
164 // 1. ALC_LOG_DIR env (explicit override — highest priority)
165 if let Ok(dir) = std::env::var("ALC_LOG_DIR") {
166 let path = PathBuf::from(dir);
167 if Self::ensure_dir(&path) {
168 return (Some(path), LogDirSource::EnvVar);
169 }
170 }
171
172 // 2. {app_dir}/logs — honors ALC_HOME via AppDir
173 let path = app_dir.logs_dir();
174 if Self::ensure_dir(&path) {
175 return (Some(path), LogDirSource::Home);
176 }
177
178 // 3. state_dir (XDG_STATE_HOME or ~/.local/state)
179 if let Some(state) = dirs::state_dir() {
180 let path = state.join("algocline").join("logs");
181 if Self::ensure_dir(&path) {
182 return (Some(path), LogDirSource::StateDir);
183 }
184 }
185
186 // 4. Current working directory (sandbox fallback — see doc above)
187 if let Ok(cwd) = std::env::current_dir() {
188 let path = cwd.join("algocline-logs");
189 if Self::ensure_dir(&path) {
190 return (Some(path), LogDirSource::CurrentDir);
191 }
192 }
193
194 // 5. No writable directory — stderr-only
195 (None, LogDirSource::None)
196 }
197
198 /// Try to create the directory. Returns true if it exists and is writable.
199 fn ensure_dir(path: &Path) -> bool {
200 std::fs::create_dir_all(path).is_ok() && path.is_dir()
201 }
202}
203
204#[cfg(test)]
205impl Default for AppConfig {
206 /// Test-only default. `app_dir` is rooted at a freshly created tempdir
207 /// whose handle is leaked (`mem::forget`); the OS reclaims the directory
208 /// when the test binary exits. This keeps every test isolated from cwd
209 /// and from other tests without requiring callers to remember to chain
210 /// `with_app_dir(tempdir)` after `AppConfig::default()` /
211 /// `..Default::default()`.
212 ///
213 /// The trait impl is `#[cfg(test)]`-gated so a misuse in production
214 /// code won't compile. Production code constructs [`AppConfig`] via
215 /// [`AppConfig::from_env`] which resolves `ALC_HOME` / `~/.algocline/`
216 /// properly.
217 fn default() -> Self {
218 let tmp = tempfile::tempdir().expect("AppConfig::default tempdir");
219 let root = tmp.path().to_path_buf();
220 // Leak the handle so the dir survives for the test duration.
221 std::mem::forget(tmp);
222 Self {
223 log_dir: None,
224 log_dir_source: LogDirSource::None,
225 log_enabled: false,
226 prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
227 app_dir: Arc::new(AppDir::new(root)),
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::super::test_support::with_env_var;
235 use super::*;
236
237 #[test]
238 fn app_dir_env_overrides_home() {
239 with_env_var("ALC_HOME", "/tmp/alc-home-override", || {
240 let dir = AppConfig::resolve_app_dir();
241 assert_eq!(dir.root(), Path::new("/tmp/alc-home-override"));
242 });
243 }
244
245 #[test]
246 fn with_app_dir_overrides_resolved() {
247 let cfg = AppConfig::default().with_app_dir(PathBuf::from("/tmp/alt"));
248 assert_eq!(cfg.app_dir().root(), Path::new("/tmp/alt"));
249 }
250
251 #[test]
252 fn app_dir_handle_is_shared() {
253 let cfg = AppConfig::default();
254 let a = cfg.app_dir();
255 let b = cfg.app_dir();
256 assert_eq!(Arc::strong_count(&a), 3); // cfg + a + b
257 assert_eq!(a.root(), b.root());
258 }
259}