1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
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());
}
}