Skip to main content

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}