agpm_cli/utils/
platform.rs

1//! Platform-specific utilities and cross-platform compatibility helpers
2//!
3//! This module provides abstractions over platform differences to ensure AGPM
4//! works consistently across Windows, macOS, and Linux.
5
6use anyhow::{Context, Result};
7use regex::Regex;
8use std::path::{Path, PathBuf};
9
10/// Checks if the current platform is Windows.
11///
12/// Returns `true` on Windows, `false` on Unix-like systems (macOS, Linux, BSD).
13#[must_use]
14pub const fn is_windows() -> bool {
15    cfg!(windows)
16}
17
18/// Gets the home directory path for the current user.
19///
20/// Uses `%USERPROFILE%` on Windows, `$HOME` on Unix-like systems.
21pub fn get_home_dir() -> Result<PathBuf> {
22    dirs::home_dir().ok_or_else(|| {
23        let platform_help = if is_windows() {
24            "On Windows: Check that the USERPROFILE environment variable is set"
25        } else {
26            "On Unix/Linux: Check that the HOME environment variable is set"
27        };
28        anyhow::anyhow!("Could not determine home directory.\n\n{platform_help}")
29    })
30}
31
32/// Returns the appropriate Git command name for the current platform.
33///
34/// Returns `"git.exe"` on Windows, `"git"` on Unix-like systems.
35#[must_use]
36pub const fn get_git_command() -> &'static str {
37    if is_windows() {
38        "git.exe"
39    } else {
40        "git"
41    }
42}
43
44/// Resolves a path with tilde expansion and environment variable substitution.
45///
46/// Supports `~/path`, `$VAR` (Unix), `%VAR%` (Windows), and `${VAR}` syntax.
47pub fn resolve_path(path: &str) -> Result<PathBuf> {
48    let expanded = if let Some(stripped) = path.strip_prefix("~/") {
49        let home = get_home_dir()?;
50        home.join(stripped)
51    } else if path.starts_with('~') {
52        // Handle Windows-style user expansion like ~username
53        if is_windows() && path.len() > 1 && !path.starts_with("~/") {
54            return Err(anyhow::anyhow!(
55                "Invalid path: {path}\n\n\
56                Windows tilde expansion only supports '~/' for current user home directory.\n\
57                Use '~/' followed by a relative path, like '~/Documents/file.txt'"
58            ));
59        }
60        return Err(anyhow::anyhow!(
61            "Invalid path: {path}\n\n\
62            Tilde expansion only supports '~/' for home directory.\n\
63            Use '~/' followed by a relative path, like '~/Documents/file.txt'"
64        ));
65    } else {
66        PathBuf::from(path)
67    };
68
69    // Expand environment variables
70    let path_str = expanded.to_string_lossy();
71
72    // Handle Windows-style %VAR% expansion differently
73    let expanded_str = if is_windows() && path_str.contains('%') {
74        // Manual Windows-style %VAR% expansion
75        let mut result = path_str.to_string();
76        let re = Regex::new(r"%([^%]+)%").unwrap();
77
78        for cap in re.captures_iter(&path_str) {
79            if let Some(var_name) = cap.get(1)
80                && let Ok(value) = std::env::var(var_name.as_str())
81            {
82                result = result.replace(&format!("%{}%", var_name.as_str()), &value);
83            }
84        }
85
86        // Also handle Unix-style for compatibility
87        match shellexpand::env(&result) {
88            Ok(expanded) => expanded.into_owned(),
89            Err(_) => result, // Return the partially expanded result
90        }
91    } else {
92        // Unix-style $VAR expansion
93        shellexpand::env(&path_str)
94            .with_context(|| {
95                let platform_vars = if is_windows() {
96                    "Common Windows variables: %USERPROFILE%, %APPDATA%, %TEMP%"
97                } else {
98                    "Common Unix variables: $HOME, $USER, $TMP"
99                };
100
101                format!(
102                    "Failed to expand environment variables in path: {path_str}\n\n\
103                    Common issues:\n\
104                    - Undefined environment variable (e.g., $UNDEFINED_VAR)\n\
105                    - Invalid variable syntax (use $VAR or ${{VAR}})\n\
106                    - Special characters that need escaping\n\n\
107                    {platform_vars}"
108                )
109            })?
110            .into_owned()
111    };
112
113    let result = PathBuf::from(expanded_str);
114
115    // Apply Windows long path handling if needed
116    Ok(windows_long_path(&result))
117}
118
119/// Converts a path to use the correct separator for the current platform.
120///
121/// Converts `/` to `\` on Windows, `\` to `/` on Unix-like systems.
122#[must_use]
123pub fn normalize_path_separator(path: &Path) -> String {
124    if is_windows() {
125        path.to_string_lossy().replace('/', "\\")
126    } else {
127        path.to_string_lossy().replace('\\', "/")
128    }
129}
130
131/// Normalizes a path for cross-platform storage by converting all separators to forward slashes.
132///
133/// Critical for lockfiles, `.gitignore` entries, TOML/JSON files. Always use this for stored paths.
134#[must_use]
135pub fn normalize_path_for_storage<P: AsRef<Path>>(path: P) -> String {
136    let path_str = path.as_ref().to_string_lossy();
137
138    // Strip Windows extended-length path prefixes before normalization
139    // These prefixes are used internally by canonicalize() but shouldn't be stored
140    let cleaned = if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
141        // Extended UNC path: \\?\UNC\server\share -> //server/share
142        format!("//{}", stripped)
143    } else if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
144        // Extended path: \\?\C:\path -> C:\path
145        stripped.to_string()
146    } else {
147        path_str.to_string()
148    };
149
150    cleaned.replace('\\', "/")
151}
152
153/// Computes the relative install path by removing redundant directory prefixes.
154///
155/// Strips redundant prefixes to prevent duplication (e.g., `.claude/agents/agents/example.md`).
156/// Also strips prefixes that match any component in the tool root path to avoid
157/// `.claude/skills/agpm/skills/skill-name` when installing `skills/skill-name`.
158#[must_use]
159pub fn compute_relative_install_path(tool_root: &Path, dep_path: &Path, flatten: bool) -> PathBuf {
160    use std::path::Component;
161
162    // If flatten is true, return just the filename
163    if flatten {
164        if let Some(filename) = dep_path.file_name() {
165            return PathBuf::from(filename);
166        }
167        // Fallback to the original path if no filename
168        return dep_path.to_path_buf();
169    }
170
171    // Extract all Normal components from tool root for matching
172    let tool_components: Vec<&str> = tool_root
173        .components()
174        .filter_map(|c| {
175            if let Component::Normal(s) = c {
176                s.to_str()
177            } else {
178                None
179            }
180        })
181        .collect();
182
183    // Find the first Normal component and its position in the dependency path
184    let components: Vec<_> = dep_path.components().collect();
185    let (dep_first, first_normal_idx) = components
186        .iter()
187        .enumerate()
188        .find_map(|(idx, c)| {
189            if let Component::Normal(s) = c {
190                s.to_str().map(|s| (s, idx))
191            } else {
192                None
193            }
194        })
195        .map(|(s, idx)| (Some(s), Some(idx)))
196        .unwrap_or((None, None));
197
198    // If the first component of dep_path matches ANY component in tool_root,
199    // strip it to avoid duplication like `.claude/skills/agpm/skills/skill-name`
200    if let Some(dep_first_str) = dep_first {
201        if tool_components.contains(&dep_first_str) {
202            // Skip everything up to and including the matching Normal component
203            if let Some(idx) = first_normal_idx {
204                return components.iter().skip(idx + 1).collect();
205            }
206        }
207    }
208
209    // No match - return the full path (but skip any leading CurDir/ParentDir for cleanliness)
210    components
211        .iter()
212        .skip_while(|c| matches!(c, Component::CurDir | Component::Prefix(_) | Component::RootDir))
213        .collect()
214}
215
216/// Safely converts a path to a string, handling non-UTF-8 paths gracefully.
217///
218/// Uses lossy conversion (replacement character � for invalid UTF-8).
219#[must_use]
220pub fn path_to_string(path: &Path) -> String {
221    path.to_string_lossy().to_string()
222}
223
224/// Returns a path as an `OsStr` for use in command arguments.
225///
226/// Provides lossless path representation for system commands and APIs.
227#[must_use]
228pub fn path_to_os_str(path: &Path) -> &std::ffi::OsStr {
229    path.as_os_str()
230}
231
232/// Compares two paths for equality, respecting platform case sensitivity rules.
233///
234/// Case-insensitive on Windows, case-sensitive on Unix-like systems.
235#[must_use]
236pub fn paths_equal(path1: &Path, path2: &Path) -> bool {
237    if is_windows() {
238        // Windows file system is case-insensitive
239        // Normalize paths by removing trailing slashes before comparison
240        let p1_str = path1.to_string_lossy();
241        let p2_str = path2.to_string_lossy();
242        let p1 = p1_str.trim_end_matches(['/', '\\']).to_lowercase();
243        let p2 = p2_str.trim_end_matches(['/', '\\']).to_lowercase();
244        p1 == p2
245    } else {
246        // Unix-like systems are case-sensitive
247        // Also normalize trailing slashes for consistency
248        let p1_str = path1.to_string_lossy();
249        let p2_str = path2.to_string_lossy();
250        let p1 = p1_str.trim_end_matches('/');
251        let p2 = p2_str.trim_end_matches('/');
252        p1 == p2
253    }
254}
255
256/// Canonicalizes a path with proper cross-platform handling.
257///
258/// Resolves to absolute form, handles Windows long paths, resolves symlinks.
259pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
260    let canonical = path.canonicalize().with_context(|| {
261        format!(
262            "Failed to canonicalize path: {}\n\n\
263                Possible causes:\n\
264                - Path does not exist\n\
265                - Permission denied\n\
266                - Invalid path characters\n\
267                - Path too long (>260 chars on Windows)",
268            path.display()
269        )
270    })?;
271
272    #[cfg(windows)]
273    {
274        Ok(windows_long_path(&canonical))
275    }
276
277    #[cfg(not(windows))]
278    {
279        Ok(canonical)
280    }
281}
282
283/// Checks if a command is available in the system PATH.
284///
285/// Returns `true` if the command exists and is executable.
286#[must_use]
287pub fn command_exists(cmd: &str) -> bool {
288    which::which(cmd).is_ok()
289}
290
291/// Returns the platform-specific cache directory for AGPM.
292///
293/// Returns `{cache_dir}/agpm` following platform conventions (XDG on Linux).
294pub fn get_cache_dir() -> Result<PathBuf> {
295    dirs::cache_dir().map(|p| p.join("agpm")).ok_or_else(|| {
296        let platform_help = if is_windows() {
297            "On Windows: Check that the LOCALAPPDATA environment variable is set"
298        } else if cfg!(target_os = "macos") {
299            "On macOS: Check that the HOME environment variable is set"
300        } else {
301            "On Linux: Check that the XDG_CACHE_HOME or HOME environment variable is set"
302        };
303        anyhow::anyhow!("Could not determine cache directory.\n\n{platform_help}")
304    })
305}
306
307/// Returns the platform-specific data directory for AGPM.
308///
309/// Returns `{data_dir}/agpm` for persistent application data.
310pub fn get_data_dir() -> Result<PathBuf> {
311    dirs::data_dir().map(|p| p.join("agpm")).ok_or_else(|| {
312        let platform_help = if is_windows() {
313            "On Windows: Check that the APPDATA environment variable is set"
314        } else if cfg!(target_os = "macos") {
315            "On macOS: Check that the HOME environment variable is set"
316        } else {
317            "On Linux: Check that the XDG_DATA_HOME or HOME environment variable is set"
318        };
319        anyhow::anyhow!("Could not determine data directory.\n\n{platform_help}")
320    })
321}
322
323/// Handles Windows long paths (>260 characters) by applying UNC prefixes.
324///
325/// Applies `\\?\` prefix on Windows for paths >260 chars. No-op on other platforms.
326///
327/// # Performance
328/// Uses fast path for short paths (<200 chars) to avoid string conversions.
329/// The 200 char threshold provides safety margin below the 260 limit.
330#[cfg(windows)]
331#[must_use]
332pub fn windows_long_path(path: &Path) -> PathBuf {
333    // Fast path: paths under 200 chars can never exceed 260 limit
334    // even with relative-to-absolute conversion. This avoids to_string_lossy().
335    if path.as_os_str().len() < 200 {
336        return path.to_path_buf();
337    }
338
339    let path_str = path.to_string_lossy();
340    if path_str.len() > 260 && !path_str.starts_with(r"\\?\") {
341        // Convert to absolute path if relative
342        let absolute_path = if path.is_relative() {
343            std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
344        } else {
345            path.to_path_buf()
346        };
347
348        let absolute_str = absolute_path.to_string_lossy();
349        if absolute_str.len() > 260 {
350            // Use UNC prefix for long paths
351            if let Some(stripped) = absolute_str.strip_prefix(r"\\") {
352                // Network path
353                PathBuf::from(format!(r"\\?\UNC\{}", stripped))
354            } else {
355                // Local path
356                PathBuf::from(format!(r"\\?\{}", absolute_str))
357            }
358        } else {
359            absolute_path
360        }
361    } else {
362        path.to_path_buf()
363    }
364}
365
366/// No-op implementation of [`windows_long_path`] for non-Windows platforms.
367#[cfg(not(windows))]
368#[must_use]
369pub fn windows_long_path(path: &Path) -> PathBuf {
370    path.to_path_buf()
371}
372
373/// Returns the appropriate shell command and flag for the current platform.
374///
375/// Returns `("cmd", "/C")` on Windows, `("sh", "-c")` on Unix-like systems.
376#[must_use]
377pub const fn get_shell_command() -> (&'static str, &'static str) {
378    if is_windows() {
379        ("cmd", "/C")
380    } else {
381        ("sh", "-c")
382    }
383}
384
385/// Validates that a path contains only characters valid for the current platform.
386///
387/// Checks for invalid characters and Windows reserved names (CON, PRN, etc.).
388pub fn validate_path_chars(path: &str) -> Result<()> {
389    if is_windows() {
390        // Windows invalid characters: < > : " | ? * and control characters
391        const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
392
393        for ch in path.chars() {
394            if INVALID_CHARS.contains(&ch) || ch.is_control() {
395                return Err(anyhow::anyhow!(
396                    "Invalid character '{ch}' in path: {path}\\n\\n\\\n                    Windows paths cannot contain: < > : \" | ? * or control characters"
397                ));
398            }
399        }
400
401        // Check for reserved names in all path components
402        const RESERVED_NAMES: &[&str] = &[
403            "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
404            "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
405        ];
406
407        // Check each component of the path
408        for component in Path::new(path).components() {
409            if let Some(os_str) = component.as_os_str().to_str() {
410                // Check if the entire component (without extension) is a reserved name
411                // Reserved names are only invalid if they're the complete name (no extension)
412                let upper = os_str.to_uppercase();
413
414                // Check if it's exactly a reserved name (no extension)
415                if RESERVED_NAMES.contains(&upper.as_str()) {
416                    return Err(anyhow::anyhow!(
417                        "Reserved name '{}' in path: {}\\n\\n\\\n                    Windows reserved names: {}",
418                        os_str,
419                        path,
420                        RESERVED_NAMES.join(", ")
421                    ));
422                }
423            }
424        }
425    }
426
427    Ok(())
428}
429
430/// Safely joins a base path with a relative path, preventing directory traversal.
431///
432/// Validates characters and prevents `../` escape attempts.
433pub fn safe_join(base: &Path, path: &str) -> Result<PathBuf> {
434    // Validate the path characters first
435    validate_path_chars(path)?;
436
437    let path_buf = PathBuf::from(path);
438
439    // Check for path traversal attempts
440    if path.contains("..") {
441        let joined = base.join(&path_buf);
442        let normalized = crate::utils::fs::normalize_path(&joined);
443        if !normalized.starts_with(base) {
444            return Err(anyhow::anyhow!(
445                "Path traversal detected in: {path}\\n\\n\\\n                Attempted to access path outside base directory"
446            ));
447        }
448    }
449
450    let result = base.join(path_buf);
451    Ok(windows_long_path(&result))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_is_windows() {
460        #[cfg(windows)]
461        assert!(is_windows());
462
463        #[cfg(not(windows))]
464        assert!(!is_windows());
465    }
466
467    #[test]
468    fn test_git_command() {
469        let cmd = get_git_command();
470        #[cfg(windows)]
471        assert_eq!(cmd, "git.exe");
472
473        #[cfg(not(windows))]
474        assert_eq!(cmd, "git");
475    }
476
477    #[test]
478    fn test_get_home_dir() -> Result<()> {
479        let home_path = get_home_dir()?;
480        assert!(home_path.exists());
481        Ok(())
482    }
483
484    #[test]
485    fn test_resolve_path_tilde() {
486        let home = get_home_dir().unwrap();
487
488        let resolved = resolve_path("~/test").unwrap();
489        assert_eq!(resolved, home.join("test"));
490
491        let resolved = resolve_path("~/test/file.txt").unwrap();
492        assert_eq!(resolved, home.join("test/file.txt"));
493    }
494
495    #[test]
496    fn test_resolve_path_absolute() {
497        let resolved = resolve_path("/tmp/test").unwrap();
498        assert_eq!(resolved, PathBuf::from("/tmp/test"));
499    }
500
501    #[test]
502    fn test_resolve_path_relative() {
503        let resolved = resolve_path("test/file.txt").unwrap();
504        assert_eq!(resolved, PathBuf::from("test/file.txt"));
505    }
506
507    #[test]
508    fn test_resolve_path_invalid_tilde() {
509        let result = resolve_path("~test");
510        assert!(result.is_err());
511    }
512
513    #[test]
514    fn test_normalize_path_separator() {
515        let path = Path::new("test/path/file.txt");
516        let normalized = normalize_path_separator(path);
517
518        #[cfg(windows)]
519        assert_eq!(normalized, "test\\path\\file.txt");
520
521        #[cfg(not(windows))]
522        assert_eq!(normalized, "test/path/file.txt");
523    }
524
525    #[test]
526    fn test_normalize_path_for_storage() {
527        // Test Unix-style path (should remain unchanged)
528        let unix_path = Path::new(".claude/agents/example.md");
529        assert_eq!(normalize_path_for_storage(unix_path), ".claude/agents/example.md");
530
531        // Test Windows-style path (should convert to forward slashes)
532        let windows_path = Path::new(".claude\\agents\\example.md");
533        assert_eq!(normalize_path_for_storage(windows_path), ".claude/agents/example.md");
534
535        // Test mixed separators (should normalize all to forward slashes)
536        let mixed_path = Path::new("src/utils\\platform.rs");
537        assert_eq!(normalize_path_for_storage(mixed_path), "src/utils/platform.rs");
538
539        // Test nested Windows path
540        let nested = Path::new(".claude\\agents\\ai\\gpt.md");
541        assert_eq!(normalize_path_for_storage(nested), ".claude/agents/ai/gpt.md");
542
543        // Test that result is always forward slashes regardless of platform
544        let path = Path::new("test\\nested\\path\\file.txt");
545        let normalized = normalize_path_for_storage(path);
546        assert_eq!(normalized, "test/nested/path/file.txt");
547        assert!(!normalized.contains('\\'));
548    }
549
550    #[test]
551    fn test_command_exists() {
552        // Test with a command that should exist on all systems
553        #[cfg(unix)]
554        assert!(command_exists("sh"));
555
556        #[cfg(windows)]
557        assert!(command_exists("cmd"));
558
559        // Test with a command that shouldn't exist
560        assert!(!command_exists("this_command_should_not_exist_12345"));
561    }
562
563    #[test]
564    fn test_get_cache_dir() {
565        let dir = get_cache_dir().unwrap();
566        assert!(dir.to_string_lossy().contains("agpm"));
567    }
568
569    #[test]
570    fn test_get_data_dir() {
571        let dir = get_data_dir().unwrap();
572        assert!(dir.to_string_lossy().contains("agpm"));
573    }
574
575    #[test]
576    fn test_windows_long_path() {
577        let path = Path::new("/test/path");
578        let result = windows_long_path(path);
579
580        #[cfg(windows)]
581        assert_eq!(result, PathBuf::from("/test/path"));
582
583        #[cfg(not(windows))]
584        assert_eq!(result, path.to_path_buf());
585    }
586
587    #[test]
588    fn test_get_shell_command() {
589        let (shell, flag) = get_shell_command();
590
591        #[cfg(windows)]
592        {
593            assert_eq!(shell, "cmd");
594            assert_eq!(flag, "/C");
595        }
596
597        #[cfg(not(windows))]
598        {
599            assert_eq!(shell, "sh");
600            assert_eq!(flag, "-c");
601        }
602    }
603
604    #[test]
605    fn test_path_to_string() {
606        let path = Path::new("test/path/file.txt");
607        let result = path_to_string(path);
608        assert!(!result.is_empty());
609        assert!(result.contains("file.txt"));
610    }
611
612    #[test]
613    fn test_paths_equal() {
614        let path1 = Path::new("Test/Path");
615        let path2 = Path::new("test/path");
616
617        #[cfg(windows)]
618        assert!(paths_equal(path1, path2));
619
620        #[cfg(not(windows))]
621        assert!(!paths_equal(path1, path2));
622
623        // Same case should always be equal
624        let path3 = Path::new("test/path");
625        assert!(paths_equal(path2, path3));
626    }
627
628    #[test]
629    fn test_safe_canonicalize() -> Result<()> {
630        let temp = tempfile::tempdir().unwrap();
631        let test_path = temp.path().join("test_file.txt");
632        std::fs::write(&test_path, "test").unwrap();
633
634        let canonical = safe_canonicalize(&test_path)?;
635        assert!(canonical.is_absolute());
636        assert!(canonical.exists());
637        Ok(())
638    }
639
640    #[test]
641    fn test_validate_path_chars() {
642        // Valid paths should pass
643        assert!(validate_path_chars("valid/path/file.txt").is_ok());
644        assert!(validate_path_chars("underscore_file.txt").is_ok());
645
646        #[cfg(windows)]
647        {
648            // Invalid Windows characters should fail
649            assert!(validate_path_chars("invalid:file.txt").is_err());
650            assert!(validate_path_chars("invalid|file.txt").is_err());
651            assert!(validate_path_chars("invalid?file.txt").is_err());
652
653            // Reserved names should fail
654            assert!(validate_path_chars("CON").is_err());
655            assert!(validate_path_chars("PRN").is_err());
656            assert!(validate_path_chars("path/AUX/file.txt").is_err());
657        }
658    }
659
660    #[test]
661    fn test_safe_join() -> Result<()> {
662        let base = Path::new("/home/user/project");
663
664        // Normal join should work
665        let _joined = safe_join(base, "subdir/file.txt")?;
666
667        // Path traversal should be detected and rejected
668        let result = safe_join(base, "../../../etc/passwd");
669        assert!(result.is_err());
670
671        #[cfg(windows)]
672        {
673            // Invalid Windows characters should be rejected
674            let result = safe_join(base, "invalid:file.txt");
675            assert!(result.is_err());
676        }
677        Ok(())
678    }
679
680    #[test]
681    fn test_validate_path_chars_edge_cases() {
682        // Test empty path
683        assert!(validate_path_chars("").is_ok());
684
685        // Test path with spaces
686        assert!(validate_path_chars("path with spaces/file.txt").is_ok());
687
688        // Test path with dots
689        assert!(validate_path_chars("../relative/path.txt").is_ok());
690
691        #[cfg(windows)]
692        {
693            // Test control characters
694            assert!(validate_path_chars("file\0name").is_err());
695            assert!(validate_path_chars("file\nname").is_err());
696
697            // Test all invalid Windows chars
698            for ch in &['<', '>', ':', '"', '|', '?', '*'] {
699                let invalid_path = format!("file{}name", ch);
700                assert!(validate_path_chars(&invalid_path).is_err());
701            }
702
703            // Test reserved names with extensions (should be ok)
704            assert!(validate_path_chars("CON.txt").is_ok());
705            assert!(validate_path_chars("PRN.log").is_ok());
706        }
707    }
708
709    #[test]
710    fn test_safe_join_edge_cases() -> Result<()> {
711        let base = Path::new("/base");
712
713        // Test single dot (current dir)
714        let _current = safe_join(base, ".")?;
715
716        // Test safe relative path with ..
717        let _safe_relative = safe_join(base, "subdir/../file.txt")?;
718
719        // Test absolute path join
720        let _absolute = safe_join(base, "/absolute/path")?;
721        Ok(())
722    }
723
724    #[test]
725    fn test_resolve_path_invalid_env_var() {
726        // Test with undefined environment variable
727        let result = resolve_path("$UNDEFINED_VAR_123/path");
728        // This should either fail or expand to empty/current path
729        if result.is_ok() {
730            // Some systems might expand undefined vars to empty string
731        } else {
732            // This is also acceptable behavior
733        }
734    }
735
736    #[test]
737    fn test_windows_specific_tilde_error() {
738        // Test invalid Windows tilde usage on any platform
739        let result = resolve_path("~user/file.txt");
740        assert!(result.is_err());
741    }
742
743    #[test]
744    fn test_get_executable_extension() {
745        let ext = get_executable_extension();
746
747        #[cfg(windows)]
748        assert_eq!(ext, ".exe");
749
750        #[cfg(not(windows))]
751        assert_eq!(ext, "");
752    }
753
754    #[test]
755    fn test_is_executable_name() {
756        #[cfg(windows)]
757        {
758            assert!(is_executable_name("test.exe"));
759            assert!(is_executable_name("TEST.EXE"));
760            assert!(!is_executable_name("test"));
761            assert!(!is_executable_name("test.txt"));
762        }
763
764        #[cfg(not(windows))]
765        {
766            // On Unix, any file can be executable
767            assert!(is_executable_name("test"));
768            assert!(is_executable_name("test.sh"));
769            assert!(is_executable_name("test.exe"));
770        }
771    }
772
773    #[test]
774    fn test_normalize_line_endings() {
775        let text_lf = "line1\nline2\nline3";
776        let text_crlf = "line1\r\nline2\r\nline3";
777        let text_mixed = "line1\nline2\r\nline3";
778
779        let normalized_lf = normalize_line_endings(text_lf);
780        let normalized_crlf = normalize_line_endings(text_crlf);
781        let normalized_mixed = normalize_line_endings(text_mixed);
782
783        #[cfg(windows)]
784        {
785            assert!(normalized_lf.contains("\r\n"));
786            assert!(normalized_crlf.contains("\r\n"));
787            assert!(normalized_mixed.contains("\r\n"));
788        }
789
790        #[cfg(not(windows))]
791        {
792            assert!(!normalized_lf.contains('\r'));
793            assert!(!normalized_crlf.contains('\r'));
794            assert!(!normalized_mixed.contains('\r'));
795        }
796    }
797
798    #[test]
799    fn test_safe_canonicalize_nonexistent() {
800        let result = safe_canonicalize(Path::new("/nonexistent/path/to/file"));
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn test_safe_canonicalize_relative() -> Result<()> {
806        use tempfile::TempDir;
807
808        // Create a temp directory to ensure we have a valid working directory
809        let temp_dir = TempDir::new().unwrap();
810        let test_file = temp_dir.path().join("test.txt");
811        std::fs::write(&test_file, "test").unwrap();
812
813        // Test with a file that exists
814        let canonical = safe_canonicalize(&test_file)?;
815        assert!(canonical.is_absolute());
816        Ok(())
817    }
818
819    #[test]
820    fn test_paths_equal_with_trailing_slash() {
821        let path1 = Path::new("test/path/");
822        let path2 = Path::new("test/path");
823
824        // Paths should be equal regardless of trailing slash
825        assert!(paths_equal(path1, path2));
826    }
827
828    #[test]
829    fn test_validate_path_chars_unicode() {
830        // Test with unicode characters
831        assert!(validate_path_chars("文件名.txt").is_ok());
832        assert!(validate_path_chars("файл.md").is_ok());
833        assert!(validate_path_chars("αρχείο.rs").is_ok());
834
835        // Test with emoji (should be ok on most systems)
836        assert!(validate_path_chars("📁folder/📄file.txt").is_ok());
837    }
838
839    #[test]
840    fn test_command_exists_with_path() {
841        // Test that command_exists works with full paths
842        #[cfg(unix)]
843        {
844            if Path::new("/bin/sh").exists() {
845                assert!(command_exists("/bin/sh"));
846            }
847        }
848
849        #[cfg(windows)]
850        {
851            if Path::new("C:\\Windows\\System32\\cmd.exe").exists() {
852                assert!(command_exists("C:\\Windows\\System32\\cmd.exe"));
853            }
854        }
855    }
856
857    #[test]
858    fn test_normalize_path_separator_edge_cases() {
859        // Test empty path
860        let empty = Path::new("");
861        let normalized = normalize_path_separator(empty);
862        assert_eq!(normalized, "");
863
864        // Test root path
865        #[cfg(unix)]
866        {
867            let root = Path::new("/");
868            let normalized = normalize_path_separator(root);
869            assert_eq!(normalized, "/");
870        }
871
872        #[cfg(windows)]
873        {
874            let root = Path::new("C:\\");
875            let normalized = normalize_path_separator(root);
876            assert_eq!(normalized, "C:\\");
877        }
878    }
879
880    #[test]
881    fn test_path_to_string_invalid_utf8() {
882        // This test is mainly for Unix where paths can be non-UTF8
883        #[cfg(unix)]
884        {
885            use std::ffi::OsStr;
886            use std::os::unix::ffi::OsStrExt;
887
888            // Create a path with invalid UTF-8
889            let invalid_bytes = vec![0xff, 0xfe, 0xfd];
890            let os_str = OsStr::from_bytes(&invalid_bytes);
891            let path = Path::new(os_str);
892
893            // path_to_string should handle this gracefully
894            let result = path_to_string(path);
895            assert!(!result.is_empty());
896        }
897    }
898
899    #[test]
900    fn test_safe_join_complex_scenarios() -> Result<()> {
901        let base = Path::new("/home/user");
902
903        // Test with empty path component
904        let _empty = safe_join(base, "")?;
905
906        // Test with multiple slashes
907        let _multiple_slashes = safe_join(base, "path//to///file")?;
908
909        // Test with backslashes on Unix (should be treated as regular characters)
910        #[cfg(unix)]
911        {
912            let _backslashes = safe_join(base, "path\\to\\file")?;
913        }
914        Ok(())
915    }
916
917    #[test]
918    fn test_resolve_path_complex() -> Result<()> {
919        // Test multiple ~ in path (only first should be expanded)
920        let resolved = resolve_path("~/path/~file.txt")?;
921        assert!(!resolved.to_string_lossy().starts_with('~'));
922
923        // Test empty path
924        let empty = resolve_path("")?;
925        assert_eq!(empty, PathBuf::from(""));
926        Ok(())
927    }
928
929    #[test]
930    fn test_get_home_dir_fallback() {
931        // Test that get_home_dir has appropriate error handling
932        // We can't easily test the error case without modifying the environment significantly
933        // but we can verify the function signature and basic operation
934        match get_home_dir() {
935            Ok(home) => {
936                assert!(home.is_absolute());
937                // Home directory should exist
938                assert!(home.exists() || home.parent().is_some_and(std::path::Path::exists));
939            }
940            Err(e) => {
941                // If it fails, it should have a meaningful error message
942                assert!(e.to_string().contains("home") || e.to_string().contains("directory"));
943            }
944        }
945    }
946
947    // Helper functions used in the module but not directly exported
948    fn is_executable_name(_name: &str) -> bool {
949        #[cfg(windows)]
950        {
951            _name.to_lowercase().ends_with(".exe")
952        }
953        #[cfg(not(windows))]
954        {
955            // On Unix, executability is determined by permissions, not name
956            true
957        }
958    }
959
960    fn get_executable_extension() -> &'static str {
961        #[cfg(windows)]
962        {
963            ".exe"
964        }
965        #[cfg(not(windows))]
966        {
967            ""
968        }
969    }
970
971    fn normalize_line_endings(text: &str) -> String {
972        #[cfg(windows)]
973        {
974            text.replace('\n', "\r\n").replace("\r\r\n", "\r\n")
975        }
976        #[cfg(not(windows))]
977        {
978            text.replace("\r\n", "\n")
979        }
980    }
981
982    #[test]
983    fn test_normalize_path_for_storage_unix() {
984        use std::path::Path;
985        // Unix-style paths should just normalize separators
986        assert_eq!(
987            normalize_path_for_storage(Path::new("/project/agents/helper.md")),
988            "/project/agents/helper.md"
989        );
990        assert_eq!(normalize_path_for_storage(Path::new("agents/helper.md")), "agents/helper.md");
991        assert_eq!(
992            normalize_path_for_storage(Path::new("../shared/utils.md")),
993            "../shared/utils.md"
994        );
995    }
996
997    #[test]
998    fn test_normalize_path_for_storage_windows_extended() {
999        use std::path::Path;
1000        // Windows extended-length path prefix should be stripped AND backslashes converted
1001        // This tests the combined behavior: \\?\C:\path -> C:/path
1002        let path = Path::new(r"\\?\C:\project\agents\helper.md");
1003        assert_eq!(
1004            normalize_path_for_storage(path),
1005            "C:/project/agents/helper.md",
1006            "Should strip extended-length prefix (\\\\?\\) AND convert backslashes to forward slashes"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_normalize_path_for_storage_windows_extended_unc() {
1012        use std::path::Path;
1013        // Windows extended-length UNC path should be converted to //server/share format
1014        let path = Path::new(r"\\?\UNC\server\share\file.md");
1015        assert_eq!(normalize_path_for_storage(path), "//server/share/file.md");
1016    }
1017
1018    #[test]
1019    fn test_normalize_path_for_storage_windows_backslash() {
1020        use std::path::Path;
1021        // Windows backslashes should be converted to forward slashes
1022        let path = Path::new(r"C:\project\agents\helper.md");
1023        assert_eq!(normalize_path_for_storage(path), "C:/project/agents/helper.md");
1024    }
1025}