1use crate::cli::{ConfigCommands, ConfigRemoteCommands};
7use crate::error::{Error, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12#[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#[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
48pub 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
61pub 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
76pub 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
87pub 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
94fn 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
190pub fn shell_quote(s: &str) -> String {
197 format!("'{}'", s.replace('\'', "'\\''"))
198}
199
200pub 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 args.push(format!("{}@{}", config.user, config.host));
224
225 args
226}
227
228pub 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()); 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
253fn 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())); assert!(args.contains(&"2222".to_string()));
367 assert!(!args.contains(&"shane@example.com".to_string()));
369 }
370}