intent_engine/setup/
common.rs

1//! Common utilities for setup operations
2
3use crate::error::{IntentError, Result};
4use serde_json::Value;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Resolve a path to absolute canonical form
10pub fn resolve_absolute_path(path: &Path) -> Result<PathBuf> {
11    path.canonicalize().or_else(|_| {
12        // If canonicalize fails (e.g., file doesn't exist yet),
13        // try to make it absolute relative to current dir
14        if path.is_absolute() {
15            Ok(path.to_path_buf())
16        } else {
17            let current_dir = env::current_dir().map_err(IntentError::IoError)?;
18            Ok(current_dir.join(path))
19        }
20    })
21}
22
23/// Get the home directory
24pub fn get_home_dir() -> Result<PathBuf> {
25    env::var("HOME")
26        .or_else(|_| env::var("USERPROFILE"))
27        .map(PathBuf::from)
28        .map_err(|_| IntentError::InvalidInput("Cannot determine home directory".to_string()))
29}
30
31/// Create a backup of a file if it exists
32pub fn create_backup(file_path: &Path) -> Result<Option<PathBuf>> {
33    if !file_path.exists() {
34        return Ok(None);
35    }
36
37    let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
38    let backup_path = file_path.with_extension(format!(
39        "{}.backup.{}",
40        file_path.extension().and_then(|s| s.to_str()).unwrap_or(""),
41        timestamp
42    ));
43
44    fs::copy(file_path, &backup_path).map_err(IntentError::IoError)?;
45    Ok(Some(backup_path))
46}
47
48/// Read a JSON config file, or return empty object if it doesn't exist
49pub fn read_json_config(path: &Path) -> Result<Value> {
50    if path.exists() {
51        let content = fs::read_to_string(path).map_err(IntentError::IoError)?;
52        serde_json::from_str(&content)
53            .map_err(|e| IntentError::InvalidInput(format!("Failed to parse JSON config: {}", e)))
54    } else {
55        Ok(serde_json::json!({}))
56    }
57}
58
59/// Write a JSON config file with pretty formatting
60pub fn write_json_config(path: &Path, config: &Value) -> Result<()> {
61    // Ensure parent directory exists
62    if let Some(parent) = path.parent() {
63        fs::create_dir_all(parent).map_err(IntentError::IoError)?;
64    }
65
66    let content = serde_json::to_string_pretty(config)?;
67    fs::write(path, content).map_err(IntentError::IoError)?;
68    Ok(())
69}
70
71/// Find the ie binary path
72pub fn find_ie_binary() -> Result<PathBuf> {
73    // First, try to use the current executable path (most reliable in test/dev environments)
74    // When setup is called, it's running inside the `ie` binary, so current_exe() returns the ie path
75    if let Ok(current_exe) = env::current_exe() {
76        // Verify the binary name ends with 'ie' or 'intent-engine'
77        if let Some(file_name) = current_exe.file_name() {
78            let name = file_name.to_string_lossy();
79            if name == "ie"
80                || name.starts_with("ie.")
81                || name == "intent-engine"
82                || name.starts_with("intent-engine.")
83            {
84                return Ok(current_exe);
85            }
86        }
87    }
88
89    // Try CARGO_BIN_EXE_ie environment variable (set by cargo test in some cases)
90    if let Ok(path) = env::var("CARGO_BIN_EXE_ie") {
91        let binary = PathBuf::from(path);
92        if binary.exists() {
93            return Ok(binary);
94        }
95    }
96
97    // Try to find `ie` in PATH
98    which::which("ie")
99        .or_else(|_| {
100            // Try ~/.cargo/bin/ie
101            let home = get_home_dir()?;
102            let cargo_bin = home.join(".cargo").join("bin").join("ie");
103            if cargo_bin.exists() {
104                Ok(cargo_bin)
105            } else {
106                Err(IntentError::InvalidInput(
107                    "ie binary not found in PATH or ~/.cargo/bin".to_string(),
108                ))
109            }
110        })
111        .or_else(|_| {
112            // Try relative paths for development/testing
113            let candidate_paths = vec![
114                PathBuf::from("./target/debug/ie"),
115                PathBuf::from("./target/release/ie"),
116                PathBuf::from("../target/debug/ie"),
117                PathBuf::from("../target/release/ie"),
118            ];
119
120            for path in candidate_paths {
121                if path.exists() {
122                    return Ok(path);
123                }
124            }
125
126            Err(IntentError::InvalidInput(
127                "ie binary not found in relative paths".to_string(),
128            ))
129        })
130        .or_else(|_| {
131            // Fallback: try old `intent-engine` name (for backward compatibility)
132            which::which("intent-engine").map_err(|_| {
133                IntentError::InvalidInput(
134                    "intent-engine binary not found. Please install with: cargo install intent-engine".to_string()
135                )
136            })
137        })
138}
139
140/// Set executable permissions on Unix platforms
141#[cfg(unix)]
142pub fn set_executable(path: &Path) -> Result<()> {
143    use std::os::unix::fs::PermissionsExt;
144    let mut perms = fs::metadata(path)
145        .map_err(IntentError::IoError)?
146        .permissions();
147    perms.set_mode(0o755);
148    fs::set_permissions(path, perms).map_err(IntentError::IoError)?;
149    Ok(())
150}
151
152/// Set executable permissions on non-Unix platforms (no-op)
153#[cfg(not(unix))]
154pub fn set_executable(_path: &Path) -> Result<()> {
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::fs;
162    use tempfile::TempDir;
163
164    // ========== resolve_absolute_path tests ==========
165
166    #[test]
167    fn test_resolve_absolute_path() {
168        let _temp = TempDir::new().unwrap();
169        let rel_path = PathBuf::from("test.txt");
170        let abs_path = resolve_absolute_path(&rel_path).unwrap();
171        assert!(abs_path.is_absolute());
172    }
173
174    #[test]
175    fn test_resolve_absolute_path_already_absolute() {
176        let abs_path = PathBuf::from("/tmp/test.txt");
177        let result = resolve_absolute_path(&abs_path).unwrap();
178        assert!(result.is_absolute());
179    }
180
181    #[test]
182    fn test_resolve_absolute_path_relative() {
183        let rel_path = PathBuf::from("./test.txt");
184        let result = resolve_absolute_path(&rel_path).unwrap();
185        assert!(result.is_absolute());
186    }
187
188    // ========== get_home_dir tests ==========
189
190    #[test]
191    fn test_get_home_dir() {
192        let result = get_home_dir();
193        assert!(result.is_ok());
194
195        let home = result.unwrap();
196        assert!(home.is_absolute());
197    }
198
199    // ========== backup and restore tests ==========
200
201    #[test]
202    fn test_create_backup_creates_file() {
203        let temp = TempDir::new().unwrap();
204        let file_path = temp.path().join("test.json");
205        fs::write(&file_path, "original content").unwrap();
206
207        // Create backup
208        let backup = create_backup(&file_path).unwrap();
209        assert!(backup.is_some());
210        let backup_path = backup.unwrap();
211        assert!(backup_path.exists());
212
213        // Verify backup contains original content
214        let backup_content = fs::read_to_string(&backup_path).unwrap();
215        assert_eq!(backup_content, "original content");
216    }
217
218    #[test]
219    fn test_create_backup_nonexistent_file() {
220        let temp = TempDir::new().unwrap();
221        let file_path = temp.path().join("nonexistent.txt");
222
223        // Backup of non-existent file should return None
224        let backup = create_backup(&file_path).unwrap();
225        assert!(backup.is_none());
226    }
227
228    #[test]
229    fn test_create_backup_filename_format() {
230        let temp = TempDir::new().unwrap();
231        let file_path = temp.path().join("test.json");
232        fs::write(&file_path, "content").unwrap();
233
234        let backup = create_backup(&file_path).unwrap();
235        assert!(backup.is_some());
236
237        let backup_path = backup.unwrap();
238        let filename = backup_path.file_name().unwrap().to_string_lossy();
239
240        // Should contain .backup. in the filename
241        assert!(filename.contains(".backup."));
242    }
243
244    // ========== JSON config tests ==========
245
246    #[test]
247    fn test_json_config_ops() {
248        let temp = TempDir::new().unwrap();
249        let config_path = temp.path().join("config.json");
250
251        // Read non-existent file
252        let config = read_json_config(&config_path).unwrap();
253        assert_eq!(config, serde_json::json!({}));
254
255        // Write config
256        let test_config = serde_json::json!({
257            "key": "value",
258            "number": 42
259        });
260        write_json_config(&config_path, &test_config).unwrap();
261        assert!(config_path.exists());
262
263        // Read back
264        let read_config = read_json_config(&config_path).unwrap();
265        assert_eq!(read_config, test_config);
266    }
267
268    #[test]
269    fn test_read_json_config_invalid_json() {
270        let temp = TempDir::new().unwrap();
271        let config_path = temp.path().join("invalid.json");
272
273        // Write invalid JSON
274        fs::write(&config_path, "{invalid json}").unwrap();
275
276        // Should return error
277        let result = read_json_config(&config_path);
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn test_write_json_config_creates_parent_dir() {
283        let temp = TempDir::new().unwrap();
284        let config_path = temp.path().join("nested").join("dir").join("config.json");
285
286        let test_config = serde_json::json!({"test": "value"});
287        write_json_config(&config_path, &test_config).unwrap();
288
289        // Parent directory should be created
290        assert!(config_path.parent().unwrap().exists());
291        assert!(config_path.exists());
292    }
293
294    #[test]
295    fn test_write_json_config_pretty_format() {
296        let temp = TempDir::new().unwrap();
297        let config_path = temp.path().join("config.json");
298
299        let test_config = serde_json::json!({
300            "key": "value",
301            "nested": {
302                "item": 123
303            }
304        });
305        write_json_config(&config_path, &test_config).unwrap();
306
307        // Read as string to verify pretty formatting
308        let content = fs::read_to_string(&config_path).unwrap();
309
310        // Pretty-printed JSON should have newlines
311        assert!(content.contains('\n'));
312        assert!(content.contains("  ")); // Should have indentation
313    }
314
315    #[test]
316    fn test_json_config_complex_types() {
317        let temp = TempDir::new().unwrap();
318        let config_path = temp.path().join("complex.json");
319
320        let test_config = serde_json::json!({
321            "string": "value",
322            "number": 42,
323            "boolean": true,
324            "null": null,
325            "array": [1, 2, 3],
326            "object": {
327                "nested": "value"
328            }
329        });
330
331        write_json_config(&config_path, &test_config).unwrap();
332        let read_config = read_json_config(&config_path).unwrap();
333
334        assert_eq!(read_config, test_config);
335    }
336
337    // ========== find_ie_binary tests ==========
338
339    #[test]
340    fn test_find_ie_binary() {
341        // This test depends on the binary being available
342        let result = find_ie_binary();
343        // Should either find it or return a descriptive error
344        match result {
345            Ok(path) => {
346                // If found, should be a valid path
347                assert!(!path.to_string_lossy().is_empty());
348            },
349            Err(e) => {
350                // Error message should be descriptive
351                let msg = format!("{:?}", e);
352                assert!(
353                    msg.contains("binary not found")
354                        || msg.contains("intent-engine")
355                        || msg.contains("ie")
356                );
357            },
358        }
359    }
360
361    // ========== set_executable tests ==========
362
363    #[cfg(unix)]
364    #[test]
365    fn test_set_executable_unix() {
366        use std::os::unix::fs::PermissionsExt;
367
368        let temp = TempDir::new().unwrap();
369        let file_path = temp.path().join("script.sh");
370        fs::write(&file_path, "#!/bin/bash\necho test").unwrap();
371
372        // Set executable
373        set_executable(&file_path).unwrap();
374
375        // Check permissions
376        let metadata = fs::metadata(&file_path).unwrap();
377        let permissions = metadata.permissions();
378        let mode = permissions.mode();
379
380        // Should have execute permissions (0o755)
381        assert_ne!(mode & 0o111, 0); // At least one execute bit set
382    }
383
384    #[cfg(not(unix))]
385    #[test]
386    fn test_set_executable_non_unix() {
387        let temp = TempDir::new().unwrap();
388        let file_path = temp.path().join("script.sh");
389        fs::write(&file_path, "echo test").unwrap();
390
391        // Should not error on non-Unix platforms
392        let result = set_executable(&file_path);
393        assert!(result.is_ok());
394    }
395}