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. It handles differences in:
5//!
6//! - Path separators and conventions
7//! - Home directory resolution
8//! - Command execution and shell interfaces
9//! - File system behavior and limitations
10//! - Environment variable handling
11//!
12//! # Cross-Platform Design
13//!
14//! AGPM is designed to provide identical functionality across all supported platforms
15//! while respecting platform conventions and limitations. This module encapsulates
16//! the platform-specific logic to achieve this goal.
17//!
18//! # Examples
19//!
20//! ```rust,no_run
21//! use agpm_cli::utils::platform::{get_home_dir, resolve_path, is_windows};
22//!
23//! # fn example() -> anyhow::Result<()> {
24//! // Get platform-appropriate home directory
25//! let home = get_home_dir()?;
26//! println!("Home directory: {}", home.display());
27//!
28//! // Resolve paths with tilde expansion and env vars
29//! let config_path = resolve_path("~/.agpm/config.toml")?;
30//!
31//! // Handle platform differences
32//! if is_windows() {
33//!     println!("Running on Windows");
34//! } else {
35//!     println!("Running on Unix-like system");
36//! }
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! # Platform Support Matrix
42//!
43//! | Feature | Windows | macOS | Linux |
44//! |---------|---------|-------|-------|
45//! | Long paths (>260 chars) | ✅ | ✅ | ✅ |
46//! | Case sensitivity | No | Configurable | Yes |
47//! | Tilde expansion | ✅ | ✅ | ✅ |
48//! | Environment variables | %VAR% | $VAR | $VAR |
49//! | Shell commands | cmd.exe | sh | sh |
50//! | Git command | git.exe | git | git |
51//!
52//! # Security Considerations
53//!
54//! - Path traversal prevention in [`safe_join`]
55//! - Input validation in [`validate_path_chars`]
56//! - Safe environment variable expansion
57//! - Proper handling of special Windows filenames
58
59use anyhow::{Context, Result};
60use regex::Regex;
61use std::path::{Path, PathBuf};
62
63/// Checks if the current platform is Windows.
64///
65/// This is a compile-time check that returns `true` if the code is compiled
66/// for Windows targets, `false` otherwise. It's used throughout the codebase
67/// to handle Windows-specific behavior.
68///
69/// # Returns
70///
71/// - `true` on Windows (any Windows target)
72/// - `false` on Unix-like systems (macOS, Linux, BSD, etc.)
73///
74/// # Examples
75///
76/// ```rust,no_run
77/// use agpm_cli::utils::platform::is_windows;
78///
79/// if is_windows() {
80///     println!("Windows-specific code path");
81/// } else {
82///     println!("Unix-like system code path");
83/// }
84/// ```
85///
86/// # Use Cases
87///
88/// - Conditional compilation of platform-specific code
89/// - Different path handling logic
90/// - Platform-specific error messages
91/// - Command execution differences
92#[must_use]
93pub const fn is_windows() -> bool {
94    cfg!(windows)
95}
96
97/// Gets the home directory path for the current user.
98///
99/// This function returns the user's home directory following platform conventions.
100/// It uses the appropriate environment variables and fallback mechanisms for
101/// each platform to reliably determine the home directory.
102///
103/// # Returns
104///
105/// The user's home directory path, or an error if it cannot be determined
106///
107/// # Examples
108///
109/// ```rust,no_run
110/// use agpm_cli::utils::platform::get_home_dir;
111///
112/// # fn example() -> anyhow::Result<()> {
113/// let home = get_home_dir()?;
114/// println!("Home directory: {}", home.display());
115///
116/// let agpm_dir = home.join(".agpm");
117/// println!("AGPM directory would be: {}", agpm_dir.display());
118/// # Ok(())
119/// # }
120/// ```
121///
122/// # Platform Behavior
123///
124/// - **Windows**: Uses `%USERPROFILE%` environment variable
125/// - **Unix/Linux**: Uses `$HOME` environment variable
126/// - **macOS**: Uses `$HOME` environment variable
127///
128/// # Error Cases
129///
130/// - Home directory environment variable is not set
131/// - Environment variable points to non-existent directory
132/// - Permission denied accessing the home directory
133///
134/// # Use Cases
135///
136/// - Finding user configuration directories
137/// - Resolving tilde (`~`) in path expansion
138/// - Creating user-specific cache and data directories
139pub fn get_home_dir() -> Result<PathBuf> {
140    dirs::home_dir().ok_or_else(|| {
141        let platform_help = if is_windows() {
142            "On Windows: Check that the USERPROFILE environment variable is set"
143        } else {
144            "On Unix/Linux: Check that the HOME environment variable is set"
145        };
146        anyhow::anyhow!("Could not determine home directory.\n\n{platform_help}")
147    })
148}
149
150/// Returns the appropriate Git command name for the current platform.
151///
152/// This function returns the platform-specific Git executable name that
153/// should be used when invoking Git commands via the system shell.
154///
155/// # Returns
156///
157/// - `"git.exe"` on Windows
158/// - `"git"` on Unix-like systems (macOS, Linux, BSD)
159///
160/// # Examples
161///
162/// ```rust,no_run
163/// use agpm_cli::utils::platform::get_git_command;
164/// use std::process::Command;
165///
166/// # fn example() -> anyhow::Result<()> {
167/// let git_cmd = get_git_command();
168/// let output = Command::new(git_cmd)
169///     .args(["--version"])
170///     .output()?;
171///
172/// println!("Git version: {}", String::from_utf8_lossy(&output.stdout));
173/// # Ok(())
174/// # }
175/// ```
176///
177/// # Platform Differences
178///
179/// - **Windows**: Uses `git.exe` to explicitly invoke the executable
180/// - **Unix-like**: Uses `git` which relies on PATH resolution
181///
182/// # Note
183///
184/// This function returns the command name, not the full path. The actual
185/// Git executable must still be available in the system PATH for commands
186/// to succeed.
187///
188/// # See Also
189///
190/// - [`command_exists`] to check if Git is available
191/// - System PATH configuration for Git availability
192#[must_use]
193pub const fn get_git_command() -> &'static str {
194    if is_windows() {
195        "git.exe"
196    } else {
197        "git"
198    }
199}
200
201/// Resolves a path with tilde expansion and environment variable substitution.
202///
203/// This function processes path strings to handle common shell conventions:
204/// - Tilde (`~`) expansion to the user's home directory
205/// - Environment variable substitution (`$VAR` or `%VAR%`)
206/// - Windows long path handling when necessary
207///
208/// # Arguments
209///
210/// * `path` - The path string to resolve (may contain `~` or environment variables)
211///
212/// # Returns
213///
214/// A resolved [`PathBuf`] with expansions applied, or an error if expansion fails
215///
216/// # Examples
217///
218/// ```rust,no_run
219/// use agpm_cli::utils::platform::resolve_path;
220///
221/// # fn example() -> anyhow::Result<()> {
222/// // Tilde expansion
223/// let config_path = resolve_path("~/.agpm/config.toml")?;
224/// println!("Config: {}", config_path.display());
225///
226/// // Environment variable expansion (Unix)
227/// # #[cfg(unix)]
228/// let path_with_env = resolve_path("$HOME/Documents/project")?;
229///
230/// // Environment variable expansion (Windows)
231/// # #[cfg(windows)]
232/// let path_with_env = resolve_path("%USERPROFILE%\\Documents\\project")?;
233/// # Ok(())
234/// # }
235/// ```
236///
237/// # Supported Patterns
238///
239/// - `~/path` - Expands to `{home}/path`
240/// - `$VAR/path` (Unix) - Expands environment variable
241/// - `%VAR%/path` (Windows) - Expands environment variable
242/// - `${VAR}/path` (Unix) - Alternative env var syntax
243///
244/// # Error Cases
245///
246/// - Invalid tilde usage (e.g., `~user/path` on Windows)
247/// - Undefined environment variables
248/// - Invalid variable syntax
249/// - Home directory cannot be determined
250///
251/// # Security
252///
253/// This function safely handles environment variable expansion and prevents
254/// common injection attacks by using proper parsing libraries.
255///
256/// # See Also
257///
258/// - [`get_home_dir`] for home directory resolution
259/// - [`validate_path_chars`] for path validation
260pub fn resolve_path(path: &str) -> Result<PathBuf> {
261    let expanded = if let Some(stripped) = path.strip_prefix("~/") {
262        let home = get_home_dir()?;
263        home.join(stripped)
264    } else if path.starts_with('~') {
265        // Handle Windows-style user expansion like ~username
266        if is_windows() && path.len() > 1 && !path.starts_with("~/") {
267            return Err(anyhow::anyhow!(
268                "Invalid path: {path}\n\n\
269                Windows tilde expansion only supports '~/' for current user home directory.\n\
270                Use '~/' followed by a relative path, like '~/Documents/file.txt'"
271            ));
272        }
273        return Err(anyhow::anyhow!(
274            "Invalid path: {path}\n\n\
275            Tilde expansion only supports '~/' for home directory.\n\
276            Use '~/' followed by a relative path, like '~/Documents/file.txt'"
277        ));
278    } else {
279        PathBuf::from(path)
280    };
281
282    // Expand environment variables
283    let path_str = expanded.to_string_lossy();
284
285    // Handle Windows-style %VAR% expansion differently
286    let expanded_str = if is_windows() && path_str.contains('%') {
287        // Manual Windows-style %VAR% expansion
288        let mut result = path_str.to_string();
289        let re = Regex::new(r"%([^%]+)%").unwrap();
290
291        for cap in re.captures_iter(&path_str) {
292            if let Some(var_name) = cap.get(1)
293                && let Ok(value) = std::env::var(var_name.as_str())
294            {
295                result = result.replace(&format!("%{}%", var_name.as_str()), &value);
296            }
297        }
298
299        // Also handle Unix-style for compatibility
300        match shellexpand::env(&result) {
301            Ok(expanded) => expanded.into_owned(),
302            Err(_) => result, // Return the partially expanded result
303        }
304    } else {
305        // Unix-style $VAR expansion
306        shellexpand::env(&path_str)
307            .with_context(|| {
308                let platform_vars = if is_windows() {
309                    "Common Windows variables: %USERPROFILE%, %APPDATA%, %TEMP%"
310                } else {
311                    "Common Unix variables: $HOME, $USER, $TMP"
312                };
313
314                format!(
315                    "Failed to expand environment variables in path: {path_str}\n\n\
316                    Common issues:\n\
317                    - Undefined environment variable (e.g., $UNDEFINED_VAR)\n\
318                    - Invalid variable syntax (use $VAR or ${{VAR}})\n\
319                    - Special characters that need escaping\n\n\
320                    {platform_vars}"
321                )
322            })?
323            .into_owned()
324    };
325
326    let result = PathBuf::from(expanded_str);
327
328    // Apply Windows long path handling if needed
329    Ok(windows_long_path(&result))
330}
331
332/// Converts a path to use the correct separator for the current platform.
333///
334/// This function normalizes path separators to match platform conventions:
335/// - Windows: Converts `/` to `\`
336/// - Unix-like: Converts `\` to `/`
337///
338/// This is primarily useful for display purposes or when interfacing with
339/// platform-specific APIs that expect native separators.
340///
341/// # Arguments
342///
343/// * `path` - The path to normalize
344///
345/// # Returns
346///
347/// A string with platform-appropriate separators
348///
349/// # Examples
350///
351/// ```rust,no_run
352/// use agpm_cli::utils::platform::normalize_path_separator;
353/// use std::path::Path;
354///
355/// let mixed_path = Path::new("src/utils\\platform.rs");
356/// let normalized = normalize_path_separator(mixed_path);
357///
358/// #[cfg(windows)]
359/// assert_eq!(normalized, "src\\utils\\platform.rs");
360///
361/// #[cfg(not(windows))]
362/// assert_eq!(normalized, "src/utils/platform.rs");
363/// ```
364///
365/// # Platform Behavior
366///
367/// - **Windows**: All separators become `\`
368/// - **Unix-like**: All separators become `/`
369///
370/// # Use Cases
371///
372/// - Display paths to users in platform-native format
373/// - Interfacing with platform-specific APIs
374/// - Generating platform-specific configuration files
375/// - Logging and error messages
376///
377/// # Note
378///
379/// Rust's [`Path`] and [`PathBuf`] types handle separators transparently
380/// in most cases, so this function is primarily needed for display and
381/// external interface purposes.
382#[must_use]
383pub fn normalize_path_separator(path: &Path) -> String {
384    if is_windows() {
385        path.to_string_lossy().replace('/', "\\")
386    } else {
387        path.to_string_lossy().replace('\\', "/")
388    }
389}
390
391/// Normalizes a path for cross-platform storage by converting all separators to forward slashes.
392///
393/// This function ensures paths are stored consistently across all platforms by always using
394/// forward slashes as separators, regardless of the current platform. This is critical for:
395///
396/// - **Lockfiles** (`agpm.lock`): Must be identical across platforms for version control
397/// - **`.gitignore` entries**: Git requires forward slashes on all platforms
398/// - **TOML manifest files**: Forward slashes are platform-independent
399/// - **JSON configuration**: Forward slashes work universally
400///
401/// # Arguments
402///
403/// * `path` - The path to normalize (accepts both `Path` and string types)
404///
405/// # Returns
406///
407/// A string with all backslashes converted to forward slashes
408///
409/// # Examples
410///
411/// ```rust,no_run
412/// use agpm_cli::utils::platform::normalize_path_for_storage;
413/// use std::path::Path;
414///
415/// // Windows path with backslashes
416/// let windows_path = Path::new(".claude\\agents\\example.md");
417/// assert_eq!(
418///     normalize_path_for_storage(windows_path),
419///     ".claude/agents/example.md"
420/// );
421///
422/// // Unix path (no change needed)
423/// let unix_path = Path::new(".claude/agents/example.md");
424/// assert_eq!(
425///     normalize_path_for_storage(unix_path),
426///     ".claude/agents/example.md"
427/// );
428///
429/// // Mixed separators (normalized to forward slashes)
430/// let mixed_path = Path::new("src/utils\\platform.rs");
431/// assert_eq!(
432///     normalize_path_for_storage(mixed_path),
433///     "src/utils/platform.rs"
434/// );
435/// ```
436///
437/// # Platform Behavior
438///
439/// - **Windows**: Converts `\` → `/`
440/// - **Unix-like**: Already uses `/`, but normalizes any stray `\` characters
441/// - **All platforms**: Output is always identical for the same logical path
442///
443/// # Use Cases
444///
445/// ```rust,no_run
446/// use agpm_cli::utils::platform::normalize_path_for_storage;
447/// use std::path::Path;
448///
449/// // Lockfile entries
450/// let installed_at = normalize_path_for_storage(
451///     Path::new(".claude\\agents\\example.md")
452/// );
453/// // Always produces: ".claude/agents/example.md"
454///
455/// // .gitignore entries
456/// let ignore_path = normalize_path_for_storage(
457///     Path::new(".claude\\cache")
458/// );
459/// // Always produces: ".claude/cache"
460///
461/// // Format strings with Path::display()
462/// let artifact_path = Path::new(".claude\\agents");
463/// let filename = "example.md";
464/// let full_path = format!("{}/{}", artifact_path.display(), filename);
465/// let normalized = normalize_path_for_storage(Path::new(&full_path));
466/// // Always produces: ".claude/agents/example.md"
467/// ```
468///
469/// # Important Notes
470///
471/// - **Always use this for stored paths**: Lockfiles, manifest files, .gitignore
472/// - **Don't use for runtime operations**: Use `Path`/`PathBuf` for filesystem operations
473/// - **Don't use for display**: Use `normalize_path_separator` for user-facing paths
474///
475/// # See Also
476///
477/// - [`normalize_path_separator`] for platform-native display formatting
478/// - CLAUDE.md "Cross-Platform Path Handling" section for complete guidelines
479#[must_use]
480pub fn normalize_path_for_storage<P: AsRef<Path>>(path: P) -> String {
481    let path_str = path.as_ref().to_string_lossy();
482
483    // Strip Windows extended-length path prefixes before normalization
484    // These prefixes are used internally by canonicalize() but shouldn't be stored
485    let cleaned = if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
486        // Extended UNC path: \\?\UNC\server\share -> //server/share
487        format!("//{}", stripped)
488    } else if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
489        // Extended path: \\?\C:\path -> C:\path
490        stripped.to_string()
491    } else {
492        path_str.to_string()
493    };
494
495    cleaned.replace('\\', "/")
496}
497
498/// Computes the relative install path by removing redundant directory prefixes.
499///
500/// This function intelligently strips redundant path components when a dependency's path
501/// starts with the same directory name as the tool's installation root. This prevents
502/// duplicate directory names like `.claude/agents/agents/example.md`.
503///
504/// # Algorithm
505///
506/// 1. Extract the last component of the tool root (e.g., `agents` from `.claude/agents/`)
507/// 2. Check if the dependency path starts with that same component (case-sensitive)
508/// 3. If yes, strip that leading component from the dependency path
509/// 4. If no, return the dependency path unchanged
510///
511/// # Arguments
512///
513/// * `tool_root` - The base installation directory (e.g., `.claude/agents/`)
514/// * `dep_path` - The relative path from the dependency (e.g., `agents/example.md`)
515///
516/// # Returns
517///
518/// The relative path to use for installation, with redundant prefixes removed.
519///
520/// # Examples
521///
522/// ```rust,no_run
523/// use agpm_cli::utils::platform::compute_relative_install_path;
524/// use std::path::Path;
525///
526/// // Standard case: strip redundant prefix
527/// let tool_root = Path::new(".claude/agents");
528/// let dep_path = Path::new("agents/carrots/agent.md");
529/// let result = compute_relative_install_path(tool_root, dep_path, false);
530/// assert_eq!(result, Path::new("carrots/agent.md"));
531///
532/// // Flatten: use only filename
533/// let tool_root = Path::new(".claude/agents");
534/// let dep_path = Path::new("agents/carrots/agent.md");
535/// let result = compute_relative_install_path(tool_root, dep_path, true);
536/// assert_eq!(result, Path::new("agent.md"));
537///
538/// // No match: preserve full path
539/// let tool_root = Path::new(".claude/agents");
540/// let dep_path = Path::new("helpers/agent.md");
541/// let result = compute_relative_install_path(tool_root, dep_path, false);
542/// assert_eq!(result, Path::new("helpers/agent.md"));
543///
544/// // Custom target with different name
545/// let tool_root = Path::new(".custom/my-stuff");
546/// let dep_path = Path::new("agents/helper.md");
547/// let result = compute_relative_install_path(tool_root, dep_path, false);
548/// assert_eq!(result, Path::new("agents/helper.md")); // No stripping
549/// ```
550///
551/// # Use Cases
552///
553/// - Installing resources from well-organized repositories
554/// - Preventing `.claude/snippets/snippets/example.md` duplication
555/// - Working with custom installation targets
556/// - Preserving intentional directory structures
557/// - Flattening directory structures for agents and commands
558///
559/// # Design Rationale
560///
561/// This approach is more generic than hardcoded resource type stripping:
562/// - Works with custom targets (e.g., `.custom/my-agents/`)
563/// - No dependency on resource type names
564/// - Handles edge cases like single-file dependencies
565/// - Respects intentional hierarchies (e.g., `helpers/agent.md` preserved)
566/// - Supports explicit flattening for resource types that don't need nested directories
567#[must_use]
568pub fn compute_relative_install_path(tool_root: &Path, dep_path: &Path, flatten: bool) -> PathBuf {
569    use std::path::Component;
570
571    // If flatten is true, return just the filename
572    if flatten {
573        if let Some(filename) = dep_path.file_name() {
574            return PathBuf::from(filename);
575        }
576        // Fallback to the original path if no filename
577        return dep_path.to_path_buf();
578    }
579
580    // Extract the last directory component from tool root
581    let tool_dir_name = tool_root.file_name().and_then(|n| n.to_str());
582
583    // Find the first Normal component and its position in the dependency path
584    let components: Vec<_> = dep_path.components().collect();
585    let (dep_first, first_normal_idx) = components
586        .iter()
587        .enumerate()
588        .find_map(|(idx, c)| {
589            if let Component::Normal(s) = c {
590                s.to_str().map(|s| (s, idx))
591            } else {
592                None
593            }
594        })
595        .map(|(s, idx)| (Some(s), Some(idx)))
596        .unwrap_or((None, None));
597
598    // If they match, strip up to and including the matching component
599    if tool_dir_name.is_some() && tool_dir_name.map(Some) == Some(dep_first) {
600        // Skip everything up to and including the matching Normal component
601        if let Some(idx) = first_normal_idx {
602            components.iter().skip(idx + 1).collect()
603        } else {
604            dep_path.to_path_buf()
605        }
606    } else {
607        // No match - return the full path (but skip any leading CurDir/ParentDir for cleanliness)
608        components
609            .iter()
610            .skip_while(|c| {
611                matches!(c, Component::CurDir | Component::Prefix(_) | Component::RootDir)
612            })
613            .collect()
614    }
615}
616
617/// Safely converts a path to a string, handling non-UTF-8 paths gracefully.
618///
619/// This function converts a [`Path`] to a [`String`] using lossy conversion,
620/// which means invalid UTF-8 sequences are replaced with the Unicode
621/// replacement character (�). This ensures the function never panics.
622///
623/// # Arguments
624///
625/// * `path` - The path to convert to a string
626///
627/// # Returns
628///
629/// A string representation of the path (may contain replacement characters)
630///
631/// # Examples
632///
633/// ```rust,no_run
634/// use agpm_cli::utils::platform::path_to_string;
635/// use std::path::Path;
636///
637/// let path = Path::new("/home/user/file.txt");
638/// let path_str = path_to_string(path);
639/// println!("Path as string: {}", path_str);
640/// ```
641///
642/// # Platform Considerations
643///
644/// - **Windows**: Paths are typically valid UTF-16, so conversion is usually lossless
645/// - **Unix-like**: Paths can contain arbitrary bytes, so lossy conversion may occur
646/// - **All platforms**: This function never panics, unlike direct UTF-8 conversion
647///
648/// # Use Cases
649///
650/// - Logging and error messages
651/// - Display to users
652/// - Interfacing with APIs that expect strings
653/// - JSON serialization of paths
654///
655/// # Alternative
656///
657/// For cases where you need `OsStr` (which preserves all path information),
658/// use [`path_to_os_str`] instead.
659///
660/// # See Also
661///
662/// - [`path_to_os_str`] for preserving all path information
663/// - [`Path::to_string_lossy`] for the underlying conversion method
664#[must_use]
665pub fn path_to_string(path: &Path) -> String {
666    path.to_string_lossy().to_string()
667}
668
669/// Returns a path as an `OsStr` for use in command arguments.
670///
671/// This function provides access to the raw `OsStr` representation of a path,
672/// which preserves all path information without any lossy conversion. This is
673/// the preferred way to pass paths to system commands and APIs.
674///
675/// # Arguments
676///
677/// * `path` - The path to get as an `OsStr`
678///
679/// # Returns
680///
681/// A reference to the path's `OsStr` representation
682///
683/// # Examples
684///
685/// ```rust,no_run
686/// use agpm_cli::utils::platform::path_to_os_str;
687/// use std::path::Path;
688/// use std::process::Command;
689///
690/// # fn example() -> anyhow::Result<()> {
691/// let file_path = Path::new("important-file.txt");
692/// let os_str = path_to_os_str(file_path);
693///
694/// // Use in command arguments
695/// let output = Command::new("cat")
696///     .arg(os_str)
697///     .output()?;
698/// # Ok(())
699/// # }
700/// ```
701///
702/// # Advantages
703///
704/// - **Lossless**: Preserves all path information
705/// - **Efficient**: No conversion or allocation
706/// - **Platform-native**: Uses the OS's native string representation
707/// - **Command-safe**: Ideal for process arguments
708///
709/// # Use Cases
710///
711/// - Passing paths to `Command::arg` and similar APIs
712/// - System API calls that expect native strings
713/// - Preserving exact path representation
714/// - File system operations
715///
716/// # See Also
717///
718/// - [`path_to_string`] for display purposes (lossy conversion)
719/// - [`std::ffi::OsStr`] for the underlying type documentation
720#[must_use]
721pub fn path_to_os_str(path: &Path) -> &std::ffi::OsStr {
722    path.as_os_str()
723}
724
725/// Compares two paths for equality, respecting platform case sensitivity rules.
726///
727/// This function performs path comparison that follows platform conventions:
728/// - **Windows**: Case-insensitive comparison (NTFS/FAT32 behavior)
729/// - **Unix-like**: Case-sensitive comparison (ext4/APFS/HFS+ behavior)
730///
731/// # Arguments
732///
733/// * `path1` - First path to compare
734/// * `path2` - Second path to compare
735///
736/// # Returns
737///
738/// `true` if the paths are considered equal on the current platform
739///
740/// # Examples
741///
742/// ```rust,no_run
743/// use agpm_cli::utils::platform::paths_equal;
744/// use std::path::Path;
745///
746/// let path1 = Path::new("Config.toml");
747/// let path2 = Path::new("config.toml");
748///
749/// #[cfg(windows)]
750/// assert!(paths_equal(path1, path2)); // Case-insensitive on Windows
751///
752/// #[cfg(not(windows))]
753/// assert!(!paths_equal(path1, path2)); // Case-sensitive on Unix
754/// ```
755///
756/// # Platform Behavior
757///
758/// - **Windows**: Converts both paths to lowercase before comparison
759/// - **macOS**: Case-sensitive by default (but filesystems may vary)
760/// - **Linux**: Always case-sensitive
761///
762/// # Use Cases
763///
764/// - Checking for duplicate file references
765/// - Path deduplication in collections
766/// - Validating user input against existing paths
767/// - Cross-platform file system operations
768///
769/// # Note
770///
771/// This function compares path strings, not filesystem entries. It does not
772/// resolve symbolic links or check if the paths actually exist.
773///
774/// # Filesystem Variations
775///
776/// Some filesystems have configurable case sensitivity (like APFS on macOS).
777/// This function uses platform defaults and may not match filesystem behavior
778/// in all cases.
779#[must_use]
780pub fn paths_equal(path1: &Path, path2: &Path) -> bool {
781    if is_windows() {
782        // Windows file system is case-insensitive
783        // Normalize paths by removing trailing slashes before comparison
784        let p1_str = path1.to_string_lossy();
785        let p2_str = path2.to_string_lossy();
786        let p1 = p1_str.trim_end_matches(['/', '\\']).to_lowercase();
787        let p2 = p2_str.trim_end_matches(['/', '\\']).to_lowercase();
788        p1 == p2
789    } else {
790        // Unix-like systems are case-sensitive
791        // Also normalize trailing slashes for consistency
792        let p1_str = path1.to_string_lossy();
793        let p2_str = path2.to_string_lossy();
794        let p1 = p1_str.trim_end_matches('/');
795        let p2 = p2_str.trim_end_matches('/');
796        p1 == p2
797    }
798}
799
800/// Canonicalizes a path with proper cross-platform handling.
801///
802/// This function resolves a path to its canonical, absolute form while handling
803/// platform-specific issues like Windows long paths. It resolves symbolic links,
804/// removes `.` and `..` components, and ensures the path is absolute.
805///
806/// # Arguments
807///
808/// * `path` - The path to canonicalize
809///
810/// # Returns
811///
812/// The canonical absolute path, or an error if canonicalization fails
813///
814/// # Examples
815///
816/// ```rust,no_run
817/// use agpm_cli::utils::platform::safe_canonicalize;
818/// use std::path::Path;
819///
820/// # fn example() -> anyhow::Result<()> {
821/// // Canonicalize a relative path
822/// let canonical = safe_canonicalize(Path::new("../src/main.rs"))?;
823/// println!("Canonical path: {}", canonical.display());
824///
825/// // Works with existing files and directories
826/// let current_dir = safe_canonicalize(Path::new("."))?;
827/// println!("Current directory: {}", current_dir.display());
828/// # Ok(())
829/// # }
830/// ```
831///
832/// # Features
833///
834/// - **Cross-platform**: Works on Windows, macOS, and Linux
835/// - **Long path support**: Handles Windows paths >260 characters
836/// - **Symlink resolution**: Follows symbolic links to their targets
837/// - **Path normalization**: Removes `.` and `..` components
838/// - **Absolute paths**: Always returns absolute paths
839///
840/// # Error Cases
841///
842/// - Path does not exist
843/// - Permission denied accessing path components
844/// - Invalid path characters for the platform
845/// - Path too long (even with Windows long path support)
846/// - Circular symbolic links
847///
848/// # Platform Notes
849///
850/// - **Windows**: Automatically applies long path prefixes when needed
851/// - **Unix-like**: Resolves symbolic links following POSIX semantics
852/// - **All platforms**: Provides helpful error messages for common issues
853///
854/// # Security
855///
856/// This function safely resolves paths and prevents directory traversal
857/// by returning absolute, normalized paths.
858///
859/// # See Also
860///
861/// - `normalize_path` for logical path normalization (no filesystem access)
862/// - [`windows_long_path`] for Windows-specific path handling
863pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
864    let canonical = path.canonicalize().with_context(|| {
865        format!(
866            "Failed to canonicalize path: {}\n\n\
867                Possible causes:\n\
868                - Path does not exist\n\
869                - Permission denied\n\
870                - Invalid path characters\n\
871                - Path too long (>260 chars on Windows)",
872            path.display()
873        )
874    })?;
875
876    #[cfg(windows)]
877    {
878        Ok(windows_long_path(&canonical))
879    }
880
881    #[cfg(not(windows))]
882    {
883        Ok(canonical)
884    }
885}
886
887/// Checks if a command is available in the system PATH.
888///
889/// This function searches the system PATH for the specified command and returns
890/// whether it can be found and is executable. This is useful for verifying that
891/// required external tools (like Git) are available before attempting to use them.
892///
893/// # Arguments
894///
895/// * `cmd` - The command name to search for
896///
897/// # Returns
898///
899/// `true` if the command exists and is executable, `false` otherwise
900///
901/// # Examples
902///
903/// ```rust,no_run
904/// use agpm_cli::utils::platform::command_exists;
905///
906/// // Check if Git is available
907/// if command_exists("git") {
908///     println!("Git is available");
909/// } else {
910///     eprintln!("Git is not installed or not in PATH");
911/// }
912///
913/// // Platform-specific commands
914/// #[cfg(windows)]
915/// let shell_available = command_exists("cmd");
916///
917/// #[cfg(unix)]
918/// let shell_available = command_exists("sh");
919/// ```
920///
921/// # Platform Behavior
922///
923/// - **Windows**: Searches PATH and PATHEXT for executable files
924/// - **Unix-like**: Searches PATH for executable files
925/// - **All platforms**: Respects system PATH configuration
926///
927/// # Use Cases
928///
929/// - Validating tool availability before execution
930/// - Providing helpful error messages when tools are missing
931/// - Feature detection based on available commands
932/// - System requirements checking
933///
934/// # Performance
935///
936/// This function performs filesystem operations and may be relatively slow.
937/// Consider caching results if checking the same command multiple times.
938///
939/// # See Also
940///
941/// - [`get_git_command`] for getting the platform-appropriate Git command name
942#[must_use]
943pub fn command_exists(cmd: &str) -> bool {
944    which::which(cmd).is_ok()
945}
946
947/// Returns the platform-specific cache directory for AGPM.
948///
949/// This function returns the appropriate cache directory following platform
950/// conventions and standards (XDG Base Directory on Linux, standard locations
951/// on Windows and macOS).
952///
953/// # Returns
954///
955/// The cache directory path (`{cache_dir}/agpm`), or an error if it cannot be determined
956///
957/// # Examples
958///
959/// ```rust,no_run
960/// use agpm_cli::utils::platform::get_cache_dir;
961///
962/// # fn example() -> anyhow::Result<()> {
963/// let cache_dir = get_cache_dir()?;
964/// println!("AGPM cache directory: {}", cache_dir.display());
965///
966/// // Use for storing temporary data
967/// let repo_cache = cache_dir.join("repositories");
968/// # Ok(())
969/// # }
970/// ```
971///
972/// # Platform Paths
973///
974/// - **Linux**: `$XDG_CACHE_HOME/agpm` or `$HOME/.cache/agpm`
975/// - **macOS**: `$HOME/Library/Caches/agpm`
976/// - **Windows**: `%LOCALAPPDATA%\agpm`
977///
978/// # Standards Compliance
979///
980/// - **Linux**: Follows XDG Base Directory Specification
981/// - **macOS**: Follows Apple File System Programming Guide
982/// - **Windows**: Follows Windows Known Folders conventions
983///
984/// # Use Cases
985///
986/// - Storing cloned Git repositories
987/// - Caching downloaded resources
988/// - Temporary build artifacts
989/// - Performance optimization data
990///
991/// # Cleanup
992///
993/// Cache directories may be cleaned by system maintenance tools or user
994/// cleanup utilities. Don't store critical data here.
995///
996/// # See Also
997///
998/// - [`get_data_dir`] for persistent application data
999/// - [`get_home_dir`] for user home directory
1000pub fn get_cache_dir() -> Result<PathBuf> {
1001    dirs::cache_dir().map(|p| p.join("agpm")).ok_or_else(|| {
1002        let platform_help = if is_windows() {
1003            "On Windows: Check that the LOCALAPPDATA environment variable is set"
1004        } else if cfg!(target_os = "macos") {
1005            "On macOS: Check that the HOME environment variable is set"
1006        } else {
1007            "On Linux: Check that the XDG_CACHE_HOME or HOME environment variable is set"
1008        };
1009        anyhow::anyhow!("Could not determine cache directory.\n\n{platform_help}")
1010    })
1011}
1012
1013/// Returns the platform-specific data directory for AGPM.
1014///
1015/// This function returns the appropriate data directory for storing persistent
1016/// application data, following platform conventions and standards.
1017///
1018/// # Returns
1019///
1020/// The data directory path (`{data_dir}/agpm`), or an error if it cannot be determined
1021///
1022/// # Examples
1023///
1024/// ```rust,no_run
1025/// use agpm_cli::utils::platform::get_data_dir;
1026///
1027/// # fn example() -> anyhow::Result<()> {
1028/// let data_dir = get_data_dir()?;
1029/// println!("AGPM data directory: {}", data_dir.display());
1030///
1031/// // Use for storing persistent data
1032/// let lockfile_backup = data_dir.join("lockfile_backups");
1033/// # Ok(())
1034/// # }
1035/// ```
1036///
1037/// # Platform Paths
1038///
1039/// - **Linux**: `$XDG_DATA_HOME/agpm` or `$HOME/.local/share/agpm`
1040/// - **macOS**: `$HOME/Library/Application Support/agpm`
1041/// - **Windows**: `%APPDATA%\agpm`
1042///
1043/// # Standards Compliance
1044///
1045/// - **Linux**: Follows XDG Base Directory Specification
1046/// - **macOS**: Follows Apple File System Programming Guide
1047/// - **Windows**: Follows Windows Known Folders conventions
1048///
1049/// # Use Cases
1050///
1051/// - Storing user preferences and settings
1052/// - Application state and history
1053/// - User-created templates and profiles
1054/// - Persistent application data
1055///
1056/// # Persistence
1057///
1058/// Unlike cache directories, data directories are intended for long-term
1059/// storage and should persist across system updates and cleanup operations.
1060///
1061/// # Difference from Cache
1062///
1063/// - **Data directory**: Persistent, user-important data
1064/// - **Cache directory**: Temporary, performance optimization data
1065///
1066/// # See Also
1067///
1068/// - [`get_cache_dir`] for temporary cached data
1069/// - [`get_home_dir`] for user home directory
1070pub fn get_data_dir() -> Result<PathBuf> {
1071    dirs::data_dir().map(|p| p.join("agpm")).ok_or_else(|| {
1072        let platform_help = if is_windows() {
1073            "On Windows: Check that the APPDATA environment variable is set"
1074        } else if cfg!(target_os = "macos") {
1075            "On macOS: Check that the HOME environment variable is set"
1076        } else {
1077            "On Linux: Check that the XDG_DATA_HOME or HOME environment variable is set"
1078        };
1079        anyhow::anyhow!("Could not determine data directory.\n\n{platform_help}")
1080    })
1081}
1082
1083/// Handles Windows long paths (>260 characters) by applying UNC prefixes.
1084///
1085/// This Windows-specific function automatically applies the `\\?\` UNC prefix
1086/// to paths longer than 260 characters, enabling access to long paths on Windows.
1087/// The function is a no-op on other platforms.
1088///
1089/// # Arguments
1090///
1091/// * `path` - The path to potentially convert to long path format
1092///
1093/// # Returns
1094///
1095/// - On Windows: Path with UNC prefix if needed, original path otherwise
1096/// - On other platforms: Returns the original path unchanged
1097///
1098/// # Examples
1099///
1100/// ```rust,no_run
1101/// use agpm_cli::utils::platform::windows_long_path;
1102/// use std::path::Path;
1103///
1104/// let long_path = Path::new("C:\\very\\long\\path\\that\\exceeds\\windows\\limit");
1105/// let handled_path = windows_long_path(long_path);
1106///
1107/// #[cfg(windows)]
1108/// {
1109///     // May have \\?\ prefix if path is long
1110///     println!("Handled path: {}", handled_path.display());
1111/// }
1112/// ```
1113///
1114/// # Windows Long Path Support
1115///
1116/// Windows historically limited paths to 260 characters (MAX_PATH). Modern
1117/// Windows versions support longer paths when:
1118/// - The application uses UNC paths (`\\?\` prefix)
1119/// - Windows 10 version 1607+ with long path support enabled
1120/// - The application manifest declares long path awareness
1121///
1122/// # UNC Prefixes Applied
1123///
1124/// - **Local paths**: `C:\path` becomes `\\?\C:\path`
1125/// - **Network paths**: `\\server\share` becomes `\\?\UNC\server\share`
1126/// - **Already prefixed**: No change to existing UNC paths
1127///
1128/// # Automatic Conversion
1129///
1130/// The function only applies prefixes when:
1131/// - Running on Windows
1132/// - Path length exceeds 260 characters
1133/// - Path doesn't already have a UNC prefix
1134/// - Path can be converted to absolute form
1135///
1136/// # Use Cases
1137///
1138/// - Deep directory structures in build systems
1139/// - Git repositories with long path names
1140/// - User data with deeply nested folders
1141/// - Ensuring compatibility across Windows versions
1142///
1143/// # See Also
1144///
1145/// - Microsoft documentation on long path support
1146/// - `safe_canonicalize` which uses this function internally
1147#[cfg(windows)]
1148pub fn windows_long_path(path: &Path) -> PathBuf {
1149    let path_str = path.to_string_lossy();
1150    if path_str.len() > 260 && !path_str.starts_with(r"\\?\") {
1151        // Convert to absolute path if relative
1152        let absolute_path = if path.is_relative() {
1153            std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
1154        } else {
1155            path.to_path_buf()
1156        };
1157
1158        let absolute_str = absolute_path.to_string_lossy();
1159        if absolute_str.len() > 260 {
1160            // Use UNC prefix for long paths
1161            if let Some(stripped) = absolute_str.strip_prefix(r"\\") {
1162                // Network path
1163                PathBuf::from(format!(r"\\?\UNC\{}", stripped))
1164            } else {
1165                // Local path
1166                PathBuf::from(format!(r"\\?\{}", absolute_str))
1167            }
1168        } else {
1169            absolute_path
1170        }
1171    } else {
1172        path.to_path_buf()
1173    }
1174}
1175
1176/// No-op implementation of [`windows_long_path`] for non-Windows platforms.
1177///
1178/// On Unix-like systems (macOS, Linux, BSD), there is no equivalent to Windows'
1179/// 260-character path limitation, so this function simply returns the input path
1180/// unchanged.
1181///
1182/// # Arguments
1183///
1184/// * `path` - The path to return unchanged
1185///
1186/// # Returns
1187///
1188/// The original path as a [`PathBuf`]
1189///
1190/// # See Also
1191///
1192/// - The Windows implementation for details on long path handling
1193#[cfg(not(windows))]
1194#[must_use]
1195pub fn windows_long_path(path: &Path) -> PathBuf {
1196    path.to_path_buf()
1197}
1198
1199/// Returns the appropriate shell command and flag for the current platform.
1200///
1201/// This function returns the platform-specific shell executable and the flag
1202/// used to execute a command string. This is used for running shell commands
1203/// in a cross-platform manner.
1204///
1205/// # Returns
1206///
1207/// A tuple of (`shell_command`, `execute_flag)`:
1208/// - Windows: `("cmd", "/C")`
1209/// - Unix-like: `("sh", "-c")`
1210///
1211/// # Examples
1212///
1213/// ```rust,no_run
1214/// use agpm_cli::utils::platform::get_shell_command;
1215/// use std::process::Command;
1216///
1217/// # fn example() -> anyhow::Result<()> {
1218/// let (shell, flag) = get_shell_command();
1219///
1220/// let output = Command::new(shell)
1221///     .arg(flag)
1222///     .arg("echo Hello World")
1223///     .output()?;
1224///
1225/// println!("Output: {}", String::from_utf8_lossy(&output.stdout));
1226/// # Ok(())
1227/// # }
1228/// ```
1229///
1230/// # Platform Commands
1231///
1232/// - **Windows**: Uses `cmd.exe` with `/C` flag to execute and terminate
1233/// - **Unix-like**: Uses `sh` with `-c` flag for POSIX shell compatibility
1234///
1235/// # Use Cases
1236///
1237/// - Executing shell commands in a cross-platform way
1238/// - Running system utilities and tools
1239/// - Batch operations that require shell features
1240/// - Environment-specific command execution
1241///
1242/// # Security Considerations
1243///
1244/// When using this function with user input, ensure proper escaping and
1245/// validation to prevent command injection vulnerabilities.
1246///
1247/// # Alternative Shells
1248///
1249/// This function returns the most compatible shell for each platform.
1250/// For specific shell requirements (bash, `PowerShell`, etc.), use direct
1251/// command execution instead.
1252///
1253/// # See Also
1254///
1255/// - [`command_exists`] for checking shell availability
1256/// - [`std::process::Command`] for safe command execution
1257#[must_use]
1258pub const fn get_shell_command() -> (&'static str, &'static str) {
1259    if is_windows() {
1260        ("cmd", "/C")
1261    } else {
1262        ("sh", "-c")
1263    }
1264}
1265
1266/// Validates that a path contains only characters valid for the current platform.
1267///
1268/// This function checks path strings for invalid characters and reserved names
1269/// according to platform-specific filesystem rules. It helps prevent errors
1270/// when creating files and directories.
1271///
1272/// # Arguments
1273///
1274/// * `path` - The path string to validate
1275///
1276/// # Returns
1277///
1278/// - `Ok(())` if the path is valid for the current platform
1279/// - `Err` if the path contains invalid characters or reserved names
1280///
1281/// # Examples
1282///
1283/// ```rust,no_run
1284/// use agpm_cli::utils::platform::validate_path_chars;
1285///
1286/// # fn example() -> anyhow::Result<()> {
1287/// // Valid paths
1288/// validate_path_chars("valid/path/file.txt")?;
1289/// validate_path_chars("another_valid_file.md")?;
1290///
1291/// // Invalid on Windows (but may be valid on Unix)
1292/// # #[cfg(windows)]
1293/// # {
1294/// let result = validate_path_chars("invalid:file.txt");
1295/// assert!(result.is_err());
1296/// # }
1297/// # Ok(())
1298/// # }
1299/// ```
1300///
1301/// # Platform-Specific Rules
1302///
1303/// ## Windows
1304/// **Invalid characters**: `< > : " | ? *` and control characters (0x00-0x1F)
1305///
1306/// **Reserved names**: `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`
1307/// (case-insensitive, applies to bare names without extensions)
1308///
1309/// ## Unix-like Systems
1310/// - Only the null character (`\0`) is invalid
1311/// - No reserved names (though some names like `.` and `..` have special meaning)
1312/// - Case-sensitive validation
1313///
1314/// # Use Cases
1315///
1316/// - Validating user input for file names
1317/// - Checking paths before creation
1318/// - Preventing filesystem errors
1319/// - Cross-platform path compatibility
1320///
1321/// # Security
1322///
1323/// This validation helps prevent:
1324/// - Filesystem errors from invalid characters
1325/// - Accidental overwriting of system files (Windows reserved names)
1326/// - Path injection attacks using special characters
1327///
1328/// # Limitations
1329///
1330/// - Does not check path length limits
1331/// - Does not verify directory existence
1332/// - May not catch all filesystem-specific restrictions
1333///
1334/// # See Also
1335///
1336/// - [`safe_join`] which uses this function for validation
1337/// - Platform filesystem documentation for complete rules
1338pub fn validate_path_chars(path: &str) -> Result<()> {
1339    if is_windows() {
1340        // Windows invalid characters: < > : " | ? * and control characters
1341        const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
1342
1343        for ch in path.chars() {
1344            if INVALID_CHARS.contains(&ch) || ch.is_control() {
1345                return Err(anyhow::anyhow!(
1346                    "Invalid character '{ch}' in path: {path}\\n\\n\\\n                    Windows paths cannot contain: < > : \" | ? * or control characters"
1347                ));
1348            }
1349        }
1350
1351        // Check for reserved names in all path components
1352        const RESERVED_NAMES: &[&str] = &[
1353            "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
1354            "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
1355        ];
1356
1357        // Check each component of the path
1358        for component in Path::new(path).components() {
1359            if let Some(os_str) = component.as_os_str().to_str() {
1360                // Check if the entire component (without extension) is a reserved name
1361                // Reserved names are only invalid if they're the complete name (no extension)
1362                let upper = os_str.to_uppercase();
1363
1364                // Check if it's exactly a reserved name (no extension)
1365                if RESERVED_NAMES.contains(&upper.as_str()) {
1366                    return Err(anyhow::anyhow!(
1367                        "Reserved name '{}' in path: {}\\n\\n\\\n                    Windows reserved names: {}",
1368                        os_str,
1369                        path,
1370                        RESERVED_NAMES.join(", ")
1371                    ));
1372                }
1373            }
1374        }
1375    }
1376
1377    Ok(())
1378}
1379
1380/// Safely joins a base path with a relative path, preventing directory traversal.
1381///
1382/// This function securely combines a base directory with a relative path while
1383/// preventing directory traversal attacks. It validates the input path and
1384/// ensures the result stays within the base directory.
1385///
1386/// # Arguments
1387///
1388/// * `base` - The base directory that should contain the result
1389/// * `path` - The relative path to join (validated for safety)
1390///
1391/// # Returns
1392///
1393/// The joined path with proper platform-specific handling, or an error if:
1394/// - The path contains invalid characters
1395/// - The path would escape the base directory
1396/// - Platform-specific validation fails
1397///
1398/// # Examples
1399///
1400/// ```rust,no_run
1401/// use agpm_cli::utils::platform::safe_join;
1402/// use std::path::Path;
1403///
1404/// # fn example() -> anyhow::Result<()> {
1405/// let base = Path::new("/home/user/project");
1406///
1407/// // Safe joins
1408/// let file_path = safe_join(base, "src/main.rs")?;
1409/// let nested_path = safe_join(base, "docs/guide/intro.md")?;
1410///
1411/// // These would fail (directory traversal)
1412/// // safe_join(base, "../../../etc/passwd").unwrap_err();
1413/// // safe_join(base, "/absolute/path").unwrap_err();
1414/// # Ok(())
1415/// # }
1416/// ```
1417///
1418/// # Security Features
1419///
1420/// - **Path traversal prevention**: Detects and blocks `../` escape attempts
1421/// - **Character validation**: Ensures valid characters for the platform
1422/// - **Normalization**: Resolves `.` and `..` components before validation
1423/// - **Platform handling**: Applies Windows long path support when needed
1424///
1425/// # Validation Performed
1426///
1427/// 1. **Character validation**: Checks for platform-invalid characters
1428/// 2. **Traversal detection**: Identifies attempts to escape base directory
1429/// 3. **Path normalization**: Resolves relative components
1430/// 4. **Boundary checking**: Ensures result stays within base
1431///
1432/// # Error Cases
1433///
1434/// - Path contains invalid characters (platform-specific)
1435/// - Path traversal attempt detected (`../../../etc/passwd`)
1436/// - Path would resolve outside the base directory
1437/// - Windows reserved names used in path components
1438///
1439/// # Use Cases
1440///
1441/// - Processing user-provided relative paths
1442/// - Extracting archive files safely
1443/// - Configuration file path resolution
1444/// - API endpoints that accept file paths
1445///
1446/// # Platform Behavior
1447///
1448/// - **Windows**: Handles long paths, validates reserved names
1449/// - **Unix-like**: Allows most characters, prevents null bytes
1450/// - **All platforms**: Prevents directory traversal attacks
1451///
1452/// # See Also
1453///
1454/// - [`validate_path_chars`] for character validation details
1455/// - [`windows_long_path`] for Windows path handling
1456/// - `is_safe_path` for path safety checking
1457pub fn safe_join(base: &Path, path: &str) -> Result<PathBuf> {
1458    // Validate the path characters first
1459    validate_path_chars(path)?;
1460
1461    let path_buf = PathBuf::from(path);
1462
1463    // Check for path traversal attempts
1464    if path.contains("..") {
1465        let joined = base.join(&path_buf);
1466        let normalized = crate::utils::fs::normalize_path(&joined);
1467        if !normalized.starts_with(base) {
1468            return Err(anyhow::anyhow!(
1469                "Path traversal detected in: {path}\\n\\n\\\n                Attempted to access path outside base directory"
1470            ));
1471        }
1472    }
1473
1474    let result = base.join(path_buf);
1475    Ok(windows_long_path(&result))
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481
1482    #[test]
1483    fn test_is_windows() {
1484        #[cfg(windows)]
1485        assert!(is_windows());
1486
1487        #[cfg(not(windows))]
1488        assert!(!is_windows());
1489    }
1490
1491    #[test]
1492    fn test_git_command() {
1493        let cmd = get_git_command();
1494        #[cfg(windows)]
1495        assert_eq!(cmd, "git.exe");
1496
1497        #[cfg(not(windows))]
1498        assert_eq!(cmd, "git");
1499    }
1500
1501    #[test]
1502    fn test_get_home_dir() {
1503        let home = get_home_dir();
1504        assert!(home.is_ok());
1505        let home_path = home.unwrap();
1506        assert!(home_path.exists());
1507    }
1508
1509    #[test]
1510    fn test_resolve_path_tilde() {
1511        let home = get_home_dir().unwrap();
1512
1513        let resolved = resolve_path("~/test").unwrap();
1514        assert_eq!(resolved, home.join("test"));
1515
1516        let resolved = resolve_path("~/test/file.txt").unwrap();
1517        assert_eq!(resolved, home.join("test/file.txt"));
1518    }
1519
1520    #[test]
1521    fn test_resolve_path_absolute() {
1522        let resolved = resolve_path("/tmp/test").unwrap();
1523        assert_eq!(resolved, PathBuf::from("/tmp/test"));
1524    }
1525
1526    #[test]
1527    fn test_resolve_path_relative() {
1528        let resolved = resolve_path("test/file.txt").unwrap();
1529        assert_eq!(resolved, PathBuf::from("test/file.txt"));
1530    }
1531
1532    #[test]
1533    fn test_resolve_path_invalid_tilde() {
1534        let result = resolve_path("~test");
1535        assert!(result.is_err());
1536    }
1537
1538    #[test]
1539    fn test_normalize_path_separator() {
1540        let path = Path::new("test/path/file.txt");
1541        let normalized = normalize_path_separator(path);
1542
1543        #[cfg(windows)]
1544        assert_eq!(normalized, "test\\path\\file.txt");
1545
1546        #[cfg(not(windows))]
1547        assert_eq!(normalized, "test/path/file.txt");
1548    }
1549
1550    #[test]
1551    fn test_normalize_path_for_storage() {
1552        // Test Unix-style path (should remain unchanged)
1553        let unix_path = Path::new(".claude/agents/example.md");
1554        assert_eq!(normalize_path_for_storage(unix_path), ".claude/agents/example.md");
1555
1556        // Test Windows-style path (should convert to forward slashes)
1557        let windows_path = Path::new(".claude\\agents\\example.md");
1558        assert_eq!(normalize_path_for_storage(windows_path), ".claude/agents/example.md");
1559
1560        // Test mixed separators (should normalize all to forward slashes)
1561        let mixed_path = Path::new("src/utils\\platform.rs");
1562        assert_eq!(normalize_path_for_storage(mixed_path), "src/utils/platform.rs");
1563
1564        // Test nested Windows path
1565        let nested = Path::new(".claude\\agents\\ai\\gpt.md");
1566        assert_eq!(normalize_path_for_storage(nested), ".claude/agents/ai/gpt.md");
1567
1568        // Test that result is always forward slashes regardless of platform
1569        let path = Path::new("test\\nested\\path\\file.txt");
1570        let normalized = normalize_path_for_storage(path);
1571        assert_eq!(normalized, "test/nested/path/file.txt");
1572        assert!(!normalized.contains('\\'));
1573    }
1574
1575    #[test]
1576    fn test_command_exists() {
1577        // Test with a command that should exist on all systems
1578        #[cfg(unix)]
1579        assert!(command_exists("sh"));
1580
1581        #[cfg(windows)]
1582        assert!(command_exists("cmd"));
1583
1584        // Test with a command that shouldn't exist
1585        assert!(!command_exists("this_command_should_not_exist_12345"));
1586    }
1587
1588    #[test]
1589    fn test_get_cache_dir() {
1590        let dir = get_cache_dir().unwrap();
1591        assert!(dir.to_string_lossy().contains("agpm"));
1592    }
1593
1594    #[test]
1595    fn test_get_data_dir() {
1596        let dir = get_data_dir().unwrap();
1597        assert!(dir.to_string_lossy().contains("agpm"));
1598    }
1599
1600    #[test]
1601    fn test_windows_long_path() {
1602        let path = Path::new("/test/path");
1603        let result = windows_long_path(path);
1604
1605        #[cfg(windows)]
1606        assert_eq!(result, PathBuf::from("/test/path"));
1607
1608        #[cfg(not(windows))]
1609        assert_eq!(result, path.to_path_buf());
1610    }
1611
1612    #[test]
1613    fn test_get_shell_command() {
1614        let (shell, flag) = get_shell_command();
1615
1616        #[cfg(windows)]
1617        {
1618            assert_eq!(shell, "cmd");
1619            assert_eq!(flag, "/C");
1620        }
1621
1622        #[cfg(not(windows))]
1623        {
1624            assert_eq!(shell, "sh");
1625            assert_eq!(flag, "-c");
1626        }
1627    }
1628
1629    #[test]
1630    fn test_path_to_string() {
1631        let path = Path::new("test/path/file.txt");
1632        let result = path_to_string(path);
1633        assert!(!result.is_empty());
1634        assert!(result.contains("file.txt"));
1635    }
1636
1637    #[test]
1638    fn test_paths_equal() {
1639        let path1 = Path::new("Test/Path");
1640        let path2 = Path::new("test/path");
1641
1642        #[cfg(windows)]
1643        assert!(paths_equal(path1, path2));
1644
1645        #[cfg(not(windows))]
1646        assert!(!paths_equal(path1, path2));
1647
1648        // Same case should always be equal
1649        let path3 = Path::new("test/path");
1650        assert!(paths_equal(path2, path3));
1651    }
1652
1653    #[test]
1654    fn test_safe_canonicalize() {
1655        let temp = tempfile::tempdir().unwrap();
1656        let test_path = temp.path().join("test_file.txt");
1657        std::fs::write(&test_path, "test").unwrap();
1658
1659        let result = safe_canonicalize(&test_path);
1660        assert!(result.is_ok());
1661
1662        let canonical = result.unwrap();
1663        assert!(canonical.is_absolute());
1664        assert!(canonical.exists());
1665    }
1666
1667    #[test]
1668    fn test_validate_path_chars() {
1669        // Valid paths should pass
1670        assert!(validate_path_chars("valid/path/file.txt").is_ok());
1671        assert!(validate_path_chars("underscore_file.txt").is_ok());
1672
1673        #[cfg(windows)]
1674        {
1675            // Invalid Windows characters should fail
1676            assert!(validate_path_chars("invalid:file.txt").is_err());
1677            assert!(validate_path_chars("invalid|file.txt").is_err());
1678            assert!(validate_path_chars("invalid?file.txt").is_err());
1679
1680            // Reserved names should fail
1681            assert!(validate_path_chars("CON").is_err());
1682            assert!(validate_path_chars("PRN").is_err());
1683            assert!(validate_path_chars("path/AUX/file.txt").is_err());
1684        }
1685    }
1686
1687    #[test]
1688    fn test_safe_join() {
1689        let base = Path::new("/home/user/project");
1690
1691        // Normal join should work
1692        let result = safe_join(base, "subdir/file.txt");
1693        assert!(result.is_ok());
1694
1695        // Path traversal should be detected and rejected
1696        let result = safe_join(base, "../../../etc/passwd");
1697        assert!(result.is_err());
1698
1699        #[cfg(windows)]
1700        {
1701            // Invalid Windows characters should be rejected
1702            let result = safe_join(base, "invalid:file.txt");
1703            assert!(result.is_err());
1704        }
1705    }
1706
1707    #[test]
1708    fn test_validate_path_chars_edge_cases() {
1709        // Test empty path
1710        assert!(validate_path_chars("").is_ok());
1711
1712        // Test path with spaces
1713        assert!(validate_path_chars("path with spaces/file.txt").is_ok());
1714
1715        // Test path with dots
1716        assert!(validate_path_chars("../relative/path.txt").is_ok());
1717
1718        #[cfg(windows)]
1719        {
1720            // Test control characters
1721            assert!(validate_path_chars("file\0name").is_err());
1722            assert!(validate_path_chars("file\nname").is_err());
1723
1724            // Test all invalid Windows chars
1725            for ch in &['<', '>', ':', '"', '|', '?', '*'] {
1726                let invalid_path = format!("file{}name", ch);
1727                assert!(validate_path_chars(&invalid_path).is_err());
1728            }
1729
1730            // Test reserved names with extensions (should be ok)
1731            assert!(validate_path_chars("CON.txt").is_ok());
1732            assert!(validate_path_chars("PRN.log").is_ok());
1733        }
1734    }
1735
1736    #[test]
1737    fn test_safe_join_edge_cases() {
1738        let base = Path::new("/base");
1739
1740        // Test single dot (current dir)
1741        let result = safe_join(base, ".");
1742        assert!(result.is_ok());
1743
1744        // Test safe relative path with ..
1745        let result = safe_join(base, "subdir/../file.txt");
1746        assert!(result.is_ok());
1747
1748        // Test absolute path join
1749        let result = safe_join(base, "/absolute/path");
1750        assert!(result.is_ok());
1751    }
1752
1753    #[test]
1754    fn test_resolve_path_invalid_env_var() {
1755        // Test with undefined environment variable
1756        let result = resolve_path("$UNDEFINED_VAR_123/path");
1757        // This should either fail or expand to empty/current path
1758        if result.is_ok() {
1759            // Some systems might expand undefined vars to empty string
1760        } else {
1761            // This is also acceptable behavior
1762        }
1763    }
1764
1765    #[test]
1766    fn test_windows_specific_tilde_error() {
1767        // Test invalid Windows tilde usage on any platform
1768        let result = resolve_path("~user/file.txt");
1769        assert!(result.is_err());
1770    }
1771
1772    #[test]
1773    fn test_get_executable_extension() {
1774        let ext = get_executable_extension();
1775
1776        #[cfg(windows)]
1777        assert_eq!(ext, ".exe");
1778
1779        #[cfg(not(windows))]
1780        assert_eq!(ext, "");
1781    }
1782
1783    #[test]
1784    fn test_is_executable_name() {
1785        #[cfg(windows)]
1786        {
1787            assert!(is_executable_name("test.exe"));
1788            assert!(is_executable_name("TEST.EXE"));
1789            assert!(!is_executable_name("test"));
1790            assert!(!is_executable_name("test.txt"));
1791        }
1792
1793        #[cfg(not(windows))]
1794        {
1795            // On Unix, any file can be executable
1796            assert!(is_executable_name("test"));
1797            assert!(is_executable_name("test.sh"));
1798            assert!(is_executable_name("test.exe"));
1799        }
1800    }
1801
1802    #[test]
1803    fn test_normalize_line_endings() {
1804        let text_lf = "line1\nline2\nline3";
1805        let text_crlf = "line1\r\nline2\r\nline3";
1806        let text_mixed = "line1\nline2\r\nline3";
1807
1808        let normalized_lf = normalize_line_endings(text_lf);
1809        let normalized_crlf = normalize_line_endings(text_crlf);
1810        let normalized_mixed = normalize_line_endings(text_mixed);
1811
1812        #[cfg(windows)]
1813        {
1814            assert!(normalized_lf.contains("\r\n"));
1815            assert!(normalized_crlf.contains("\r\n"));
1816            assert!(normalized_mixed.contains("\r\n"));
1817        }
1818
1819        #[cfg(not(windows))]
1820        {
1821            assert!(!normalized_lf.contains('\r'));
1822            assert!(!normalized_crlf.contains('\r'));
1823            assert!(!normalized_mixed.contains('\r'));
1824        }
1825    }
1826
1827    #[test]
1828    fn test_safe_canonicalize_nonexistent() {
1829        let result = safe_canonicalize(Path::new("/nonexistent/path/to/file"));
1830        assert!(result.is_err());
1831    }
1832
1833    #[test]
1834    fn test_safe_canonicalize_relative() {
1835        use tempfile::TempDir;
1836
1837        // Create a temp directory to ensure we have a valid working directory
1838        let temp_dir = TempDir::new().unwrap();
1839        let test_file = temp_dir.path().join("test.txt");
1840        std::fs::write(&test_file, "test").unwrap();
1841
1842        // Test with a file that exists
1843        let result = safe_canonicalize(&test_file);
1844        assert!(result.is_ok());
1845        let canonical = result.unwrap();
1846        assert!(canonical.is_absolute());
1847    }
1848
1849    #[test]
1850    fn test_paths_equal_with_trailing_slash() {
1851        let path1 = Path::new("test/path/");
1852        let path2 = Path::new("test/path");
1853
1854        // Paths should be equal regardless of trailing slash
1855        assert!(paths_equal(path1, path2));
1856    }
1857
1858    #[test]
1859    fn test_validate_path_chars_unicode() {
1860        // Test with unicode characters
1861        assert!(validate_path_chars("文件名.txt").is_ok());
1862        assert!(validate_path_chars("файл.md").is_ok());
1863        assert!(validate_path_chars("αρχείο.rs").is_ok());
1864
1865        // Test with emoji (should be ok on most systems)
1866        assert!(validate_path_chars("📁folder/📄file.txt").is_ok());
1867    }
1868
1869    #[test]
1870    fn test_command_exists_with_path() {
1871        // Test that command_exists works with full paths
1872        #[cfg(unix)]
1873        {
1874            if Path::new("/bin/sh").exists() {
1875                assert!(command_exists("/bin/sh"));
1876            }
1877        }
1878
1879        #[cfg(windows)]
1880        {
1881            if Path::new("C:\\Windows\\System32\\cmd.exe").exists() {
1882                assert!(command_exists("C:\\Windows\\System32\\cmd.exe"));
1883            }
1884        }
1885    }
1886
1887    #[test]
1888    fn test_normalize_path_separator_edge_cases() {
1889        // Test empty path
1890        let empty = Path::new("");
1891        let normalized = normalize_path_separator(empty);
1892        assert_eq!(normalized, "");
1893
1894        // Test root path
1895        #[cfg(unix)]
1896        {
1897            let root = Path::new("/");
1898            let normalized = normalize_path_separator(root);
1899            assert_eq!(normalized, "/");
1900        }
1901
1902        #[cfg(windows)]
1903        {
1904            let root = Path::new("C:\\");
1905            let normalized = normalize_path_separator(root);
1906            assert_eq!(normalized, "C:\\");
1907        }
1908    }
1909
1910    #[test]
1911    fn test_path_to_string_invalid_utf8() {
1912        // This test is mainly for Unix where paths can be non-UTF8
1913        #[cfg(unix)]
1914        {
1915            use std::ffi::OsStr;
1916            use std::os::unix::ffi::OsStrExt;
1917
1918            // Create a path with invalid UTF-8
1919            let invalid_bytes = vec![0xff, 0xfe, 0xfd];
1920            let os_str = OsStr::from_bytes(&invalid_bytes);
1921            let path = Path::new(os_str);
1922
1923            // path_to_string should handle this gracefully
1924            let result = path_to_string(path);
1925            assert!(!result.is_empty());
1926        }
1927    }
1928
1929    #[test]
1930    fn test_safe_join_complex_scenarios() {
1931        let base = Path::new("/home/user");
1932
1933        // Test with empty path component
1934        let result = safe_join(base, "");
1935        assert!(result.is_ok());
1936
1937        // Test with multiple slashes
1938        let result = safe_join(base, "path//to///file");
1939        assert!(result.is_ok());
1940
1941        // Test with backslashes on Unix (should be treated as regular characters)
1942        #[cfg(unix)]
1943        {
1944            let result = safe_join(base, "path\\to\\file");
1945            assert!(result.is_ok());
1946        }
1947    }
1948
1949    #[test]
1950    fn test_resolve_path_complex() {
1951        // Test multiple ~ in path (only first should be expanded)
1952        let result = resolve_path("~/path/~file.txt");
1953        assert!(result.is_ok());
1954        let resolved = result.unwrap();
1955        assert!(!resolved.to_string_lossy().starts_with('~'));
1956
1957        // Test empty path
1958        let result = resolve_path("");
1959        assert!(result.is_ok());
1960        assert_eq!(result.unwrap(), PathBuf::from(""));
1961    }
1962
1963    #[test]
1964    fn test_get_home_dir_fallback() {
1965        // Test that get_home_dir has appropriate error handling
1966        // We can't easily test the error case without modifying the environment significantly
1967        // but we can verify the function signature and basic operation
1968        match get_home_dir() {
1969            Ok(home) => {
1970                assert!(home.is_absolute());
1971                // Home directory should exist
1972                assert!(home.exists() || home.parent().is_some_and(std::path::Path::exists));
1973            }
1974            Err(e) => {
1975                // If it fails, it should have a meaningful error message
1976                assert!(e.to_string().contains("home") || e.to_string().contains("directory"));
1977            }
1978        }
1979    }
1980
1981    // Helper functions used in the module but not directly exported
1982    fn is_executable_name(_name: &str) -> bool {
1983        #[cfg(windows)]
1984        {
1985            _name.to_lowercase().ends_with(".exe")
1986        }
1987        #[cfg(not(windows))]
1988        {
1989            // On Unix, executability is determined by permissions, not name
1990            true
1991        }
1992    }
1993
1994    fn get_executable_extension() -> &'static str {
1995        #[cfg(windows)]
1996        {
1997            ".exe"
1998        }
1999        #[cfg(not(windows))]
2000        {
2001            ""
2002        }
2003    }
2004
2005    fn normalize_line_endings(text: &str) -> String {
2006        #[cfg(windows)]
2007        {
2008            text.replace('\n', "\r\n").replace("\r\r\n", "\r\n")
2009        }
2010        #[cfg(not(windows))]
2011        {
2012            text.replace("\r\n", "\n")
2013        }
2014    }
2015
2016    #[test]
2017    fn test_normalize_path_for_storage_unix() {
2018        use std::path::Path;
2019        // Unix-style paths should just normalize separators
2020        assert_eq!(
2021            normalize_path_for_storage(Path::new("/project/agents/helper.md")),
2022            "/project/agents/helper.md"
2023        );
2024        assert_eq!(normalize_path_for_storage(Path::new("agents/helper.md")), "agents/helper.md");
2025        assert_eq!(
2026            normalize_path_for_storage(Path::new("../shared/utils.md")),
2027            "../shared/utils.md"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_normalize_path_for_storage_windows_extended() {
2033        use std::path::Path;
2034        // Windows extended-length path prefix should be stripped AND backslashes converted
2035        // This tests the combined behavior: \\?\C:\path -> C:/path
2036        let path = Path::new(r"\\?\C:\project\agents\helper.md");
2037        assert_eq!(
2038            normalize_path_for_storage(path),
2039            "C:/project/agents/helper.md",
2040            "Should strip extended-length prefix (\\\\?\\) AND convert backslashes to forward slashes"
2041        );
2042    }
2043
2044    #[test]
2045    fn test_normalize_path_for_storage_windows_extended_unc() {
2046        use std::path::Path;
2047        // Windows extended-length UNC path should be converted to //server/share format
2048        let path = Path::new(r"\\?\UNC\server\share\file.md");
2049        assert_eq!(normalize_path_for_storage(path), "//server/share/file.md");
2050    }
2051
2052    #[test]
2053    fn test_normalize_path_for_storage_windows_backslash() {
2054        use std::path::Path;
2055        // Windows backslashes should be converted to forward slashes
2056        let path = Path::new(r"C:\project\agents\helper.md");
2057        assert_eq!(normalize_path_for_storage(path), "C:/project/agents/helper.md");
2058    }
2059}