mcp_sync/
utils.rs

1//! # mcp-sync utilities
2//!
3//! Common helper functions for file operations, JSON/TOML manipulation,
4//! and path resolution.
5
6use anyhow::{anyhow, Context, Result};
7use serde_json::Value as JsonValue;
8use std::{
9    fs,
10    path::{Path, PathBuf},
11};
12use time::OffsetDateTime;
13
14/// Returns the user's home directory.
15pub fn home() -> Result<PathBuf> {
16    dirs::home_dir().ok_or_else(|| anyhow!("no home directory found"))
17}
18
19/// Finds the git repository root by traversing up from `start`.
20/// Returns `start` if no `.git` directory is found.
21pub fn find_git_root(start: &Path) -> PathBuf {
22    let mut cur = start.to_path_buf();
23    loop {
24        if cur.join(".git").exists() {
25            return cur;
26        }
27        if !cur.pop() {
28            return start.to_path_buf();
29        }
30    }
31}
32
33/// Generates a timestamp string in `YYYYMMDD-HHMMSS` format.
34pub fn now_stamp() -> String {
35    let dt = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
36    dt.format(&time::format_description::parse("[year][month][day]-[hour][minute][second]").unwrap())
37        .unwrap()
38}
39
40/// Creates a backup copy of a file with a timestamped extension.
41///
42/// # Arguments
43/// * `path` - The file to back up
44///
45/// # Returns
46/// `Ok(())` if the backup was created or the file doesn't exist.
47pub fn backup(path: &Path) -> Result<()> {
48    if !path.exists() {
49        return Ok(());
50    }
51    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
52    let bak = path.with_extension(format!("{}.bak.{}", ext, now_stamp()));
53    fs::copy(path, &bak).with_context(|| format!("backup {:?} -> {:?}", path, bak))?;
54    Ok(())
55}
56
57/// Ensures that the parent directory of `path` exists.
58pub fn ensure_parent(path: &Path) -> Result<()> {
59    if let Some(p) = path.parent() {
60        fs::create_dir_all(p).with_context(|| format!("create dirs {:?}", p))?;
61    }
62    Ok(())
63}
64
65/// Prints a message to stderr if `verbose` is true.
66#[inline]
67pub fn log_verbose(verbose: bool, msg: impl AsRef<str>) {
68    if verbose {
69        eprintln!("{}", msg.as_ref());
70    }
71}
72
73// ─────────────────────────── JSON Helpers ───────────────────────────
74
75/// Reads a JSON file, returning an empty object if the file doesn't exist.
76pub fn read_json_file(path: &Path) -> Result<JsonValue> {
77    if !path.exists() {
78        return Ok(JsonValue::Object(serde_json::Map::new()));
79    }
80    let s = fs::read_to_string(path).with_context(|| format!("read {:?}", path))?;
81    let v: JsonValue = serde_json::from_str(&s).with_context(|| format!("parse JSON {:?}", path))?;
82    Ok(v)
83}
84
85/// Writes a JSON value to a file with pretty-printing.
86/// Creates a backup before writing.
87pub fn write_json_file(path: &Path, v: &JsonValue, dry_run: bool) -> Result<()> {
88    let s = serde_json::to_string_pretty(v).context("stringify JSON")? + "\n";
89    if dry_run {
90        return Ok(());
91    }
92    ensure_parent(path)?;
93    backup(path)?;
94    fs::write(path, s).with_context(|| format!("write {:?}", path))?;
95    Ok(())
96}
97
98/// Returns a mutable reference to the JSON object, or an error if not an object.
99pub fn json_obj_mut(v: &mut JsonValue) -> Result<&mut serde_json::Map<String, JsonValue>> {
100    v.as_object_mut()
101        .ok_or_else(|| anyhow!("expected JSON object"))
102}
103
104/// Gets or creates a nested object at `key`.
105pub fn json_get_obj_mut<'a>(
106    root: &'a mut JsonValue,
107    key: &str,
108) -> Result<&'a mut serde_json::Map<String, JsonValue>> {
109    let obj = json_obj_mut(root)?;
110    if !obj.contains_key(key) {
111        obj.insert(key.to_string(), JsonValue::Object(serde_json::Map::new()));
112    }
113    obj.get_mut(key)
114        .and_then(|v| v.as_object_mut())
115        .ok_or_else(|| anyhow!("expected object at key {}", key))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::fs;
122    use tempfile::TempDir;
123
124    // ─────────────────────────── find_git_root tests ───────────────────────────
125
126    #[test]
127    fn test_find_git_root_with_git_dir() {
128        let temp = TempDir::new().unwrap();
129        let git_dir = temp.path().join(".git");
130        fs::create_dir(&git_dir).unwrap();
131        
132        let result = find_git_root(temp.path());
133        assert_eq!(result, temp.path());
134    }
135
136    #[test]
137    fn test_find_git_root_in_subdirectory() {
138        let temp = TempDir::new().unwrap();
139        let git_dir = temp.path().join(".git");
140        fs::create_dir(&git_dir).unwrap();
141        
142        let subdir = temp.path().join("src").join("deep");
143        fs::create_dir_all(&subdir).unwrap();
144        
145        let result = find_git_root(&subdir);
146        assert_eq!(result, temp.path());
147    }
148
149    #[test]
150    fn test_find_git_root_no_git_returns_start() {
151        let temp = TempDir::new().unwrap();
152        let subdir = temp.path().join("some").join("path");
153        fs::create_dir_all(&subdir).unwrap();
154        
155        let result = find_git_root(&subdir);
156        assert_eq!(result, subdir);
157    }
158
159    // ─────────────────────────── now_stamp tests ───────────────────────────
160
161    #[test]
162    fn test_now_stamp_format() {
163        let stamp = now_stamp();
164        // Format: YYYYMMDD-HHMMSS (15 characters)
165        assert_eq!(stamp.len(), 15);
166        assert!(stamp.contains('-'));
167        
168        // Should be numeric except for the dash
169        let parts: Vec<&str> = stamp.split('-').collect();
170        assert_eq!(parts.len(), 2);
171        assert!(parts[0].chars().all(|c| c.is_ascii_digit()));
172        assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
173    }
174
175    // ─────────────────────────── ensure_parent tests ───────────────────────────
176
177    #[test]
178    fn test_ensure_parent_creates_directory() {
179        let temp = TempDir::new().unwrap();
180        let file_path = temp.path().join("deep").join("nested").join("file.json");
181        
182        ensure_parent(&file_path).unwrap();
183        
184        assert!(file_path.parent().unwrap().exists());
185    }
186
187    #[test]
188    fn test_ensure_parent_existing_directory() {
189        let temp = TempDir::new().unwrap();
190        let file_path = temp.path().join("file.json");
191        
192        // Should not fail for existing parent
193        ensure_parent(&file_path).unwrap();
194    }
195
196    // ─────────────────────────── backup tests ───────────────────────────
197
198    #[test]
199    fn test_backup_nonexistent_file_ok() {
200        let temp = TempDir::new().unwrap();
201        let file_path = temp.path().join("nonexistent.json");
202        
203        // Should succeed for nonexistent files
204        backup(&file_path).unwrap();
205    }
206
207    #[test]
208    fn test_backup_creates_backup_file() {
209        let temp = TempDir::new().unwrap();
210        let file_path = temp.path().join("config.json");
211        fs::write(&file_path, r#"{"test": true}"#).unwrap();
212        
213        backup(&file_path).unwrap();
214        
215        // Check that a backup file was created
216        let entries: Vec<_> = fs::read_dir(temp.path()).unwrap().collect();
217        assert_eq!(entries.len(), 2); // Original + backup
218        
219        // Verify backup contains same content
220        let backup_file = entries.iter()
221            .map(|e| e.as_ref().unwrap().path())
222            .find(|p| p.to_string_lossy().contains(".bak."))
223            .unwrap();
224        let backup_content = fs::read_to_string(&backup_file).unwrap();
225        assert_eq!(backup_content, r#"{"test": true}"#);
226    }
227
228    // ─────────────────────────── JSON helpers tests ───────────────────────────
229
230    #[test]
231    fn test_read_json_file_nonexistent_returns_empty_object() {
232        let temp = TempDir::new().unwrap();
233        let file_path = temp.path().join("nonexistent.json");
234        
235        let result = read_json_file(&file_path).unwrap();
236        
237        assert!(result.is_object());
238        assert!(result.as_object().unwrap().is_empty());
239    }
240
241    #[test]
242    fn test_read_json_file_valid_json() {
243        let temp = TempDir::new().unwrap();
244        let file_path = temp.path().join("config.json");
245        fs::write(&file_path, r#"{"name": "test", "value": 42}"#).unwrap();
246        
247        let result = read_json_file(&file_path).unwrap();
248        
249        assert_eq!(result["name"], "test");
250        assert_eq!(result["value"], 42);
251    }
252
253    #[test]
254    fn test_read_json_file_invalid_json_error() {
255        let temp = TempDir::new().unwrap();
256        let file_path = temp.path().join("invalid.json");
257        fs::write(&file_path, "not valid json {").unwrap();
258        
259        let result = read_json_file(&file_path);
260        
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_write_json_file_creates_file() {
266        let temp = TempDir::new().unwrap();
267        let file_path = temp.path().join("output.json");
268        let value = serde_json::json!({"key": "value"});
269        
270        write_json_file(&file_path, &value, false).unwrap();
271        
272        assert!(file_path.exists());
273        let content = fs::read_to_string(&file_path).unwrap();
274        assert!(content.contains(r#""key": "value""#));
275    }
276
277    #[test]
278    fn test_write_json_file_dry_run_no_write() {
279        let temp = TempDir::new().unwrap();
280        let file_path = temp.path().join("output.json");
281        let value = serde_json::json!({"key": "value"});
282        
283        write_json_file(&file_path, &value, true).unwrap();
284        
285        assert!(!file_path.exists());
286    }
287
288    #[test]
289    fn test_write_json_file_creates_parent_dirs() {
290        let temp = TempDir::new().unwrap();
291        let file_path = temp.path().join("deep").join("nested").join("output.json");
292        let value = serde_json::json!({});
293        
294        write_json_file(&file_path, &value, false).unwrap();
295        
296        assert!(file_path.exists());
297    }
298
299    #[test]
300    fn test_json_obj_mut_on_object() {
301        let mut value = serde_json::json!({"key": "value"});
302        
303        let obj = json_obj_mut(&mut value).unwrap();
304        obj.insert("new_key".to_string(), JsonValue::String("new_value".to_string()));
305        
306        assert_eq!(value["new_key"], "new_value");
307    }
308
309    #[test]
310    fn test_json_obj_mut_on_non_object_error() {
311        let mut value = serde_json::json!("string");
312        
313        let result = json_obj_mut(&mut value);
314        
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_json_get_obj_mut_existing_key() {
320        let mut value = serde_json::json!({"servers": {"test": {}}});
321        
322        let servers = json_get_obj_mut(&mut value, "servers").unwrap();
323        
324        assert!(servers.contains_key("test"));
325    }
326
327    #[test]
328    fn test_json_get_obj_mut_creates_key() {
329        let mut value = serde_json::json!({});
330        
331        let servers = json_get_obj_mut(&mut value, "servers").unwrap();
332        servers.insert("test".to_string(), serde_json::json!({}));
333        
334        assert!(value["servers"]["test"].is_object());
335    }
336
337    #[test]
338    fn test_json_get_obj_mut_nested_key_not_object_error() {
339        let mut value = serde_json::json!({"servers": "not an object"});
340        
341        let result = json_get_obj_mut(&mut value, "servers");
342        
343        assert!(result.is_err());
344    }
345
346    // ─────────────────────────── log_verbose tests ───────────────────────────
347
348    #[test]
349    fn test_log_verbose_false_no_panic() {
350        // Should not panic
351        log_verbose(false, "test message");
352    }
353
354    #[test]
355    fn test_log_verbose_true_no_panic() {
356        // Should not panic (output goes to stderr)
357        log_verbose(true, "test message");
358    }
359
360    // ─────────────────────────── home tests ───────────────────────────
361
362    #[test]
363    fn test_home_returns_path() {
364        let result = home();
365        // On most systems, this should succeed
366        assert!(result.is_ok());
367        assert!(result.unwrap().is_absolute());
368    }
369}
370