cascade_cli/utils/
platform.rs1use std::path::{Path, PathBuf};
2
3pub fn path_separator() -> &'static str {
10 if cfg!(windows) {
11 ";"
12 } else {
13 ":"
14 }
15}
16
17pub fn executable_extension() -> &'static str {
19 if cfg!(windows) {
20 ".exe"
21 } else {
22 ""
23 }
24}
25
26pub fn executable_name(name: &str) -> String {
28 format!("{}{}", name, executable_extension())
29}
30
31pub 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 metadata.permissions().mode() & 0o111 != 0
39 } else {
40 false
41 }
42 }
43
44 #[cfg(windows)]
45 {
46 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#[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 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 Ok(())
78}
79
80pub fn shell_completion_dirs() -> Vec<(String, PathBuf)> {
82 let mut dirs = Vec::new();
83
84 #[cfg(unix)]
85 {
86 if let Some(home) = dirs::home_dir() {
88 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 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 if let Some(home) = dirs::home_dir() {
128 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 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 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
159pub fn git_hook_extension() -> &'static str {
161 if cfg!(windows) {
162 ".bat"
163 } else {
164 ""
165 }
166}
167
168pub 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
192pub fn default_shell() -> Option<String> {
194 #[cfg(windows)]
195 {
196 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 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
218fn 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
232pub fn secure_temp_dir() -> std::io::Result<PathBuf> {
234 let temp_dir = std::env::temp_dir();
235
236 #[cfg(unix)]
237 {
238 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 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
258pub fn normalize_line_endings(content: &str) -> String {
260 content.replace("\r\n", "\n").replace('\r', "\n")
263}
264
265pub fn default_editor() -> Option<String> {
267 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 #[cfg(windows)]
278 {
279 Some("notepad".to_string())
281 }
282
283 #[cfg(not(windows))]
284 {
285 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()) }
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 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 let shell = default_shell();
368 if cfg!(windows) {
369 if let Some(shell_name) = shell {
371 assert!(shell_name == "powershell" || shell_name == "cmd");
372 }
373 } else {
374 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}