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}