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
15pub mod plan_discovery;
16mod status_cache;
17
18pub use status_cache::{
19    bind_session_to_terminal, clear_status_cache, current_session_id, read_status_cache,
20    write_status_cache, StatusCacheEntry,
21};
22
23use crate::error::{Error, Result};
24use crate::model::Project;
25use crate::storage::SqliteStorage;
26use tracing::{debug, trace};
27
28use std::path::{Path, PathBuf};
29
30/// Discover the project-level SaveContext directory for JSONL exports.
31///
32/// Walks up from the current directory looking for `.savecontext/`.
33/// This is used for finding the per-project export directory, NOT the database.
34///
35/// # Returns
36///
37/// Returns the path to the project `.savecontext/` directory, or `None` if not found.
38///
39/// Resolution strategy:
40/// 1. Check the **git root** first — if the git root has `.savecontext/`, use it.
41///    This prevents subdirectory export dirs from shadowing the real project root.
42/// 2. Fall back to walking up from CWD (for non-git projects).
43#[must_use]
44pub fn discover_project_savecontext_dir() -> Option<PathBuf> {
45    // Strategy 1: Use git root as the anchor (handles monorepos/subdirectories)
46    if let Some(git_root) = git_toplevel() {
47        let candidate = git_root.join(".savecontext");
48        if candidate.exists() && candidate.is_dir() {
49            return Some(candidate);
50        }
51    }
52
53    // Strategy 2: Walk up from CWD (non-git projects)
54    if let Ok(cwd) = std::env::current_dir() {
55        let mut dir = cwd.as_path();
56        loop {
57            let candidate = dir.join(".savecontext");
58            if candidate.exists() && candidate.is_dir() {
59                return Some(candidate);
60            }
61
62            match dir.parent() {
63                Some(parent) => dir = parent,
64                None => break,
65            }
66        }
67    }
68    None
69}
70
71/// Get the git repository root directory.
72fn git_toplevel() -> Option<PathBuf> {
73    std::process::Command::new("git")
74        .args(["rev-parse", "--show-toplevel"])
75        .output()
76        .ok()
77        .filter(|o| o.status.success())
78        .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()))
79}
80
81/// Discover the SaveContext directory (legacy behavior).
82///
83/// Walks up from the current directory looking for `.savecontext/`,
84/// falling back to the global location.
85///
86/// **Note**: For new code, prefer:
87/// - `resolve_db_path()` for database access (uses global DB)
88/// - `discover_project_savecontext_dir()` for export directory (per-project)
89///
90/// # Returns
91///
92/// Returns the path to the `.savecontext/` directory, or `None` if not found.
93#[must_use]
94pub fn discover_savecontext_dir() -> Option<PathBuf> {
95    // First, try walking up from current directory
96    discover_project_savecontext_dir().or_else(global_savecontext_dir)
97}
98
99/// Get the global SaveContext directory location.
100///
101/// **Always uses `~/.savecontext/`** to match the MCP server location.
102/// This ensures CLI and MCP server share the same database.
103#[must_use]
104pub fn global_savecontext_dir() -> Option<PathBuf> {
105    directories::BaseDirs::new().map(|b| b.home_dir().join(".savecontext"))
106}
107
108/// Check if test mode is enabled.
109///
110/// Test mode is enabled by setting `SC_TEST_DB=1` (or any non-empty value).
111/// This redirects all database operations to an isolated test database.
112#[must_use]
113pub fn is_test_mode() -> bool {
114    std::env::var("SC_TEST_DB")
115        .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
116        .unwrap_or(false)
117}
118
119/// Get the test database path.
120///
121/// Returns `~/.savecontext/test/savecontext.db` for isolated testing.
122#[must_use]
123pub fn test_db_path() -> Option<PathBuf> {
124    global_savecontext_dir().map(|dir| dir.join("test").join("savecontext.db"))
125}
126
127/// Resolve the database path.
128///
129/// **Always uses the global database** to match MCP server architecture.
130/// The database is shared across all projects.
131///
132/// Priority:
133/// 1. If `explicit_path` is provided, use it directly
134/// 2. `SC_TEST_DB` environment variable → uses test database
135/// 3. `SAVECONTEXT_DB` environment variable
136/// 4. Global location: `~/.savecontext/data/savecontext.db`
137///
138/// # Test Mode
139///
140/// Set `SC_TEST_DB=1` to use `~/.savecontext/test/savecontext.db` instead.
141/// This keeps your production data safe during CLI development.
142///
143/// # Returns
144///
145/// Returns the path to the database file, or `None` if no location found.
146#[must_use]
147pub fn resolve_db_path(explicit_path: Option<&Path>) -> Option<PathBuf> {
148    // Priority 1: Explicit path from CLI flag
149    if let Some(path) = explicit_path {
150        trace!(path = %path.display(), source = "explicit", "DB path resolved");
151        return Some(path.to_path_buf());
152    }
153
154    // Priority 2: Test mode - use isolated test database
155    if is_test_mode() {
156        let path = test_db_path();
157        trace!(path = ?path, source = "test mode", "DB path resolved");
158        return path;
159    }
160
161    // Priority 3: SAVECONTEXT_DB environment variable
162    if let Ok(db_path) = std::env::var("SAVECONTEXT_DB") {
163        if !db_path.trim().is_empty() {
164            trace!(path = %db_path, source = "SAVECONTEXT_DB env", "DB path resolved");
165            return Some(PathBuf::from(db_path));
166        }
167    }
168
169    // Priority 4: Global database location (matches MCP server)
170    let path = global_savecontext_dir().map(|dir| dir.join("data").join("savecontext.db"));
171    trace!(path = ?path, source = "global default", "DB path resolved");
172    path
173}
174
175/// Resolve the session ID for any CLI command.
176///
177/// This is the **single source of truth** for session resolution.
178/// Every session-scoped command must use this instead of the old
179/// `current_project_path() + list_sessions("active", 1)` pattern.
180///
181/// Priority:
182/// 1. Explicit `--session` flag (from CLI or MCP bridge)
183/// 2. `SC_SESSION` environment variable
184/// 3. TTY-keyed status cache (written by CLI/MCP on session start/resume)
185/// 4. **Error** — no fallback, no guessing
186pub fn resolve_session_id(explicit_session: Option<&str>) -> Result<String> {
187    // 1. Explicit session from CLI flag or MCP bridge
188    if let Some(id) = explicit_session {
189        debug!(session = id, source = "explicit", "Session resolved");
190        return Ok(id.to_string());
191    }
192
193    // 2. SC_SESSION environment variable
194    if let Ok(id) = std::env::var("SC_SESSION") {
195        if !id.is_empty() {
196            debug!(session = %id, source = "SC_SESSION env", "Session resolved");
197            return Ok(id);
198        }
199    }
200
201    // 3. TTY-keyed status cache
202    if let Some(id) = current_session_id() {
203        debug!(session = %id, source = "TTY status cache", "Session resolved");
204        return Ok(id);
205    }
206
207    // 4. No session — hard error, never guess
208    debug!("Session resolution failed: no explicit, no env, no cache");
209    Err(Error::NoActiveSession)
210}
211
212/// Resolve session ID with rich hints on failure.
213///
214/// Like [`resolve_session_id`], but on `NoActiveSession` queries the database
215/// for recent resumable sessions and enriches the error with suggestions.
216///
217/// Use this in command handlers that already have a `SqliteStorage` instance.
218pub fn resolve_session_or_suggest(
219    explicit_session: Option<&str>,
220    storage: &crate::storage::SqliteStorage,
221) -> Result<String> {
222    resolve_session_id(explicit_session).map_err(|e| {
223        if !matches!(e, Error::NoActiveSession) {
224            return e;
225        }
226
227        // Compute project path for session query
228        let project_path = current_project_path();
229        let pp_str = project_path.as_ref().map(|p| p.to_string_lossy().to_string());
230
231        // Query recent sessions that could be resumed
232        let recent = storage
233            .list_sessions(pp_str.as_deref(), None, Some(5))
234            .unwrap_or_default()
235            .into_iter()
236            .filter(|s| s.status == "active" || s.status == "paused")
237            .take(3)
238            .map(|s| {
239                (s.id.clone(), s.name.clone(), s.status.clone())
240            })
241            .collect::<Vec<_>>();
242
243        if recent.is_empty() {
244            e
245        } else {
246            Error::NoActiveSessionWithRecent { recent }
247        }
248    })
249}
250
251/// Get the current project path.
252///
253/// Returns the directory containing `.savecontext/`, which is the project root.
254/// This ensures all project-scoped data (memory, sessions) uses a consistent path
255/// regardless of which subdirectory the CLI is run from.
256#[must_use]
257pub fn current_project_path() -> Option<PathBuf> {
258    // Find the .savecontext directory, then return its parent (the project root)
259    discover_savecontext_dir().and_then(|sc_dir| sc_dir.parent().map(Path::to_path_buf))
260}
261
262/// Resolve a project from explicit input or CWD.
263///
264/// Accepts a project ID, a filesystem path, or `None` for CWD auto-detection.
265/// Always validates against the database — sessions can only be created for
266/// registered projects.
267///
268/// # Resolution
269///
270/// **Explicit value (`Some`):**
271/// 1. Try as project ID (`storage.get_project`)
272/// 2. Try as filesystem path, canonicalized (`storage.get_project_by_path`)
273/// 3. Error with `ProjectNotFound`
274///
275/// **Auto-detect (`None`):**
276/// 1. Canonicalize CWD
277/// 2. Check CWD and parent directories against known project paths (longest match)
278/// 3. Fall back to `.savecontext/` directory discovery (legacy)
279/// 4. Error with `NoProjectForDirectory` listing available projects
280///
281/// # Errors
282///
283/// Returns `ProjectNotFound` or `NoProjectForDirectory` if no match is found.
284pub fn resolve_project(
285    storage: &SqliteStorage,
286    explicit: Option<&str>,
287) -> Result<Project> {
288    if let Some(value) = explicit {
289        // Try as project ID first
290        if let Some(project) = storage.get_project(value)? {
291            return Ok(project);
292        }
293
294        // Try as filesystem path (canonicalize for consistent matching)
295        let canon = std::path::PathBuf::from(value)
296            .canonicalize()
297            .map(|p| p.to_string_lossy().to_string())
298            .unwrap_or_else(|_| value.to_string());
299
300        if let Some(project) = storage.get_project_by_path(&canon)? {
301            return Ok(project);
302        }
303
304        return Err(Error::ProjectNotFound {
305            id: value.to_string(),
306        });
307    }
308
309    // Auto-detect from CWD
310    let cwd = std::env::current_dir()
311        .ok()
312        .and_then(|p| p.canonicalize().ok())
313        .map(|p| p.to_string_lossy().to_string())
314        .unwrap_or_else(|| ".".to_string());
315    trace!(cwd = %cwd, "Resolving project from CWD");
316
317    // Load all projects and find the best match (longest path that is a prefix of CWD)
318    let projects = storage.list_projects(200)?;
319    trace!(registered_projects = projects.len(), "Loaded projects for matching");
320
321    let mut best_match: Option<&Project> = None;
322    let mut best_len: usize = 0;
323
324    for project in &projects {
325        let pp = &project.project_path;
326        // CWD must equal or be a subdirectory of the project path
327        if cwd == *pp || cwd.starts_with(&format!("{pp}/")) {
328            if pp.len() > best_len {
329                best_len = pp.len();
330                best_match = Some(project);
331            }
332        }
333    }
334
335    if let Some(project) = best_match {
336        debug!(project = %project.project_path, name = %project.name, "Project resolved via CWD");
337        return Ok(project.clone());
338    }
339
340    // No match — error with suggestions
341    debug!(cwd = %cwd, "No project matched CWD");
342    let available: Vec<(String, String)> = projects
343        .iter()
344        .map(|p| (p.project_path.clone(), p.name.clone()))
345        .collect();
346
347    Err(Error::NoProjectForDirectory { cwd, available })
348}
349
350/// Resolve the project path from explicit input or CWD.
351///
352/// Convenience wrapper around [`resolve_project`] that returns just the path string.
353/// Use this in commands that need a project path but don't need the full `Project` object.
354///
355/// # Errors
356///
357/// Same as [`resolve_project`].
358pub fn resolve_project_path(
359    storage: &SqliteStorage,
360    explicit: Option<&str>,
361) -> Result<String> {
362    resolve_project(storage, explicit).map(|p| p.project_path)
363}
364
365/// Get the current git branch name.
366///
367/// Returns `None` if not in a git repository or if git command fails.
368#[must_use]
369pub fn current_git_branch() -> Option<String> {
370    std::process::Command::new("git")
371        .args(["rev-parse", "--abbrev-ref", "HEAD"])
372        .output()
373        .ok()
374        .filter(|output| output.status.success())
375        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
376}
377
378/// Get the default actor name.
379///
380/// Priority:
381/// 1. `SC_ACTOR` environment variable
382/// 2. Git user name
383/// 3. System username
384/// 4. "unknown"
385#[must_use]
386pub fn default_actor() -> String {
387    // Check environment variable
388    if let Ok(actor) = std::env::var("SC_ACTOR") {
389        if !actor.is_empty() {
390            return actor;
391        }
392    }
393
394    // Try git user name
395    if let Ok(output) = std::process::Command::new("git")
396        .args(["config", "user.name"])
397        .output()
398    {
399        if output.status.success() {
400            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
401            if !name.is_empty() {
402                return name;
403            }
404        }
405    }
406
407    // Try system username
408    if let Ok(user) = std::env::var("USER") {
409        return user;
410    }
411
412    "unknown".to_string()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_default_actor() {
421        let actor = default_actor();
422        assert!(!actor.is_empty());
423    }
424
425    #[test]
426    fn test_resolve_db_path_with_explicit() {
427        let explicit = PathBuf::from("/custom/path/db.sqlite");
428        let result = resolve_db_path(Some(&explicit));
429        assert_eq!(result, Some(explicit));
430    }
431
432    #[test]
433    fn test_resolve_db_path_uses_global_not_project() {
434        // Without explicit path, should resolve to global location
435        // (not walk up looking for per-project .savecontext/)
436        let result = resolve_db_path(None);
437        assert!(result.is_some());
438
439        let path = result.unwrap();
440        // Should contain "savecontext.db" and be in a global location
441        assert!(path.ends_with("savecontext.db"));
442        // Should NOT be in current directory's .savecontext/
443        // (it should be in ~/.savecontext or platform data dir)
444    }
445
446    #[test]
447    fn test_global_savecontext_dir_returns_some() {
448        let result = global_savecontext_dir();
449        assert!(result.is_some());
450    }
451
452    #[test]
453    fn test_test_db_path_is_separate() {
454        let global = global_savecontext_dir().unwrap();
455        let test = test_db_path().unwrap();
456
457        // Test path should be under test/ subdirectory
458        assert!(test.to_string_lossy().contains("/test/"));
459        // Should still end with savecontext.db
460        assert!(test.ends_with("savecontext.db"));
461        // Should be different from production path
462        assert_ne!(
463            global.join("data").join("savecontext.db"),
464            test
465        );
466    }
467
468    #[test]
469    fn test_is_test_mode_parsing() {
470        // Test the parsing logic directly (without modifying env vars)
471        // The actual env var behavior is tested via integration tests
472
473        // These values should be falsy
474        assert!(!("0" != "0" && "0".to_lowercase() != "false"));
475        assert!(!("false" != "0" && "false".to_lowercase() != "false"));
476        assert!(!("FALSE" != "0" && "FALSE".to_lowercase() != "false"));
477
478        // These values should be truthy
479        assert!("1" != "0" && "1".to_lowercase() != "false");
480        assert!("true" != "0" && "true".to_lowercase() != "false");
481        assert!("yes" != "0" && "yes".to_lowercase() != "false");
482    }
483}