Skip to main content

sc/cli/commands/
init.rs

1//! Initialize a SaveContext workspace.
2//!
3//! # Architecture
4//!
5//! SaveContext uses a **global database** architecture:
6//! - **Global init (`sc init --global`)**: Creates the shared database at
7//!   `~/.savecontext/data/savecontext.db`. Run this once per machine.
8//! - **Project init (`sc init`)**: Creates per-project `.savecontext/` directory
9//!   for JSONL sync exports. Does NOT create a database.
10//!
11//! The database is shared across all projects, while each project maintains
12//! its own git-friendly JSONL exports.
13
14use crate::config::{global_savecontext_dir, is_test_mode};
15use crate::error::{Error, Result};
16use crate::sync::gitignore_content;
17use serde::Serialize;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21#[derive(Serialize)]
22struct InitOutput {
23    path: PathBuf,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    database: Option<PathBuf>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    export_dir: Option<PathBuf>,
28}
29
30/// Execute the init command.
31///
32/// - **Global mode**: Creates the shared database at `~/.savecontext/data/savecontext.db`
33/// - **Project mode**: Creates per-project `.savecontext/` for JSONL exports only
34///
35/// # Errors
36///
37/// Returns an error if the directory or database cannot be created.
38pub fn execute(global: bool, force: bool, json: bool) -> Result<()> {
39    if global {
40        execute_global_init(force, json)
41    } else {
42        execute_project_init(force, json)
43    }
44}
45
46/// Initialize the global SaveContext database.
47///
48/// Creates `~/.savecontext/data/savecontext.db`.
49/// When `SC_TEST_DB=1` is set, creates `~/.savecontext/test/savecontext.db` instead.
50/// This is a one-time setup per machine (or once per test cycle).
51fn execute_global_init(force: bool, json: bool) -> Result<()> {
52    let base_dir = global_savecontext_dir().ok_or_else(|| {
53        Error::Config("Could not determine global SaveContext directory".to_string())
54    })?;
55
56    // Use test/ subdirectory in test mode, data/ otherwise
57    let data_dir = if is_test_mode() {
58        base_dir.join("test")
59    } else {
60        base_dir.join("data")
61    };
62
63    // Check if already initialized
64    let db_path = data_dir.join("savecontext.db");
65    if db_path.exists() && !force {
66        return Err(Error::AlreadyInitialized { path: db_path });
67    }
68
69    // Create directory structure
70    fs::create_dir_all(&data_dir)?;
71
72    // Create empty database file (schema will be applied on first open)
73    if !db_path.exists() || force {
74        fs::File::create(&db_path)?;
75    }
76
77    // Write global .gitignore (for safety if someone puts this in git)
78    let gitignore_path = base_dir.join(".gitignore");
79    if !gitignore_path.exists() || force {
80        let gitignore = "# Everything in global SaveContext is local-only\n*\n";
81        fs::write(&gitignore_path, gitignore)?;
82    }
83
84    if json {
85        let output = InitOutput {
86            path: base_dir,
87            database: Some(db_path),
88            export_dir: None,
89        };
90        let payload = serde_json::to_string(&output)?;
91        println!("{payload}");
92    } else {
93        println!("Initialized global SaveContext database");
94        println!("  Database: {}", db_path.display());
95        println!();
96        println!("Next: Run 'sc init' in your project directories to set up JSONL sync.");
97    }
98
99    Ok(())
100}
101
102/// Initialize a project-level SaveContext directory for JSONL exports.
103///
104/// Creates `.savecontext/` in the current directory with config and gitignore.
105/// Does NOT create a database (uses global database).
106fn execute_project_init(force: bool, json: bool) -> Result<()> {
107    let base_dir = Path::new(".").join(".savecontext");
108
109    // Check if already initialized
110    if base_dir.exists() && !force {
111        return Err(Error::AlreadyInitialized { path: base_dir });
112    }
113
114    // Create directory
115    fs::create_dir_all(&base_dir)?;
116
117    // Write .gitignore (tracks JSONL files, ignores temp files)
118    let gitignore_path = base_dir.join(".gitignore");
119    if !gitignore_path.exists() || force {
120        fs::write(&gitignore_path, gitignore_content())?;
121    }
122
123    // Write config.json template
124    let config_path = base_dir.join("config.json");
125    if !config_path.exists() {
126        let config = r#"{
127  "default_priority": 2,
128  "default_type": "task"
129}
130"#;
131        fs::write(&config_path, config)?;
132    }
133
134    // Check if global database exists (respects test mode)
135    let db_subdir = if is_test_mode() { "test" } else { "data" };
136    let global_db = global_savecontext_dir()
137        .map(|dir| dir.join(db_subdir).join("savecontext.db"))
138        .filter(|p| p.exists());
139
140    if json {
141        let output = InitOutput {
142            path: base_dir,
143            database: global_db.clone(),
144            export_dir: Some(PathBuf::from(".savecontext")),
145        };
146        let payload = serde_json::to_string(&output)?;
147        println!("{payload}");
148    } else {
149        println!(
150            "Initialized SaveContext project in {}",
151            std::env::current_dir()
152                .map(|p| p.display().to_string())
153                .unwrap_or_else(|_| ".".to_string())
154        );
155        println!("  Export directory: .savecontext/");
156
157        if let Some(db) = global_db {
158            println!("  Database: {}", db.display());
159        } else {
160            println!();
161            println!(
162                "⚠️  Global database not found. Run 'sc init --global' first to create it."
163            );
164        }
165    }
166
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::sync::Mutex;
174    use tempfile::TempDir;
175
176    // Mutex to serialize tests that change current directory
177    static CWD_LOCK: Mutex<()> = Mutex::new(());
178
179    fn with_temp_cwd<F, R>(f: F) -> R
180    where
181        F: FnOnce(&Path) -> R,
182    {
183        let _lock = CWD_LOCK.lock().unwrap();
184        let original_cwd = std::env::current_dir().unwrap();
185        let temp_dir = TempDir::new().unwrap();
186        std::env::set_current_dir(temp_dir.path()).unwrap();
187
188        let result = f(temp_dir.path());
189
190        std::env::set_current_dir(original_cwd).unwrap();
191        result
192    }
193
194    #[test]
195    fn test_project_init_creates_export_directory() {
196        with_temp_cwd(|temp_path| {
197            let result = execute(false, false, false);
198            assert!(result.is_ok());
199
200            // Project init creates export directory
201            assert!(temp_path.join(".savecontext").exists());
202            assert!(temp_path.join(".savecontext/.gitignore").exists());
203            assert!(temp_path.join(".savecontext/config.json").exists());
204
205            // Project init does NOT create database (that's global)
206            assert!(!temp_path.join(".savecontext/data").exists());
207            assert!(!temp_path.join(".savecontext/data/savecontext.db").exists());
208        });
209    }
210
211    #[test]
212    fn test_project_init_fails_if_already_initialized() {
213        with_temp_cwd(|_| {
214            // First init should succeed
215            assert!(execute(false, false, false).is_ok());
216
217            // Second init without force should fail
218            let result = execute(false, false, false);
219            assert!(matches!(result, Err(Error::AlreadyInitialized { .. })));
220        });
221    }
222
223    #[test]
224    fn test_project_init_force_overwrites() {
225        with_temp_cwd(|_| {
226            assert!(execute(false, false, false).is_ok());
227            assert!(execute(false, true, false).is_ok()); // Force should succeed
228        });
229    }
230
231    // Note: Global init tests are harder to run in CI because they touch
232    // the user's actual home directory. In practice, global init is tested
233    // manually or with environment variable overrides.
234}