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}