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: {hook_name}\n\
175             rem Generated automatically - do not edit manually\n\n\
176             \"{command}\" %*\n\
177             if %ERRORLEVEL% neq 0 exit /b %ERRORLEVEL%\n"
178        )
179    }
180
181    #[cfg(not(windows))]
182    {
183        format!(
184            "#!/bin/sh\n\
185             # Cascade CLI Git Hook: {hook_name}\n\
186             # Generated automatically - do not edit manually\n\n\
187             exec \"{command}\" \"$@\"\n"
188        )
189    }
190}
191
192/// Get the default shell for the current platform
193pub fn default_shell() -> Option<String> {
194    #[cfg(windows)]
195    {
196        // On Windows, prefer PowerShell, then Command Prompt
197        if which_shell("powershell").is_some() {
198            Some("powershell".to_string())
199        } else if which_shell("cmd").is_some() {
200            Some("cmd".to_string())
201        } else {
202            None
203        }
204    }
205
206    #[cfg(not(windows))]
207    {
208        // On Unix, check common shells in order of preference
209        for shell in &["zsh", "bash", "fish", "sh"] {
210            if which_shell(shell).is_some() {
211                return Some(shell.to_string());
212            }
213        }
214        None
215    }
216}
217
218/// Find a shell executable in PATH
219fn which_shell(shell_name: &str) -> Option<PathBuf> {
220    let path_var = std::env::var("PATH").ok()?;
221    let executable_name = executable_name(shell_name);
222
223    for path_dir in path_var.split(path_separator()) {
224        let shell_path = PathBuf::from(path_dir).join(&executable_name);
225        if is_executable(&shell_path) {
226            return Some(shell_path);
227        }
228    }
229    None
230}
231
232/// Get platform-specific temporary directory with proper permissions
233pub fn secure_temp_dir() -> std::io::Result<PathBuf> {
234    let temp_dir = std::env::temp_dir();
235
236    #[cfg(unix)]
237    {
238        // On Unix, ensure temp directory has proper permissions (700 = rwx------)
239        use std::os::unix::fs::PermissionsExt;
240        let temp_subdir = temp_dir.join(format!("cascade-{}", std::process::id()));
241        std::fs::create_dir_all(&temp_subdir)?;
242
243        let mut perms = std::fs::metadata(&temp_subdir)?.permissions();
244        perms.set_mode(0o700);
245        std::fs::set_permissions(&temp_subdir, perms)?;
246        Ok(temp_subdir)
247    }
248
249    #[cfg(windows)]
250    {
251        // On Windows, create subdirectory but permissions are handled by ACLs
252        let temp_subdir = temp_dir.join(format!("cascade-{}", std::process::id()));
253        std::fs::create_dir_all(&temp_subdir)?;
254        Ok(temp_subdir)
255    }
256}
257
258/// Platform-specific line ending normalization
259pub fn normalize_line_endings(content: &str) -> String {
260    // Always normalize to Unix line endings internally
261    // Git will handle conversion based on core.autocrlf setting
262    content.replace("\r\n", "\n").replace('\r', "\n")
263}
264
265/// Get platform-specific environment variable for editor
266pub fn default_editor() -> Option<String> {
267    // Check common editor environment variables in order of preference
268    for var in &["EDITOR", "VISUAL"] {
269        if let Ok(editor) = std::env::var(var) {
270            if !editor.trim().is_empty() {
271                return Some(editor);
272            }
273        }
274    }
275
276    // Platform-specific defaults
277    #[cfg(windows)]
278    {
279        // On Windows, try notepad as last resort
280        Some("notepad".to_string())
281    }
282
283    #[cfg(not(windows))]
284    {
285        // On Unix, try common editors
286        for editor in &["nano", "vim", "vi"] {
287            if which_shell(editor).is_some() {
288                return Some(editor.to_string());
289            }
290        }
291        Some("vi".to_string()) // vi should always be available on Unix
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_path_separator() {
301        let separator = path_separator();
302        if cfg!(windows) {
303            assert_eq!(separator, ";");
304        } else {
305            assert_eq!(separator, ":");
306        }
307    }
308
309    #[test]
310    fn test_executable_extension() {
311        let ext = executable_extension();
312        if cfg!(windows) {
313            assert_eq!(ext, ".exe");
314        } else {
315            assert_eq!(ext, "");
316        }
317    }
318
319    #[test]
320    fn test_executable_name() {
321        let name = executable_name("test");
322        if cfg!(windows) {
323            assert_eq!(name, "test.exe");
324        } else {
325            assert_eq!(name, "test");
326        }
327    }
328
329    #[test]
330    fn test_git_hook_extension() {
331        let ext = git_hook_extension();
332        if cfg!(windows) {
333            assert_eq!(ext, ".bat");
334        } else {
335            assert_eq!(ext, "");
336        }
337    }
338
339    #[test]
340    fn test_line_ending_normalization() {
341        assert_eq!(normalize_line_endings("hello\r\nworld"), "hello\nworld");
342        assert_eq!(normalize_line_endings("hello\rworld"), "hello\nworld");
343        assert_eq!(normalize_line_endings("hello\nworld"), "hello\nworld");
344        assert_eq!(normalize_line_endings("hello world"), "hello world");
345    }
346
347    #[test]
348    fn test_shell_completion_dirs() {
349        let dirs = shell_completion_dirs();
350        assert!(
351            !dirs.is_empty(),
352            "Should return at least one completion directory"
353        );
354
355        // All paths should be absolute
356        for (name, path) in &dirs {
357            assert!(
358                path.is_absolute(),
359                "Completion directory should be absolute: {name} -> {path:?}"
360            );
361        }
362    }
363
364    #[test]
365    fn test_default_shell() {
366        // Should return some shell on any platform
367        let shell = default_shell();
368        if cfg!(windows) {
369            // On Windows, should prefer PowerShell or cmd
370            if let Some(shell_name) = shell {
371                assert!(shell_name == "powershell" || shell_name == "cmd");
372            }
373        } else {
374            // On Unix, should return a common shell
375            if let Some(shell_name) = shell {
376                assert!(["zsh", "bash", "fish", "sh"].contains(&shell_name.as_str()));
377            }
378        }
379    }
380
381    #[test]
382    fn test_git_hook_content() {
383        let content = create_git_hook_content("pre-commit", "/usr/bin/cc");
384
385        if cfg!(windows) {
386            assert!(content.contains("@echo off"));
387            assert!(content.contains("rem Cascade CLI Git Hook"));
388            assert!(content.contains("ERRORLEVEL"));
389            assert!(content.contains("/usr/bin/cc"));
390        } else {
391            assert!(content.starts_with("#!/bin/sh"));
392            assert!(content.contains("# Cascade CLI Git Hook"));
393            assert!(content.contains("exec"));
394            assert!(content.contains("/usr/bin/cc"));
395        }
396    }
397}