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}