1use anyhow::{Context, Result};
7use regex::Regex;
8use std::path::{Path, PathBuf};
9
10#[must_use]
14pub const fn is_windows() -> bool {
15 cfg!(windows)
16}
17
18pub fn get_home_dir() -> Result<PathBuf> {
22 dirs::home_dir().ok_or_else(|| {
23 let platform_help = if is_windows() {
24 "On Windows: Check that the USERPROFILE environment variable is set"
25 } else {
26 "On Unix/Linux: Check that the HOME environment variable is set"
27 };
28 anyhow::anyhow!("Could not determine home directory.\n\n{platform_help}")
29 })
30}
31
32#[must_use]
36pub const fn get_git_command() -> &'static str {
37 if is_windows() {
38 "git.exe"
39 } else {
40 "git"
41 }
42}
43
44pub fn resolve_path(path: &str) -> Result<PathBuf> {
48 let expanded = if let Some(stripped) = path.strip_prefix("~/") {
49 let home = get_home_dir()?;
50 home.join(stripped)
51 } else if path.starts_with('~') {
52 if is_windows() && path.len() > 1 && !path.starts_with("~/") {
54 return Err(anyhow::anyhow!(
55 "Invalid path: {path}\n\n\
56 Windows tilde expansion only supports '~/' for current user home directory.\n\
57 Use '~/' followed by a relative path, like '~/Documents/file.txt'"
58 ));
59 }
60 return Err(anyhow::anyhow!(
61 "Invalid path: {path}\n\n\
62 Tilde expansion only supports '~/' for home directory.\n\
63 Use '~/' followed by a relative path, like '~/Documents/file.txt'"
64 ));
65 } else {
66 PathBuf::from(path)
67 };
68
69 let path_str = expanded.to_string_lossy();
71
72 let expanded_str = if is_windows() && path_str.contains('%') {
74 let mut result = path_str.to_string();
76 let re = Regex::new(r"%([^%]+)%").unwrap();
77
78 for cap in re.captures_iter(&path_str) {
79 if let Some(var_name) = cap.get(1)
80 && let Ok(value) = std::env::var(var_name.as_str())
81 {
82 result = result.replace(&format!("%{}%", var_name.as_str()), &value);
83 }
84 }
85
86 match shellexpand::env(&result) {
88 Ok(expanded) => expanded.into_owned(),
89 Err(_) => result, }
91 } else {
92 shellexpand::env(&path_str)
94 .with_context(|| {
95 let platform_vars = if is_windows() {
96 "Common Windows variables: %USERPROFILE%, %APPDATA%, %TEMP%"
97 } else {
98 "Common Unix variables: $HOME, $USER, $TMP"
99 };
100
101 format!(
102 "Failed to expand environment variables in path: {path_str}\n\n\
103 Common issues:\n\
104 - Undefined environment variable (e.g., $UNDEFINED_VAR)\n\
105 - Invalid variable syntax (use $VAR or ${{VAR}})\n\
106 - Special characters that need escaping\n\n\
107 {platform_vars}"
108 )
109 })?
110 .into_owned()
111 };
112
113 let result = PathBuf::from(expanded_str);
114
115 Ok(windows_long_path(&result))
117}
118
119#[must_use]
123pub fn normalize_path_separator(path: &Path) -> String {
124 if is_windows() {
125 path.to_string_lossy().replace('/', "\\")
126 } else {
127 path.to_string_lossy().replace('\\', "/")
128 }
129}
130
131#[must_use]
135pub fn normalize_path_for_storage<P: AsRef<Path>>(path: P) -> String {
136 let path_str = path.as_ref().to_string_lossy();
137
138 let cleaned = if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
141 format!("//{}", stripped)
143 } else if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
144 stripped.to_string()
146 } else {
147 path_str.to_string()
148 };
149
150 cleaned.replace('\\', "/")
151}
152
153#[must_use]
159pub fn compute_relative_install_path(tool_root: &Path, dep_path: &Path, flatten: bool) -> PathBuf {
160 use std::path::Component;
161
162 if flatten {
164 if let Some(filename) = dep_path.file_name() {
165 return PathBuf::from(filename);
166 }
167 return dep_path.to_path_buf();
169 }
170
171 let tool_components: Vec<&str> = tool_root
173 .components()
174 .filter_map(|c| {
175 if let Component::Normal(s) = c {
176 s.to_str()
177 } else {
178 None
179 }
180 })
181 .collect();
182
183 let components: Vec<_> = dep_path.components().collect();
185 let (dep_first, first_normal_idx) = components
186 .iter()
187 .enumerate()
188 .find_map(|(idx, c)| {
189 if let Component::Normal(s) = c {
190 s.to_str().map(|s| (s, idx))
191 } else {
192 None
193 }
194 })
195 .map(|(s, idx)| (Some(s), Some(idx)))
196 .unwrap_or((None, None));
197
198 if let Some(dep_first_str) = dep_first {
201 if tool_components.contains(&dep_first_str) {
202 if let Some(idx) = first_normal_idx {
204 return components.iter().skip(idx + 1).collect();
205 }
206 }
207 }
208
209 components
211 .iter()
212 .skip_while(|c| matches!(c, Component::CurDir | Component::Prefix(_) | Component::RootDir))
213 .collect()
214}
215
216#[must_use]
220pub fn path_to_string(path: &Path) -> String {
221 path.to_string_lossy().to_string()
222}
223
224#[must_use]
228pub fn path_to_os_str(path: &Path) -> &std::ffi::OsStr {
229 path.as_os_str()
230}
231
232#[must_use]
236pub fn paths_equal(path1: &Path, path2: &Path) -> bool {
237 if is_windows() {
238 let p1_str = path1.to_string_lossy();
241 let p2_str = path2.to_string_lossy();
242 let p1 = p1_str.trim_end_matches(['/', '\\']).to_lowercase();
243 let p2 = p2_str.trim_end_matches(['/', '\\']).to_lowercase();
244 p1 == p2
245 } else {
246 let p1_str = path1.to_string_lossy();
249 let p2_str = path2.to_string_lossy();
250 let p1 = p1_str.trim_end_matches('/');
251 let p2 = p2_str.trim_end_matches('/');
252 p1 == p2
253 }
254}
255
256pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
260 let canonical = path.canonicalize().with_context(|| {
261 format!(
262 "Failed to canonicalize path: {}\n\n\
263 Possible causes:\n\
264 - Path does not exist\n\
265 - Permission denied\n\
266 - Invalid path characters\n\
267 - Path too long (>260 chars on Windows)",
268 path.display()
269 )
270 })?;
271
272 #[cfg(windows)]
273 {
274 Ok(windows_long_path(&canonical))
275 }
276
277 #[cfg(not(windows))]
278 {
279 Ok(canonical)
280 }
281}
282
283#[must_use]
287pub fn command_exists(cmd: &str) -> bool {
288 which::which(cmd).is_ok()
289}
290
291pub fn get_cache_dir() -> Result<PathBuf> {
295 dirs::cache_dir().map(|p| p.join("agpm")).ok_or_else(|| {
296 let platform_help = if is_windows() {
297 "On Windows: Check that the LOCALAPPDATA environment variable is set"
298 } else if cfg!(target_os = "macos") {
299 "On macOS: Check that the HOME environment variable is set"
300 } else {
301 "On Linux: Check that the XDG_CACHE_HOME or HOME environment variable is set"
302 };
303 anyhow::anyhow!("Could not determine cache directory.\n\n{platform_help}")
304 })
305}
306
307pub fn get_data_dir() -> Result<PathBuf> {
311 dirs::data_dir().map(|p| p.join("agpm")).ok_or_else(|| {
312 let platform_help = if is_windows() {
313 "On Windows: Check that the APPDATA environment variable is set"
314 } else if cfg!(target_os = "macos") {
315 "On macOS: Check that the HOME environment variable is set"
316 } else {
317 "On Linux: Check that the XDG_DATA_HOME or HOME environment variable is set"
318 };
319 anyhow::anyhow!("Could not determine data directory.\n\n{platform_help}")
320 })
321}
322
323#[cfg(windows)]
331#[must_use]
332pub fn windows_long_path(path: &Path) -> PathBuf {
333 if path.as_os_str().len() < 200 {
336 return path.to_path_buf();
337 }
338
339 let path_str = path.to_string_lossy();
340 if path_str.len() > 260 && !path_str.starts_with(r"\\?\") {
341 let absolute_path = if path.is_relative() {
343 std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
344 } else {
345 path.to_path_buf()
346 };
347
348 let absolute_str = absolute_path.to_string_lossy();
349 if absolute_str.len() > 260 {
350 if let Some(stripped) = absolute_str.strip_prefix(r"\\") {
352 PathBuf::from(format!(r"\\?\UNC\{}", stripped))
354 } else {
355 PathBuf::from(format!(r"\\?\{}", absolute_str))
357 }
358 } else {
359 absolute_path
360 }
361 } else {
362 path.to_path_buf()
363 }
364}
365
366#[cfg(not(windows))]
368#[must_use]
369pub fn windows_long_path(path: &Path) -> PathBuf {
370 path.to_path_buf()
371}
372
373#[must_use]
377pub const fn get_shell_command() -> (&'static str, &'static str) {
378 if is_windows() {
379 ("cmd", "/C")
380 } else {
381 ("sh", "-c")
382 }
383}
384
385pub fn validate_path_chars(path: &str) -> Result<()> {
389 if is_windows() {
390 const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
392
393 for ch in path.chars() {
394 if INVALID_CHARS.contains(&ch) || ch.is_control() {
395 return Err(anyhow::anyhow!(
396 "Invalid character '{ch}' in path: {path}\\n\\n\\\n Windows paths cannot contain: < > : \" | ? * or control characters"
397 ));
398 }
399 }
400
401 const RESERVED_NAMES: &[&str] = &[
403 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
404 "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
405 ];
406
407 for component in Path::new(path).components() {
409 if let Some(os_str) = component.as_os_str().to_str() {
410 let upper = os_str.to_uppercase();
413
414 if RESERVED_NAMES.contains(&upper.as_str()) {
416 return Err(anyhow::anyhow!(
417 "Reserved name '{}' in path: {}\\n\\n\\\n Windows reserved names: {}",
418 os_str,
419 path,
420 RESERVED_NAMES.join(", ")
421 ));
422 }
423 }
424 }
425 }
426
427 Ok(())
428}
429
430pub fn safe_join(base: &Path, path: &str) -> Result<PathBuf> {
434 validate_path_chars(path)?;
436
437 let path_buf = PathBuf::from(path);
438
439 if path.contains("..") {
441 let joined = base.join(&path_buf);
442 let normalized = crate::utils::fs::normalize_path(&joined);
443 if !normalized.starts_with(base) {
444 return Err(anyhow::anyhow!(
445 "Path traversal detected in: {path}\\n\\n\\\n Attempted to access path outside base directory"
446 ));
447 }
448 }
449
450 let result = base.join(path_buf);
451 Ok(windows_long_path(&result))
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn test_is_windows() {
460 #[cfg(windows)]
461 assert!(is_windows());
462
463 #[cfg(not(windows))]
464 assert!(!is_windows());
465 }
466
467 #[test]
468 fn test_git_command() {
469 let cmd = get_git_command();
470 #[cfg(windows)]
471 assert_eq!(cmd, "git.exe");
472
473 #[cfg(not(windows))]
474 assert_eq!(cmd, "git");
475 }
476
477 #[test]
478 fn test_get_home_dir() -> Result<()> {
479 let home_path = get_home_dir()?;
480 assert!(home_path.exists());
481 Ok(())
482 }
483
484 #[test]
485 fn test_resolve_path_tilde() {
486 let home = get_home_dir().unwrap();
487
488 let resolved = resolve_path("~/test").unwrap();
489 assert_eq!(resolved, home.join("test"));
490
491 let resolved = resolve_path("~/test/file.txt").unwrap();
492 assert_eq!(resolved, home.join("test/file.txt"));
493 }
494
495 #[test]
496 fn test_resolve_path_absolute() {
497 let resolved = resolve_path("/tmp/test").unwrap();
498 assert_eq!(resolved, PathBuf::from("/tmp/test"));
499 }
500
501 #[test]
502 fn test_resolve_path_relative() {
503 let resolved = resolve_path("test/file.txt").unwrap();
504 assert_eq!(resolved, PathBuf::from("test/file.txt"));
505 }
506
507 #[test]
508 fn test_resolve_path_invalid_tilde() {
509 let result = resolve_path("~test");
510 assert!(result.is_err());
511 }
512
513 #[test]
514 fn test_normalize_path_separator() {
515 let path = Path::new("test/path/file.txt");
516 let normalized = normalize_path_separator(path);
517
518 #[cfg(windows)]
519 assert_eq!(normalized, "test\\path\\file.txt");
520
521 #[cfg(not(windows))]
522 assert_eq!(normalized, "test/path/file.txt");
523 }
524
525 #[test]
526 fn test_normalize_path_for_storage() {
527 let unix_path = Path::new(".claude/agents/example.md");
529 assert_eq!(normalize_path_for_storage(unix_path), ".claude/agents/example.md");
530
531 let windows_path = Path::new(".claude\\agents\\example.md");
533 assert_eq!(normalize_path_for_storage(windows_path), ".claude/agents/example.md");
534
535 let mixed_path = Path::new("src/utils\\platform.rs");
537 assert_eq!(normalize_path_for_storage(mixed_path), "src/utils/platform.rs");
538
539 let nested = Path::new(".claude\\agents\\ai\\gpt.md");
541 assert_eq!(normalize_path_for_storage(nested), ".claude/agents/ai/gpt.md");
542
543 let path = Path::new("test\\nested\\path\\file.txt");
545 let normalized = normalize_path_for_storage(path);
546 assert_eq!(normalized, "test/nested/path/file.txt");
547 assert!(!normalized.contains('\\'));
548 }
549
550 #[test]
551 fn test_command_exists() {
552 #[cfg(unix)]
554 assert!(command_exists("sh"));
555
556 #[cfg(windows)]
557 assert!(command_exists("cmd"));
558
559 assert!(!command_exists("this_command_should_not_exist_12345"));
561 }
562
563 #[test]
564 fn test_get_cache_dir() {
565 let dir = get_cache_dir().unwrap();
566 assert!(dir.to_string_lossy().contains("agpm"));
567 }
568
569 #[test]
570 fn test_get_data_dir() {
571 let dir = get_data_dir().unwrap();
572 assert!(dir.to_string_lossy().contains("agpm"));
573 }
574
575 #[test]
576 fn test_windows_long_path() {
577 let path = Path::new("/test/path");
578 let result = windows_long_path(path);
579
580 #[cfg(windows)]
581 assert_eq!(result, PathBuf::from("/test/path"));
582
583 #[cfg(not(windows))]
584 assert_eq!(result, path.to_path_buf());
585 }
586
587 #[test]
588 fn test_get_shell_command() {
589 let (shell, flag) = get_shell_command();
590
591 #[cfg(windows)]
592 {
593 assert_eq!(shell, "cmd");
594 assert_eq!(flag, "/C");
595 }
596
597 #[cfg(not(windows))]
598 {
599 assert_eq!(shell, "sh");
600 assert_eq!(flag, "-c");
601 }
602 }
603
604 #[test]
605 fn test_path_to_string() {
606 let path = Path::new("test/path/file.txt");
607 let result = path_to_string(path);
608 assert!(!result.is_empty());
609 assert!(result.contains("file.txt"));
610 }
611
612 #[test]
613 fn test_paths_equal() {
614 let path1 = Path::new("Test/Path");
615 let path2 = Path::new("test/path");
616
617 #[cfg(windows)]
618 assert!(paths_equal(path1, path2));
619
620 #[cfg(not(windows))]
621 assert!(!paths_equal(path1, path2));
622
623 let path3 = Path::new("test/path");
625 assert!(paths_equal(path2, path3));
626 }
627
628 #[test]
629 fn test_safe_canonicalize() -> Result<()> {
630 let temp = tempfile::tempdir().unwrap();
631 let test_path = temp.path().join("test_file.txt");
632 std::fs::write(&test_path, "test").unwrap();
633
634 let canonical = safe_canonicalize(&test_path)?;
635 assert!(canonical.is_absolute());
636 assert!(canonical.exists());
637 Ok(())
638 }
639
640 #[test]
641 fn test_validate_path_chars() {
642 assert!(validate_path_chars("valid/path/file.txt").is_ok());
644 assert!(validate_path_chars("underscore_file.txt").is_ok());
645
646 #[cfg(windows)]
647 {
648 assert!(validate_path_chars("invalid:file.txt").is_err());
650 assert!(validate_path_chars("invalid|file.txt").is_err());
651 assert!(validate_path_chars("invalid?file.txt").is_err());
652
653 assert!(validate_path_chars("CON").is_err());
655 assert!(validate_path_chars("PRN").is_err());
656 assert!(validate_path_chars("path/AUX/file.txt").is_err());
657 }
658 }
659
660 #[test]
661 fn test_safe_join() -> Result<()> {
662 let base = Path::new("/home/user/project");
663
664 let _joined = safe_join(base, "subdir/file.txt")?;
666
667 let result = safe_join(base, "../../../etc/passwd");
669 assert!(result.is_err());
670
671 #[cfg(windows)]
672 {
673 let result = safe_join(base, "invalid:file.txt");
675 assert!(result.is_err());
676 }
677 Ok(())
678 }
679
680 #[test]
681 fn test_validate_path_chars_edge_cases() {
682 assert!(validate_path_chars("").is_ok());
684
685 assert!(validate_path_chars("path with spaces/file.txt").is_ok());
687
688 assert!(validate_path_chars("../relative/path.txt").is_ok());
690
691 #[cfg(windows)]
692 {
693 assert!(validate_path_chars("file\0name").is_err());
695 assert!(validate_path_chars("file\nname").is_err());
696
697 for ch in &['<', '>', ':', '"', '|', '?', '*'] {
699 let invalid_path = format!("file{}name", ch);
700 assert!(validate_path_chars(&invalid_path).is_err());
701 }
702
703 assert!(validate_path_chars("CON.txt").is_ok());
705 assert!(validate_path_chars("PRN.log").is_ok());
706 }
707 }
708
709 #[test]
710 fn test_safe_join_edge_cases() -> Result<()> {
711 let base = Path::new("/base");
712
713 let _current = safe_join(base, ".")?;
715
716 let _safe_relative = safe_join(base, "subdir/../file.txt")?;
718
719 let _absolute = safe_join(base, "/absolute/path")?;
721 Ok(())
722 }
723
724 #[test]
725 fn test_resolve_path_invalid_env_var() {
726 let result = resolve_path("$UNDEFINED_VAR_123/path");
728 if result.is_ok() {
730 } else {
732 }
734 }
735
736 #[test]
737 fn test_windows_specific_tilde_error() {
738 let result = resolve_path("~user/file.txt");
740 assert!(result.is_err());
741 }
742
743 #[test]
744 fn test_get_executable_extension() {
745 let ext = get_executable_extension();
746
747 #[cfg(windows)]
748 assert_eq!(ext, ".exe");
749
750 #[cfg(not(windows))]
751 assert_eq!(ext, "");
752 }
753
754 #[test]
755 fn test_is_executable_name() {
756 #[cfg(windows)]
757 {
758 assert!(is_executable_name("test.exe"));
759 assert!(is_executable_name("TEST.EXE"));
760 assert!(!is_executable_name("test"));
761 assert!(!is_executable_name("test.txt"));
762 }
763
764 #[cfg(not(windows))]
765 {
766 assert!(is_executable_name("test"));
768 assert!(is_executable_name("test.sh"));
769 assert!(is_executable_name("test.exe"));
770 }
771 }
772
773 #[test]
774 fn test_normalize_line_endings() {
775 let text_lf = "line1\nline2\nline3";
776 let text_crlf = "line1\r\nline2\r\nline3";
777 let text_mixed = "line1\nline2\r\nline3";
778
779 let normalized_lf = normalize_line_endings(text_lf);
780 let normalized_crlf = normalize_line_endings(text_crlf);
781 let normalized_mixed = normalize_line_endings(text_mixed);
782
783 #[cfg(windows)]
784 {
785 assert!(normalized_lf.contains("\r\n"));
786 assert!(normalized_crlf.contains("\r\n"));
787 assert!(normalized_mixed.contains("\r\n"));
788 }
789
790 #[cfg(not(windows))]
791 {
792 assert!(!normalized_lf.contains('\r'));
793 assert!(!normalized_crlf.contains('\r'));
794 assert!(!normalized_mixed.contains('\r'));
795 }
796 }
797
798 #[test]
799 fn test_safe_canonicalize_nonexistent() {
800 let result = safe_canonicalize(Path::new("/nonexistent/path/to/file"));
801 assert!(result.is_err());
802 }
803
804 #[test]
805 fn test_safe_canonicalize_relative() -> Result<()> {
806 use tempfile::TempDir;
807
808 let temp_dir = TempDir::new().unwrap();
810 let test_file = temp_dir.path().join("test.txt");
811 std::fs::write(&test_file, "test").unwrap();
812
813 let canonical = safe_canonicalize(&test_file)?;
815 assert!(canonical.is_absolute());
816 Ok(())
817 }
818
819 #[test]
820 fn test_paths_equal_with_trailing_slash() {
821 let path1 = Path::new("test/path/");
822 let path2 = Path::new("test/path");
823
824 assert!(paths_equal(path1, path2));
826 }
827
828 #[test]
829 fn test_validate_path_chars_unicode() {
830 assert!(validate_path_chars("文件名.txt").is_ok());
832 assert!(validate_path_chars("файл.md").is_ok());
833 assert!(validate_path_chars("αρχείο.rs").is_ok());
834
835 assert!(validate_path_chars("📁folder/📄file.txt").is_ok());
837 }
838
839 #[test]
840 fn test_command_exists_with_path() {
841 #[cfg(unix)]
843 {
844 if Path::new("/bin/sh").exists() {
845 assert!(command_exists("/bin/sh"));
846 }
847 }
848
849 #[cfg(windows)]
850 {
851 if Path::new("C:\\Windows\\System32\\cmd.exe").exists() {
852 assert!(command_exists("C:\\Windows\\System32\\cmd.exe"));
853 }
854 }
855 }
856
857 #[test]
858 fn test_normalize_path_separator_edge_cases() {
859 let empty = Path::new("");
861 let normalized = normalize_path_separator(empty);
862 assert_eq!(normalized, "");
863
864 #[cfg(unix)]
866 {
867 let root = Path::new("/");
868 let normalized = normalize_path_separator(root);
869 assert_eq!(normalized, "/");
870 }
871
872 #[cfg(windows)]
873 {
874 let root = Path::new("C:\\");
875 let normalized = normalize_path_separator(root);
876 assert_eq!(normalized, "C:\\");
877 }
878 }
879
880 #[test]
881 fn test_path_to_string_invalid_utf8() {
882 #[cfg(unix)]
884 {
885 use std::ffi::OsStr;
886 use std::os::unix::ffi::OsStrExt;
887
888 let invalid_bytes = vec![0xff, 0xfe, 0xfd];
890 let os_str = OsStr::from_bytes(&invalid_bytes);
891 let path = Path::new(os_str);
892
893 let result = path_to_string(path);
895 assert!(!result.is_empty());
896 }
897 }
898
899 #[test]
900 fn test_safe_join_complex_scenarios() -> Result<()> {
901 let base = Path::new("/home/user");
902
903 let _empty = safe_join(base, "")?;
905
906 let _multiple_slashes = safe_join(base, "path//to///file")?;
908
909 #[cfg(unix)]
911 {
912 let _backslashes = safe_join(base, "path\\to\\file")?;
913 }
914 Ok(())
915 }
916
917 #[test]
918 fn test_resolve_path_complex() -> Result<()> {
919 let resolved = resolve_path("~/path/~file.txt")?;
921 assert!(!resolved.to_string_lossy().starts_with('~'));
922
923 let empty = resolve_path("")?;
925 assert_eq!(empty, PathBuf::from(""));
926 Ok(())
927 }
928
929 #[test]
930 fn test_get_home_dir_fallback() {
931 match get_home_dir() {
935 Ok(home) => {
936 assert!(home.is_absolute());
937 assert!(home.exists() || home.parent().is_some_and(std::path::Path::exists));
939 }
940 Err(e) => {
941 assert!(e.to_string().contains("home") || e.to_string().contains("directory"));
943 }
944 }
945 }
946
947 fn is_executable_name(_name: &str) -> bool {
949 #[cfg(windows)]
950 {
951 _name.to_lowercase().ends_with(".exe")
952 }
953 #[cfg(not(windows))]
954 {
955 true
957 }
958 }
959
960 fn get_executable_extension() -> &'static str {
961 #[cfg(windows)]
962 {
963 ".exe"
964 }
965 #[cfg(not(windows))]
966 {
967 ""
968 }
969 }
970
971 fn normalize_line_endings(text: &str) -> String {
972 #[cfg(windows)]
973 {
974 text.replace('\n', "\r\n").replace("\r\r\n", "\r\n")
975 }
976 #[cfg(not(windows))]
977 {
978 text.replace("\r\n", "\n")
979 }
980 }
981
982 #[test]
983 fn test_normalize_path_for_storage_unix() {
984 use std::path::Path;
985 assert_eq!(
987 normalize_path_for_storage(Path::new("/project/agents/helper.md")),
988 "/project/agents/helper.md"
989 );
990 assert_eq!(normalize_path_for_storage(Path::new("agents/helper.md")), "agents/helper.md");
991 assert_eq!(
992 normalize_path_for_storage(Path::new("../shared/utils.md")),
993 "../shared/utils.md"
994 );
995 }
996
997 #[test]
998 fn test_normalize_path_for_storage_windows_extended() {
999 use std::path::Path;
1000 let path = Path::new(r"\\?\C:\project\agents\helper.md");
1003 assert_eq!(
1004 normalize_path_for_storage(path),
1005 "C:/project/agents/helper.md",
1006 "Should strip extended-length prefix (\\\\?\\) AND convert backslashes to forward slashes"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_normalize_path_for_storage_windows_extended_unc() {
1012 use std::path::Path;
1013 let path = Path::new(r"\\?\UNC\server\share\file.md");
1015 assert_eq!(normalize_path_for_storage(path), "//server/share/file.md");
1016 }
1017
1018 #[test]
1019 fn test_normalize_path_for_storage_windows_backslash() {
1020 use std::path::Path;
1021 let path = Path::new(r"C:\project\agents\helper.md");
1023 assert_eq!(normalize_path_for_storage(path), "C:/project/agents/helper.md");
1024 }
1025}