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/// Restore from a backup file
49pub fn restore_from_backup(backup_path: &Path, original_path: &Path) -> Result<()> {
50    if backup_path.exists() {
51        fs::copy(backup_path, original_path).map_err(IntentError::IoError)?;
52    }
53    Ok(())
54}
55
56/// Remove a backup file
57pub fn remove_backup(backup_path: &Path) -> Result<()> {
58    if backup_path.exists() {
59        fs::remove_file(backup_path).map_err(IntentError::IoError)?;
60    }
61    Ok(())
62}
63
64/// Read a JSON config file, or return empty object if it doesn't exist
65pub fn read_json_config(path: &Path) -> Result<Value> {
66    if path.exists() {
67        let content = fs::read_to_string(path).map_err(IntentError::IoError)?;
68        serde_json::from_str(&content)
69            .map_err(|e| IntentError::InvalidInput(format!("Failed to parse JSON config: {}", e)))
70    } else {
71        Ok(serde_json::json!({}))
72    }
73}
74
75/// Write a JSON config file with pretty formatting
76pub fn write_json_config(path: &Path, config: &Value) -> Result<()> {
77    // Ensure parent directory exists
78    if let Some(parent) = path.parent() {
79        fs::create_dir_all(parent).map_err(IntentError::IoError)?;
80    }
81
82    let content = serde_json::to_string_pretty(config)?;
83    fs::write(path, content).map_err(IntentError::IoError)?;
84    Ok(())
85}
86
87/// Find the ie binary path
88pub fn find_ie_binary() -> Result<PathBuf> {
89    // First, try to use the current executable path (most reliable in test/dev environments)
90    // When setup is called, it's running inside the `ie` binary, so current_exe() returns the ie path
91    if let Ok(current_exe) = env::current_exe() {
92        // Verify the binary name ends with 'ie' or 'intent-engine'
93        if let Some(file_name) = current_exe.file_name() {
94            let name = file_name.to_string_lossy();
95            if name == "ie"
96                || name.starts_with("ie.")
97                || name == "intent-engine"
98                || name.starts_with("intent-engine.")
99            {
100                return Ok(current_exe);
101            }
102        }
103    }
104
105    // Try CARGO_BIN_EXE_ie environment variable (set by cargo test in some cases)
106    if let Ok(path) = env::var("CARGO_BIN_EXE_ie") {
107        let binary = PathBuf::from(path);
108        if binary.exists() {
109            return Ok(binary);
110        }
111    }
112
113    // Try to find `ie` in PATH
114    which::which("ie")
115        .or_else(|_| {
116            // Try ~/.cargo/bin/ie
117            let home = get_home_dir()?;
118            let cargo_bin = home.join(".cargo").join("bin").join("ie");
119            if cargo_bin.exists() {
120                Ok(cargo_bin)
121            } else {
122                Err(IntentError::InvalidInput(
123                    "ie binary not found in PATH or ~/.cargo/bin".to_string(),
124                ))
125            }
126        })
127        .or_else(|_| {
128            // Try relative paths for development/testing
129            let candidate_paths = vec![
130                PathBuf::from("./target/debug/ie"),
131                PathBuf::from("./target/release/ie"),
132                PathBuf::from("../target/debug/ie"),
133                PathBuf::from("../target/release/ie"),
134            ];
135
136            for path in candidate_paths {
137                if path.exists() {
138                    return Ok(path);
139                }
140            }
141
142            Err(IntentError::InvalidInput(
143                "ie binary not found in relative paths".to_string(),
144            ))
145        })
146        .or_else(|_| {
147            // Fallback: try old `intent-engine` name (for backward compatibility)
148            which::which("intent-engine").map_err(|_| {
149                IntentError::InvalidInput(
150                    "intent-engine binary not found. Please install with: cargo install intent-engine".to_string()
151                )
152            })
153        })
154}
155
156/// Set executable permissions on Unix platforms
157#[cfg(unix)]
158pub fn set_executable(path: &Path) -> Result<()> {
159    use std::os::unix::fs::PermissionsExt;
160    let mut perms = fs::metadata(path)
161        .map_err(IntentError::IoError)?
162        .permissions();
163    perms.set_mode(0o755);
164    fs::set_permissions(path, perms).map_err(IntentError::IoError)?;
165    Ok(())
166}
167
168/// Set executable permissions on non-Unix platforms (no-op)
169#[cfg(not(unix))]
170pub fn set_executable(_path: &Path) -> Result<()> {
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::fs;
178    use tempfile::TempDir;
179
180    #[test]
181    fn test_resolve_absolute_path() {
182        let _temp = TempDir::new().unwrap();
183        let rel_path = PathBuf::from("test.txt");
184        let abs_path = resolve_absolute_path(&rel_path).unwrap();
185        assert!(abs_path.is_absolute());
186    }
187
188    #[test]
189    fn test_backup_and_restore() {
190        let temp = TempDir::new().unwrap();
191        let file_path = temp.path().join("test.json");
192        fs::write(&file_path, "original content").unwrap();
193
194        // Create backup
195        let backup = create_backup(&file_path).unwrap();
196        assert!(backup.is_some());
197        let backup_path = backup.unwrap();
198        assert!(backup_path.exists());
199
200        // Modify original
201        fs::write(&file_path, "modified content").unwrap();
202
203        // Restore from backup
204        restore_from_backup(&backup_path, &file_path).unwrap();
205        let content = fs::read_to_string(&file_path).unwrap();
206        assert_eq!(content, "original content");
207
208        // Clean up
209        remove_backup(&backup_path).unwrap();
210        assert!(!backup_path.exists());
211    }
212
213    #[test]
214    fn test_json_config_ops() {
215        let temp = TempDir::new().unwrap();
216        let config_path = temp.path().join("config.json");
217
218        // Read non-existent file
219        let config = read_json_config(&config_path).unwrap();
220        assert_eq!(config, serde_json::json!({}));
221
222        // Write config
223        let test_config = serde_json::json!({
224            "key": "value",
225            "number": 42
226        });
227        write_json_config(&config_path, &test_config).unwrap();
228        assert!(config_path.exists());
229
230        // Read back
231        let read_config = read_json_config(&config_path).unwrap();
232        assert_eq!(read_config, test_config);
233    }
234}