sc/config/mod.rs
1//! Configuration management.
2//!
3//! This module provides functions for discovering SaveContext directories,
4//! resolving database paths, and loading configuration.
5//!
6//! # Architecture
7//!
8//! SaveContext uses a **global database** architecture to match the MCP server:
9//! - **Database**: Single global database at `~/.savecontext/data/savecontext.db`
10//! - **Exports**: Per-project `.savecontext/` directories for JSONL sync files
11//!
12//! This allows the CLI and MCP server to share the same data, while each project
13//! maintains its own git-friendly JSONL exports.
14
15mod status_cache;
16
17pub use status_cache::{
18 bind_session_to_terminal, clear_status_cache, current_session_id, read_status_cache,
19 write_status_cache, StatusCacheEntry,
20};
21
22use crate::error::{Error, Result};
23
24use std::path::{Path, PathBuf};
25
26/// Discover the project-level SaveContext directory for JSONL exports.
27///
28/// Walks up from the current directory looking for `.savecontext/`.
29/// This is used for finding the per-project export directory, NOT the database.
30///
31/// # Returns
32///
33/// Returns the path to the project `.savecontext/` directory, or `None` if not found.
34///
35/// Resolution strategy:
36/// 1. Check the **git root** first — if the git root has `.savecontext/`, use it.
37/// This prevents subdirectory export dirs from shadowing the real project root.
38/// 2. Fall back to walking up from CWD (for non-git projects).
39#[must_use]
40pub fn discover_project_savecontext_dir() -> Option<PathBuf> {
41 // Strategy 1: Use git root as the anchor (handles monorepos/subdirectories)
42 if let Some(git_root) = git_toplevel() {
43 let candidate = git_root.join(".savecontext");
44 if candidate.exists() && candidate.is_dir() {
45 return Some(candidate);
46 }
47 }
48
49 // Strategy 2: Walk up from CWD (non-git projects)
50 if let Ok(cwd) = std::env::current_dir() {
51 let mut dir = cwd.as_path();
52 loop {
53 let candidate = dir.join(".savecontext");
54 if candidate.exists() && candidate.is_dir() {
55 return Some(candidate);
56 }
57
58 match dir.parent() {
59 Some(parent) => dir = parent,
60 None => break,
61 }
62 }
63 }
64 None
65}
66
67/// Get the git repository root directory.
68fn git_toplevel() -> Option<PathBuf> {
69 std::process::Command::new("git")
70 .args(["rev-parse", "--show-toplevel"])
71 .output()
72 .ok()
73 .filter(|o| o.status.success())
74 .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()))
75}
76
77/// Discover the SaveContext directory (legacy behavior).
78///
79/// Walks up from the current directory looking for `.savecontext/`,
80/// falling back to the global location.
81///
82/// **Note**: For new code, prefer:
83/// - `resolve_db_path()` for database access (uses global DB)
84/// - `discover_project_savecontext_dir()` for export directory (per-project)
85///
86/// # Returns
87///
88/// Returns the path to the `.savecontext/` directory, or `None` if not found.
89#[must_use]
90pub fn discover_savecontext_dir() -> Option<PathBuf> {
91 // First, try walking up from current directory
92 discover_project_savecontext_dir().or_else(global_savecontext_dir)
93}
94
95/// Get the global SaveContext directory location.
96///
97/// **Always uses `~/.savecontext/`** to match the MCP server location.
98/// This ensures CLI and MCP server share the same database.
99#[must_use]
100pub fn global_savecontext_dir() -> Option<PathBuf> {
101 directories::BaseDirs::new().map(|b| b.home_dir().join(".savecontext"))
102}
103
104/// Check if test mode is enabled.
105///
106/// Test mode is enabled by setting `SC_TEST_DB=1` (or any non-empty value).
107/// This redirects all database operations to an isolated test database.
108#[must_use]
109pub fn is_test_mode() -> bool {
110 std::env::var("SC_TEST_DB")
111 .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
112 .unwrap_or(false)
113}
114
115/// Get the test database path.
116///
117/// Returns `~/.savecontext/test/savecontext.db` for isolated testing.
118#[must_use]
119pub fn test_db_path() -> Option<PathBuf> {
120 global_savecontext_dir().map(|dir| dir.join("test").join("savecontext.db"))
121}
122
123/// Resolve the database path.
124///
125/// **Always uses the global database** to match MCP server architecture.
126/// The database is shared across all projects.
127///
128/// Priority:
129/// 1. If `explicit_path` is provided, use it directly
130/// 2. `SC_TEST_DB` environment variable → uses test database
131/// 3. `SAVECONTEXT_DB` environment variable
132/// 4. Global location: `~/.savecontext/data/savecontext.db`
133///
134/// # Test Mode
135///
136/// Set `SC_TEST_DB=1` to use `~/.savecontext/test/savecontext.db` instead.
137/// This keeps your production data safe during CLI development.
138///
139/// # Returns
140///
141/// Returns the path to the database file, or `None` if no location found.
142#[must_use]
143pub fn resolve_db_path(explicit_path: Option<&Path>) -> Option<PathBuf> {
144 // Priority 1: Explicit path from CLI flag
145 if let Some(path) = explicit_path {
146 return Some(path.to_path_buf());
147 }
148
149 // Priority 2: Test mode - use isolated test database
150 if is_test_mode() {
151 return test_db_path();
152 }
153
154 // Priority 3: SAVECONTEXT_DB environment variable
155 if let Ok(db_path) = std::env::var("SAVECONTEXT_DB") {
156 if !db_path.trim().is_empty() {
157 return Some(PathBuf::from(db_path));
158 }
159 }
160
161 // Priority 4: Global database location (matches MCP server)
162 global_savecontext_dir().map(|dir| dir.join("data").join("savecontext.db"))
163}
164
165/// Resolve the session ID for any CLI command.
166///
167/// This is the **single source of truth** for session resolution.
168/// Every session-scoped command must use this instead of the old
169/// `current_project_path() + list_sessions("active", 1)` pattern.
170///
171/// Priority:
172/// 1. Explicit `--session` flag (from CLI or MCP bridge)
173/// 2. `SC_SESSION` environment variable
174/// 3. TTY-keyed status cache (written by CLI/MCP on session start/resume)
175/// 4. **Error** — no fallback, no guessing
176pub fn resolve_session_id(explicit_session: Option<&str>) -> Result<String> {
177 // 1. Explicit session from CLI flag or MCP bridge
178 if let Some(id) = explicit_session {
179 return Ok(id.to_string());
180 }
181
182 // 2. SC_SESSION environment variable
183 if let Ok(id) = std::env::var("SC_SESSION") {
184 if !id.is_empty() {
185 return Ok(id);
186 }
187 }
188
189 // 3. TTY-keyed status cache
190 if let Some(id) = current_session_id() {
191 return Ok(id);
192 }
193
194 // 4. No session — hard error, never guess
195 Err(Error::NoActiveSession)
196}
197
198/// Resolve session ID with rich hints on failure.
199///
200/// Like [`resolve_session_id`], but on `NoActiveSession` queries the database
201/// for recent resumable sessions and enriches the error with suggestions.
202///
203/// Use this in command handlers that already have a `SqliteStorage` instance.
204pub fn resolve_session_or_suggest(
205 explicit_session: Option<&str>,
206 storage: &crate::storage::SqliteStorage,
207) -> Result<String> {
208 resolve_session_id(explicit_session).map_err(|e| {
209 if !matches!(e, Error::NoActiveSession) {
210 return e;
211 }
212
213 // Compute project path for session query
214 let project_path = current_project_path();
215 let pp_str = project_path.as_ref().map(|p| p.to_string_lossy().to_string());
216
217 // Query recent sessions that could be resumed
218 let recent = storage
219 .list_sessions(pp_str.as_deref(), None, Some(5))
220 .unwrap_or_default()
221 .into_iter()
222 .filter(|s| s.status == "active" || s.status == "paused")
223 .take(3)
224 .map(|s| {
225 (s.id.clone(), s.name.clone(), s.status.clone())
226 })
227 .collect::<Vec<_>>();
228
229 if recent.is_empty() {
230 e
231 } else {
232 Error::NoActiveSessionWithRecent { recent }
233 }
234 })
235}
236
237/// Get the current project path.
238///
239/// Returns the directory containing `.savecontext/`, which is the project root.
240/// This ensures all project-scoped data (memory, sessions) uses a consistent path
241/// regardless of which subdirectory the CLI is run from.
242#[must_use]
243pub fn current_project_path() -> Option<PathBuf> {
244 // Find the .savecontext directory, then return its parent (the project root)
245 discover_savecontext_dir().and_then(|sc_dir| sc_dir.parent().map(Path::to_path_buf))
246}
247
248/// Get the current git branch name.
249///
250/// Returns `None` if not in a git repository or if git command fails.
251#[must_use]
252pub fn current_git_branch() -> Option<String> {
253 std::process::Command::new("git")
254 .args(["rev-parse", "--abbrev-ref", "HEAD"])
255 .output()
256 .ok()
257 .filter(|output| output.status.success())
258 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
259}
260
261/// Get the default actor name.
262///
263/// Priority:
264/// 1. `SC_ACTOR` environment variable
265/// 2. Git user name
266/// 3. System username
267/// 4. "unknown"
268#[must_use]
269pub fn default_actor() -> String {
270 // Check environment variable
271 if let Ok(actor) = std::env::var("SC_ACTOR") {
272 if !actor.is_empty() {
273 return actor;
274 }
275 }
276
277 // Try git user name
278 if let Ok(output) = std::process::Command::new("git")
279 .args(["config", "user.name"])
280 .output()
281 {
282 if output.status.success() {
283 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
284 if !name.is_empty() {
285 return name;
286 }
287 }
288 }
289
290 // Try system username
291 if let Ok(user) = std::env::var("USER") {
292 return user;
293 }
294
295 "unknown".to_string()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_default_actor() {
304 let actor = default_actor();
305 assert!(!actor.is_empty());
306 }
307
308 #[test]
309 fn test_resolve_db_path_with_explicit() {
310 let explicit = PathBuf::from("/custom/path/db.sqlite");
311 let result = resolve_db_path(Some(&explicit));
312 assert_eq!(result, Some(explicit));
313 }
314
315 #[test]
316 fn test_resolve_db_path_uses_global_not_project() {
317 // Without explicit path, should resolve to global location
318 // (not walk up looking for per-project .savecontext/)
319 let result = resolve_db_path(None);
320 assert!(result.is_some());
321
322 let path = result.unwrap();
323 // Should contain "savecontext.db" and be in a global location
324 assert!(path.ends_with("savecontext.db"));
325 // Should NOT be in current directory's .savecontext/
326 // (it should be in ~/.savecontext or platform data dir)
327 }
328
329 #[test]
330 fn test_global_savecontext_dir_returns_some() {
331 let result = global_savecontext_dir();
332 assert!(result.is_some());
333 }
334
335 #[test]
336 fn test_test_db_path_is_separate() {
337 let global = global_savecontext_dir().unwrap();
338 let test = test_db_path().unwrap();
339
340 // Test path should be under test/ subdirectory
341 assert!(test.to_string_lossy().contains("/test/"));
342 // Should still end with savecontext.db
343 assert!(test.ends_with("savecontext.db"));
344 // Should be different from production path
345 assert_ne!(
346 global.join("data").join("savecontext.db"),
347 test
348 );
349 }
350
351 #[test]
352 fn test_is_test_mode_parsing() {
353 // Test the parsing logic directly (without modifying env vars)
354 // The actual env var behavior is tested via integration tests
355
356 // These values should be falsy
357 assert!(!("0" != "0" && "0".to_lowercase() != "false"));
358 assert!(!("false" != "0" && "false".to_lowercase() != "false"));
359 assert!(!("FALSE" != "0" && "FALSE".to_lowercase() != "false"));
360
361 // These values should be truthy
362 assert!("1" != "0" && "1".to_lowercase() != "false");
363 assert!("true" != "0" && "true".to_lowercase() != "false");
364 assert!("yes" != "0" && "yes".to_lowercase() != "false");
365 }
366}