cascade_cli/utils/
platform.rs

1use std::path::{Path, PathBuf};
2
3/// Platform-specific utilities for handling cross-platform differences
4///
5/// This module centralizes platform detection and platform-specific behavior
6/// to ensure consistent handling across Windows, macOS, and Linux.
7///
8/// Get the appropriate PATH environment variable separator for the current platform
9pub fn path_separator() -> &'static str {
10    if cfg!(windows) {
11        ";"
12    } else {
13        ":"
14    }
15}
16
17/// Get the executable file extension for the current platform
18pub fn executable_extension() -> &'static str {
19    if cfg!(windows) {
20        ".exe"
21    } else {
22        ""
23    }
24}
25
26/// Add the appropriate executable extension to a binary name
27pub fn executable_name(name: &str) -> String {
28    format!("{}{}", name, executable_extension())
29}
30
31/// Check if a file is executable on the current platform
32pub fn is_executable(path: &Path) -> bool {
33    #[cfg(unix)]
34    {
35        use std::os::unix::fs::PermissionsExt;
36        if let Ok(metadata) = std::fs::metadata(path) {
37            // Check if any execute bit is set (owner, group, or other)
38            metadata.permissions().mode() & 0o111 != 0
39        } else {
40            false
41        }
42    }
43
44    #[cfg(windows)]
45    {
46        // On Windows, check if file exists and has executable extension
47        if !path.exists() {
48            return false;
49        }
50
51        if let Some(extension) = path.extension() {
52            let ext = extension.to_string_lossy().to_lowercase();
53            matches!(ext.as_str(), "exe" | "bat" | "cmd" | "com" | "scr" | "ps1")
54        } else {
55            false
56        }
57    }
58}
59
60/// Make a file executable on the current platform
61#[cfg(unix)]
62pub fn make_executable(path: &Path) -> std::io::Result<()> {
63    use std::os::unix::fs::PermissionsExt;
64
65    let mut perms = std::fs::metadata(path)?.permissions();
66    // Set executable for owner, group, and others (if they have read permission)
67    let current_mode = perms.mode();
68    let new_mode = current_mode | ((current_mode & 0o444) >> 2);
69    perms.set_mode(new_mode);
70    std::fs::set_permissions(path, perms)
71}
72
73#[cfg(windows)]
74pub fn make_executable(_path: &Path) -> std::io::Result<()> {
75    // On Windows, executability is determined by file extension, not permissions
76    // If we need to make something executable, we should ensure it has the right extension
77    Ok(())
78}
79
80/// Get platform-specific shell completion directories
81pub fn shell_completion_dirs() -> Vec<(String, PathBuf)> {
82    let mut dirs = Vec::new();
83
84    #[cfg(unix)]
85    {
86        // Standard Unix completion directories
87        if let Some(home) = dirs::home_dir() {
88            // User-specific directories
89            dirs.push(("bash (user)".to_string(), home.join(".bash_completion.d")));
90            dirs.push(("zsh (user)".to_string(), home.join(".zsh/completions")));
91            dirs.push((
92                "fish (user)".to_string(),
93                home.join(".config/fish/completions"),
94            ));
95        }
96
97        // System-wide directories (may require sudo)
98        dirs.push((
99            "bash (system)".to_string(),
100            PathBuf::from("/usr/local/etc/bash_completion.d"),
101        ));
102        dirs.push((
103            "bash (system alt)".to_string(),
104            PathBuf::from("/etc/bash_completion.d"),
105        ));
106        dirs.push((
107            "zsh (system)".to_string(),
108            PathBuf::from("/usr/local/share/zsh/site-functions"),
109        ));
110        dirs.push((
111            "zsh (system alt)".to_string(),
112            PathBuf::from("/usr/share/zsh/site-functions"),
113        ));
114        dirs.push((
115            "fish (system)".to_string(),
116            PathBuf::from("/usr/local/share/fish/completions"),
117        ));
118        dirs.push((
119            "fish (system alt)".to_string(),
120            PathBuf::from("/usr/share/fish/completions"),
121        ));
122    }
123
124    #[cfg(windows)]
125    {
126        // Windows-specific completion directories
127        if let Some(home) = dirs::home_dir() {
128            // PowerShell profile directory
129            let ps_profile_dir = home
130                .join("Documents")
131                .join("WindowsPowerShell")
132                .join("Modules");
133            dirs.push(("PowerShell (user)".to_string(), ps_profile_dir));
134
135            // Git Bash (if installed)
136            let git_bash_completion = home
137                .join("AppData")
138                .join("Local")
139                .join("Programs")
140                .join("Git")
141                .join("etc")
142                .join("bash_completion.d");
143            dirs.push(("Git Bash (user)".to_string(), git_bash_completion));
144        }
145
146        // System-wide directories
147        if let Ok(program_files) = std::env::var("PROGRAMFILES") {
148            let git_bash_system = PathBuf::from(program_files)
149                .join("Git")
150                .join("etc")
151                .join("bash_completion.d");
152            dirs.push(("Git Bash (system)".to_string(), git_bash_system));
153        }
154    }
155
156    dirs
157}
158
159/// Get platform-specific Git hook script extension
160pub fn git_hook_extension() -> &'static str {
161    if cfg!(windows) {
162        ".bat"
163    } else {
164        ""
165    }
166}
167
168/// Create platform-specific Git hook content
169pub fn create_git_hook_content(hook_name: &str, command: &str) -> String {
170    #[cfg(windows)]
171    {
172        format!(
173            "@echo off\n\
174             rem Cascade CLI Git Hook: {}\n\
175             rem Generated automatically - do not edit manually\n\n\
176             \"{}\" %*\n\
177             if %ERRORLEVEL% neq 0 exit /b %ERRORLEVEL%\n",
178            hook_name, command
179        )
180    }
181
182    #[cfg(not(windows))]
183    {
184        format!(
185            "#!/bin/sh\n\
186             # Cascade CLI Git Hook: {hook_name}\n\
187             # Generated automatically - do not edit manually\n\n\
188             exec \"{command}\" \"$@\"\n"
189        )
190    }
191}
192
193/// Get the default shell for the current platform
194pub fn default_shell() -> Option<String> {
195    #[cfg(windows)]
196    {
197        // On Windows, prefer PowerShell, then Command Prompt
198        if which_shell("powershell").is_some() {
199            Some("powershell".to_string())
200        } else if which_shell("cmd").is_some() {
201            Some("cmd".to_string())
202        } else {
203            None
204        }
205    }
206
207    #[cfg(not(windows))]
208    {
209        // On Unix, check common shells in order of preference
210        for shell in &["zsh", "bash", "fish", "sh"] {
211            if which_shell(shell).is_some() {
212                return Some(shell.to_string());
213            }
214        }
215        None
216    }
217}
218
219/// Find a shell executable in PATH
220fn which_shell(shell_name: &str) -> Option<PathBuf> {
221    let path_var = std::env::var("PATH").ok()?;
222    let executable_name = executable_name(shell_name);
223
224    for path_dir in path_var.split(path_separator()) {
225        let shell_path = PathBuf::from(path_dir).join(&executable_name);
226        if is_executable(&shell_path) {
227            return Some(shell_path);
228        }
229    }
230    None
231}
232
233/// Get platform-specific temporary directory with proper permissions
234pub fn secure_temp_dir() -> std::io::Result<PathBuf> {
235    let temp_dir = std::env::temp_dir();
236
237    #[cfg(unix)]
238    {
239        // On Unix, ensure temp directory has proper permissions (700 = rwx------)
240        use std::os::unix::fs::PermissionsExt;
241        let temp_subdir = temp_dir.join(format!("cascade-{}", std::process::id()));
242        std::fs::create_dir_all(&temp_subdir)?;
243
244        let mut perms = std::fs::metadata(&temp_subdir)?.permissions();
245        perms.set_mode(0o700);
246        std::fs::set_permissions(&temp_subdir, perms)?;
247        Ok(temp_subdir)
248    }
249
250    #[cfg(windows)]
251    {
252        // On Windows, create subdirectory but permissions are handled by ACLs
253        let temp_subdir = temp_dir.join(format!("cascade-{}", std::process::id()));
254        std::fs::create_dir_all(&temp_subdir)?;
255        Ok(temp_subdir)
256    }
257}
258
259/// Platform-specific line ending normalization
260pub fn normalize_line_endings(content: &str) -> String {
261    // Always normalize to Unix line endings internally
262    // Git will handle conversion based on core.autocrlf setting
263    content.replace("\r\n", "\n").replace('\r', "\n")
264}
265
266/// Get platform-specific environment variable for editor
267pub fn default_editor() -> Option<String> {
268    // Check common editor environment variables in order of preference
269    for var in &["EDITOR", "VISUAL"] {
270        if let Ok(editor) = std::env::var(var) {
271            if !editor.trim().is_empty() {
272                return Some(editor);
273            }
274        }
275    }
276
277    // Platform-specific defaults
278    #[cfg(windows)]
279    {
280        // On Windows, try notepad as last resort
281        Some("notepad".to_string())
282    }
283
284    #[cfg(not(windows))]
285    {
286        // On Unix, try common editors
287        for editor in &["nano", "vim", "vi"] {
288            if which_shell(editor).is_some() {
289                return Some(editor.to_string());
290            }
291        }
292        Some("vi".to_string()) // vi should always be available on Unix
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_path_separator() {
302        let separator = path_separator();
303        if cfg!(windows) {
304            assert_eq!(separator, ";");
305        } else {
306            assert_eq!(separator, ":");
307        }
308    }
309
310    #[test]
311    fn test_executable_extension() {
312        let ext = executable_extension();
313        if cfg!(windows) {
314            assert_eq!(ext, ".exe");
315        } else {
316            assert_eq!(ext, "");
317        }
318    }
319
320    #[test]
321    fn test_executable_name() {
322        let name = executable_name("test");
323        if cfg!(windows) {
324            assert_eq!(name, "test.exe");
325        } else {
326            assert_eq!(name, "test");
327        }
328    }
329
330    #[test]
331    fn test_git_hook_extension() {
332        let ext = git_hook_extension();
333        if cfg!(windows) {
334            assert_eq!(ext, ".bat");
335        } else {
336            assert_eq!(ext, "");
337        }
338    }
339
340    #[test]
341    fn test_line_ending_normalization() {
342        assert_eq!(normalize_line_endings("hello\r\nworld"), "hello\nworld");
343        assert_eq!(normalize_line_endings("hello\rworld"), "hello\nworld");
344        assert_eq!(normalize_line_endings("hello\nworld"), "hello\nworld");
345        assert_eq!(normalize_line_endings("hello world"), "hello world");
346    }
347
348    #[test]
349    fn test_shell_completion_dirs() {
350        let dirs = shell_completion_dirs();
351        assert!(
352            !dirs.is_empty(),
353            "Should return at least one completion directory"
354        );
355
356        // All paths should be absolute
357        for (name, path) in &dirs {
358            assert!(
359                path.is_absolute(),
360                "Completion directory should be absolute: {name} -> {path:?}"
361            );
362        }
363    }
364
365    #[test]
366    fn test_default_shell() {
367        // Should return some shell on any platform
368        let shell = default_shell();
369        if cfg!(windows) {
370            // On Windows, should prefer PowerShell or cmd
371            if let Some(shell_name) = shell {
372                assert!(shell_name == "powershell" || shell_name == "cmd");
373            }
374        } else {
375            // On Unix, should return a common shell
376            if let Some(shell_name) = shell {
377                assert!(["zsh", "bash", "fish", "sh"].contains(&shell_name.as_str()));
378            }
379        }
380    }
381
382    #[test]
383    fn test_git_hook_content() {
384        let content = create_git_hook_content("pre-commit", "/usr/bin/cc");
385
386        if cfg!(windows) {
387            assert!(content.contains("@echo off"));
388            assert!(content.contains(".bat"));
389            assert!(content.contains("ERRORLEVEL"));
390        } else {
391            assert!(content.starts_with("#!/bin/sh"));
392            assert!(content.contains("exec"));
393        }
394    }
395}