Skip to main content

sc/cli/commands/
config.rs

1//! Configuration management commands.
2//!
3//! Manages SaveContext settings including remote host configuration
4//! stored at `~/.savecontext/config.json`.
5
6use crate::cli::{ConfigCommands, ConfigRemoteCommands};
7use crate::error::{Error, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12// ── Types ────────────────────────────────────────────────────
13
14/// Top-level SaveContext configuration.
15#[derive(Debug, Serialize, Deserialize, Default)]
16pub struct SaveContextConfig {
17    #[serde(default)]
18    pub version: u32,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub remote: Option<RemoteConfig>,
21}
22
23/// Remote host configuration for SSH proxy and sync.
24#[derive(Debug, Serialize, Deserialize, Clone)]
25pub struct RemoteConfig {
26    pub host: String,
27    pub user: String,
28    #[serde(default = "default_port")]
29    pub port: u16,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub identity_file: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none", default = "default_sc_path")]
33    pub remote_sc_path: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub remote_project_path: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub remote_db_path: Option<String>,
38}
39
40fn default_port() -> u16 {
41    22
42}
43
44fn default_sc_path() -> Option<String> {
45    Some("sc".to_string())
46}
47
48// ── Public API ───────────────────────────────────────────────
49
50/// Execute config commands.
51pub fn execute(command: &ConfigCommands, json: bool) -> Result<()> {
52    match command {
53        ConfigCommands::Remote { command } => match command {
54            ConfigRemoteCommands::Set(args) => remote_set(args, json),
55            ConfigRemoteCommands::Show => remote_show(json),
56            ConfigRemoteCommands::Remove => remote_remove(json),
57        },
58    }
59}
60
61/// Load the SaveContext configuration file.
62///
63/// Returns default config if file doesn't exist or is invalid.
64pub fn load_config() -> SaveContextConfig {
65    let path = config_path();
66    if path.exists() {
67        fs::read_to_string(&path)
68            .ok()
69            .and_then(|s| serde_json::from_str(&s).ok())
70            .unwrap_or_default()
71    } else {
72        SaveContextConfig::default()
73    }
74}
75
76/// Load the remote configuration, returning an error if not configured.
77pub fn load_remote_config() -> Result<RemoteConfig> {
78    let config = load_config();
79    config.remote.ok_or_else(|| {
80        Error::Remote(
81            "No remote configured. Run: sc config remote set --host <host> --user <user>"
82                .to_string(),
83        )
84    })
85}
86
87/// Path to the global config file.
88pub fn config_path() -> PathBuf {
89    directories::BaseDirs::new()
90        .map(|b| b.home_dir().join(".savecontext").join("config.json"))
91        .unwrap_or_else(|| PathBuf::from(".savecontext/config.json"))
92}
93
94// ── Command Handlers ─────────────────────────────────────────
95
96fn remote_set(args: &crate::cli::RemoteSetArgs, json: bool) -> Result<()> {
97    let mut config = load_config();
98    config.version = 1;
99    config.remote = Some(RemoteConfig {
100        host: args.host.clone(),
101        user: args.user.clone(),
102        port: args.port,
103        identity_file: args.identity_file.clone(),
104        remote_sc_path: args.remote_sc_path.clone(),
105        remote_project_path: args.remote_project_path.clone(),
106        remote_db_path: args.remote_db_path.clone(),
107    });
108
109    save_config(&config)?;
110
111    if json {
112        let output = serde_json::json!({
113            "success": true,
114            "remote": config.remote,
115        });
116        println!("{}", serde_json::to_string(&output)?);
117    } else {
118        println!("Remote configuration saved.");
119        println!();
120        println!("  Host: {}@{}:{}", args.user, args.host, args.port);
121        if let Some(ref key) = args.identity_file {
122            println!("  Key:  {key}");
123        }
124        if let Some(ref path) = args.remote_project_path {
125            println!("  Path: {path}");
126        }
127        println!();
128        println!("Test with: sc remote version");
129    }
130
131    Ok(())
132}
133
134fn remote_show(json: bool) -> Result<()> {
135    let config = load_config();
136
137    if json {
138        let output = serde_json::json!({
139            "configured": config.remote.is_some(),
140            "remote": config.remote,
141        });
142        println!("{}", serde_json::to_string(&output)?);
143    } else if let Some(ref remote) = config.remote {
144        println!("Remote configuration:");
145        println!();
146        println!("  Host: {}@{}:{}", remote.user, remote.host, remote.port);
147        if let Some(ref key) = remote.identity_file {
148            println!("  Key:  {key}");
149        }
150        println!(
151            "  SC:   {}",
152            remote.remote_sc_path.as_deref().unwrap_or("sc")
153        );
154        if let Some(ref path) = remote.remote_project_path {
155            println!("  Path: {path}");
156        }
157        if let Some(ref db) = remote.remote_db_path {
158            println!("  DB:   {db}");
159        }
160    } else {
161        println!("No remote configured.");
162        println!("Run: sc config remote set --host <host> --user <user>");
163    }
164
165    Ok(())
166}
167
168fn remote_remove(json: bool) -> Result<()> {
169    let mut config = load_config();
170    let was_configured = config.remote.is_some();
171    config.remote = None;
172
173    save_config(&config)?;
174
175    if json {
176        let output = serde_json::json!({
177            "success": true,
178            "removed": was_configured,
179        });
180        println!("{}", serde_json::to_string(&output)?);
181    } else if was_configured {
182        println!("Remote configuration removed.");
183    } else {
184        println!("No remote configuration to remove.");
185    }
186
187    Ok(())
188}
189
190// ── SSH Helpers (shared by remote.rs and sync.rs) ───────────
191
192/// Shell-quote a string for safe interpolation into a remote shell command.
193///
194/// Wraps the value in single quotes and escapes any embedded single quotes
195/// using the `'\''` idiom (end quote, escaped literal quote, restart quote).
196pub fn shell_quote(s: &str) -> String {
197    format!("'{}'", s.replace('\'', "'\\''"))
198}
199
200/// Build base SSH connection args from remote config.
201///
202/// Returns args for: identity file, port, BatchMode, ConnectTimeout, user@host.
203/// Does NOT include the remote command — caller appends that.
204pub fn build_ssh_base_args(config: &RemoteConfig) -> Vec<String> {
205    let mut args = Vec::new();
206
207    if let Some(ref key) = config.identity_file {
208        args.push("-i".to_string());
209        args.push(key.clone());
210    }
211
212    if config.port != 22 {
213        args.push("-p".to_string());
214        args.push(config.port.to_string());
215    }
216
217    args.push("-o".to_string());
218    args.push("BatchMode=yes".to_string());
219    args.push("-o".to_string());
220    args.push("ConnectTimeout=10".to_string());
221
222    // Target: user@host
223    args.push(format!("{}@{}", config.user, config.host));
224
225    args
226}
227
228/// Build base SCP connection args from remote config.
229///
230/// Same as SSH but uses uppercase `-P` for port (SCP convention).
231/// Does NOT include source/destination paths — caller appends those.
232pub fn build_scp_base_args(config: &RemoteConfig) -> Vec<String> {
233    let mut args = Vec::new();
234
235    if let Some(ref key) = config.identity_file {
236        args.push("-i".to_string());
237        args.push(key.clone());
238    }
239
240    if config.port != 22 {
241        args.push("-P".to_string()); // SCP uses uppercase -P
242        args.push(config.port.to_string());
243    }
244
245    args.push("-o".to_string());
246    args.push("BatchMode=yes".to_string());
247    args.push("-o".to_string());
248    args.push("ConnectTimeout=10".to_string());
249
250    args
251}
252
253// ── Helpers ──────────────────────────────────────────────────
254
255fn save_config(config: &SaveContextConfig) -> Result<()> {
256    let path = config_path();
257    if let Some(parent) = path.parent() {
258        fs::create_dir_all(parent)
259            .map_err(|e| Error::Config(format!("Failed to create config directory: {e}")))?;
260    }
261
262    let json_str = serde_json::to_string_pretty(config)?;
263    fs::write(&path, format!("{json_str}\n"))
264        .map_err(|e| Error::Config(format!("Failed to write config: {e}")))?;
265
266    Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_default_config() {
275        let config = SaveContextConfig::default();
276        assert_eq!(config.version, 0);
277        assert!(config.remote.is_none());
278    }
279
280    #[test]
281    fn test_remote_config_serialization() {
282        let config = SaveContextConfig {
283            version: 1,
284            remote: Some(RemoteConfig {
285                host: "example.com".to_string(),
286                user: "shane".to_string(),
287                port: 22,
288                identity_file: None,
289                remote_sc_path: Some("sc".to_string()),
290                remote_project_path: None,
291                remote_db_path: None,
292            }),
293        };
294
295        let json = serde_json::to_string(&config).unwrap();
296        let parsed: SaveContextConfig = serde_json::from_str(&json).unwrap();
297
298        assert_eq!(parsed.version, 1);
299        let remote = parsed.remote.unwrap();
300        assert_eq!(remote.host, "example.com");
301        assert_eq!(remote.user, "shane");
302        assert_eq!(remote.port, 22);
303    }
304
305    #[test]
306    fn test_config_path_is_under_savecontext() {
307        let path = config_path();
308        assert!(path.to_string_lossy().contains(".savecontext"));
309        assert!(path.to_string_lossy().ends_with("config.json"));
310    }
311
312    #[test]
313    fn test_shell_quote_simple() {
314        assert_eq!(shell_quote("hello"), "'hello'");
315    }
316
317    #[test]
318    fn test_shell_quote_with_spaces() {
319        assert_eq!(shell_quote("/path/to/my project"), "'/path/to/my project'");
320    }
321
322    #[test]
323    fn test_shell_quote_with_semicolon() {
324        assert_eq!(shell_quote("foo; rm -rf /"), "'foo; rm -rf /'");
325    }
326
327    #[test]
328    fn test_shell_quote_with_single_quotes() {
329        assert_eq!(shell_quote("it's"), "'it'\\''s'");
330    }
331
332    #[test]
333    fn test_shell_quote_with_backticks() {
334        assert_eq!(shell_quote("`whoami`"), "'`whoami`'");
335    }
336
337    #[test]
338    fn test_build_ssh_base_args_includes_user_host() {
339        let config = RemoteConfig {
340            host: "example.com".to_string(),
341            user: "shane".to_string(),
342            port: 22,
343            identity_file: None,
344            remote_sc_path: None,
345            remote_project_path: None,
346            remote_db_path: None,
347        };
348        let args = build_ssh_base_args(&config);
349        assert!(args.contains(&"shane@example.com".to_string()));
350        assert!(!args.contains(&"-p".to_string()));
351    }
352
353    #[test]
354    fn test_build_scp_base_args_custom_port() {
355        let config = RemoteConfig {
356            host: "example.com".to_string(),
357            user: "shane".to_string(),
358            port: 2222,
359            identity_file: None,
360            remote_sc_path: None,
361            remote_project_path: None,
362            remote_db_path: None,
363        };
364        let args = build_scp_base_args(&config);
365        assert!(args.contains(&"-P".to_string())); // uppercase for SCP
366        assert!(args.contains(&"2222".to_string()));
367        // SCP base args should NOT include user@host
368        assert!(!args.contains(&"shane@example.com".to_string()));
369    }
370}