1use std::path::{Path, PathBuf};
43
44pub mod fs;
45pub mod manifest_utils;
46pub mod path_validation;
47pub mod platform;
48pub mod progress;
49pub mod security;
50
51pub use fs::{
52 atomic_write, compare_file_times, copy_dir, create_temp_file, ensure_dir,
53 file_exists_and_readable, get_modified_time, normalize_path, read_json_file, read_text_file,
54 read_toml_file, read_yaml_file, safe_write, write_json_file, write_text_file, write_toml_file,
55 write_yaml_file,
56};
57pub use manifest_utils::{
58 load_and_validate_manifest, load_project_manifest, manifest_exists, manifest_path,
59};
60pub use path_validation::{
61 ensure_directory_exists, ensure_within_directory, find_project_root, safe_canonicalize,
62 safe_relative_path, sanitize_file_name, validate_no_traversal, validate_project_path,
63 validate_resource_path,
64};
65pub use platform::{
66 compute_relative_install_path, get_git_command, get_home_dir, is_windows,
67 normalize_path_for_storage, resolve_path,
68};
69pub use progress::{InstallationPhase, MultiPhaseProgress, ProgressBar, collect_dependency_names};
70
71pub fn generate_backup_path(config_path: &Path, tool_name: &str) -> anyhow::Result<PathBuf> {
106 use anyhow::{Context, anyhow};
107
108 let project_root = find_project_root(config_path)
110 .with_context(|| format!("Failed to find project root from: {}", config_path.display()))?;
111
112 let backup_dir = project_root.join(".agpm").join("backups").join(tool_name);
114
115 let filename = config_path
117 .file_name()
118 .ok_or_else(|| anyhow!("Invalid config path: {}", config_path.display()))?;
119
120 Ok(backup_dir.join(filename))
121}
122
123#[must_use]
150pub fn is_local_path(url: &str) -> bool {
151 if url.starts_with("file://") {
153 return false;
154 }
155
156 if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
158 return true;
159 }
160
161 if url.len() >= 2 {
164 let chars: Vec<char> = url.chars().collect();
165 if chars[0].is_ascii_alphabetic() && chars[1] == ':' {
166 return true;
167 }
168 }
169
170 if url.starts_with("//") || url.starts_with("\\\\") {
172 return true;
173 }
174
175 false
176}
177
178#[must_use]
195pub fn is_git_url(url: &str) -> bool {
196 !is_local_path(url)
197}
198
199pub fn resolve_file_relative_path(
236 parent_file_path: &std::path::Path,
237 relative_path: &str,
238) -> anyhow::Result<std::path::PathBuf> {
239 use anyhow::{Context, anyhow};
240
241 if !relative_path.starts_with("./") && !relative_path.starts_with("../") {
243 return Err(anyhow!(
244 "Transitive dependency path must start with './' or '../': {}",
245 relative_path
246 ));
247 }
248
249 let parent_dir = parent_file_path
251 .parent()
252 .ok_or_else(|| anyhow!("Parent file has no directory: {}", parent_file_path.display()))?;
253
254 let resolved = parent_dir.join(relative_path);
256
257 resolved.canonicalize().with_context(|| {
259 format!(
260 "Transitive dependency does not exist: {} (resolved from '{}' relative to '{}')",
261 resolved.display(),
262 relative_path,
263 parent_dir.display()
264 )
265 })
266}
267
268pub fn resolve_path_relative_to_manifest(
301 manifest_dir: &std::path::Path,
302 rel_path: &str,
303) -> anyhow::Result<std::path::PathBuf> {
304 use anyhow::Context;
305
306 let expanded = shellexpand::full(rel_path)
307 .with_context(|| format!("Failed to expand path: {}", rel_path))?;
308 let path = std::path::PathBuf::from(expanded.as_ref());
309
310 let resolved = if path.is_absolute() {
311 path
312 } else {
313 manifest_dir.join(path)
314 };
315
316 resolved.canonicalize().with_context(|| {
317 format!(
318 "Path does not exist: {} (resolved from manifest dir '{}')",
319 resolved.display(),
320 manifest_dir.display()
321 )
322 })
323}
324
325pub fn compute_relative_path(base: &std::path::Path, target: &std::path::Path) -> String {
360 use std::path::Component;
361
362 if let Ok(relative) = target.strip_prefix(base) {
364 return normalize_path_for_storage(relative);
366 }
367
368 let base_components: Vec<_> = base.components().collect();
370 let target_components: Vec<_> = target.components().collect();
371
372 let mut common_prefix_len = 0;
374 for (b, t) in base_components.iter().zip(target_components.iter()) {
375 if b == t {
376 common_prefix_len += 1;
377 } else {
378 break;
379 }
380 }
381
382 let base_remainder = &base_components[common_prefix_len..];
384 let target_remainder = &target_components[common_prefix_len..];
385
386 let mut result = std::path::PathBuf::new();
388
389 for _ in base_remainder {
391 result.push("..");
392 }
393
394 for component in target_remainder {
396 if let Component::Normal(c) = component {
397 result.push(c);
398 }
399 }
400
401 normalize_path_for_storage(result)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use std::path::Path;
409
410 #[test]
411 fn test_compute_relative_path_inside_base() {
412 let base = Path::new("/project");
414 let target = Path::new("/project/agents/helper.md");
415 let result = compute_relative_path(base, target);
416 assert_eq!(result, "agents/helper.md");
417 }
418
419 #[test]
420 fn test_compute_relative_path_outside_base() {
421 let base = Path::new("/project");
423 let target = Path::new("/shared/utils.md");
424 let result = compute_relative_path(base, target);
425 assert_eq!(result, "../shared/utils.md");
426 }
427
428 #[test]
429 fn test_compute_relative_path_multiple_levels_up() {
430 let base = Path::new("/project/subdir");
432 let target = Path::new("/other/file.md");
433 let result = compute_relative_path(base, target);
434 assert_eq!(result, "../../other/file.md");
435 }
436
437 #[test]
438 fn test_compute_relative_path_same_directory() {
439 let base = Path::new("/project");
441 let target = Path::new("/project");
442 let result = compute_relative_path(base, target);
443 assert_eq!(result, "");
444 }
445
446 #[test]
447 fn test_compute_relative_path_nested() {
448 let base = Path::new("/a/b/c");
450 let target = Path::new("/a/d/e/f.md");
451 let result = compute_relative_path(base, target);
452 assert_eq!(result, "../../d/e/f.md");
453 }
454}
455
456#[cfg(test)]
457mod backup_path_tests {
458 use super::*;
459 use std::fs;
460 use tempfile::TempDir;
461
462 #[test]
463 fn test_generate_backup_path() {
464 use crate::utils::platform::normalize_path_for_storage;
465
466 let temp_dir = TempDir::new().unwrap();
467 let project_root = temp_dir.path();
468
469 fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
471
472 let config_dir = project_root.join(".claude");
474 fs::create_dir_all(&config_dir).unwrap();
475 let config_path = config_dir.join("settings.local.json");
476 fs::write(&config_path, "{}").unwrap();
477
478 let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
479
480 let project_root = std::fs::canonicalize(project_root).unwrap();
482 assert!(backup_path.starts_with(project_root));
483
484 let normalized_backup = normalize_path_for_storage(&backup_path);
486 assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
487 assert!(normalized_backup.ends_with("settings.local.json"));
488 }
489
490 #[test]
491 fn test_generate_backup_path_fails_when_no_project_root() {
492 let temp_dir = TempDir::new().unwrap();
493
494 let config_path = temp_dir.path().join("orphan-config.json");
496 fs::write(&config_path, "{}").unwrap();
497
498 let result = generate_backup_path(&config_path, "claude-code");
499
500 assert!(result.is_err());
501 let error_msg = result.unwrap_err().to_string();
502 assert!(error_msg.contains("Failed to find project root"));
503 assert!(error_msg.contains("agpm.toml") || error_msg.contains("project root"));
505 }
506
507 #[test]
508 fn test_generate_backup_path_with_nested_config() {
509 use crate::utils::platform::normalize_path_for_storage;
510
511 let temp_dir = TempDir::new().unwrap();
512 let project_root = temp_dir.path();
513
514 fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
515
516 let config_path = project_root.join(".claude/subdir/settings.local.json");
518 let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
519
520 let project_root = std::fs::canonicalize(project_root).unwrap();
522 assert!(backup_path.starts_with(project_root));
523
524 let normalized_backup = normalize_path_for_storage(&backup_path);
526 assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
527 assert!(normalized_backup.ends_with("settings.local.json"));
528
529 assert!(!normalized_backup.contains("subdir"));
531 }
532
533 #[test]
534 fn test_generate_backup_path_different_tools() {
535 use crate::utils::platform::normalize_path_for_storage;
536
537 let temp_dir = TempDir::new().unwrap();
538 let project_root = temp_dir.path();
539
540 fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
541
542 let config_path = project_root.join(".mcp.json");
543
544 let claude_backup = generate_backup_path(&config_path, "claude-code").unwrap();
546 let open_backup = generate_backup_path(&config_path, "opencode").unwrap();
547 let custom_backup = generate_backup_path(&config_path, "my-tool").unwrap();
548
549 let normalized_claude = normalize_path_for_storage(&claude_backup);
551 let normalized_open = normalize_path_for_storage(&open_backup);
552 let normalized_custom = normalize_path_for_storage(&custom_backup);
553
554 assert!(normalized_claude.contains(".agpm/backups/claude-code/"));
555 assert!(normalized_open.contains(".agpm/backups/opencode/"));
556 assert!(normalized_custom.contains(".agpm/backups/my-tool/"));
557
558 assert!(normalized_claude.ends_with("mcp.json"));
560 assert!(normalized_open.ends_with("mcp.json"));
561 assert!(normalized_custom.ends_with("mcp.json"));
562 }
563
564 #[test]
565 fn test_generate_backup_path_invalid_config_path() {
566 let invalid_path = Path::new("/");
568 let result = generate_backup_path(invalid_path, "claude-code");
569
570 assert!(result.is_err());
571 let error_msg = result.unwrap_err().to_string();
572 assert!(
574 error_msg.contains("Failed to find project root")
575 || error_msg.contains("Invalid config path")
576 );
577 }
578
579 #[test]
580 fn test_backup_path_normalization_cross_platform() {
581 use crate::utils::platform::normalize_path_for_storage;
582
583 let temp_dir = TempDir::new().unwrap();
584 fs::write(temp_dir.path().join("agpm.toml"), "[sources]\n").unwrap();
585
586 let unix_style = temp_dir.path().join(".claude").join("settings.local.json");
588 let direct_path = temp_dir.path().join(".claude/settings.local.json");
589
590 let backup1 = generate_backup_path(&unix_style, "claude-code").unwrap();
591 let backup2 = generate_backup_path(&direct_path, "claude-code").unwrap();
592
593 assert_eq!(backup1, backup2);
595
596 let backup_normalized = normalize_path_for_storage(&backup1);
598 assert!(backup_normalized.contains(".agpm/backups/claude-code/settings.local.json"));
599 }
600
601 #[test]
602 #[cfg(unix)]
603 fn test_backup_with_symlinked_config() {
604 use std::os::unix::fs::symlink;
605
606 let temp_dir = TempDir::new().unwrap();
607 let project_root = temp_dir.path();
608
609 fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
610
611 let real_config = project_root.join("real-settings.json");
613 fs::write(&real_config, r#"{"test": "value"}"#).unwrap();
614
615 fs::create_dir_all(project_root.join(".claude")).unwrap();
617
618 let symlink_config = project_root.join(".claude/settings.local.json");
620 symlink(&real_config, &symlink_config).unwrap();
621
622 let backup_path = generate_backup_path(&symlink_config, "claude-code").unwrap();
623
624 let project_root = std::fs::canonicalize(project_root).unwrap();
626 assert!(backup_path.starts_with(project_root));
627 assert!(backup_path.to_str().unwrap().contains(".agpm/backups/claude-code/"));
628 assert!(backup_path.to_str().unwrap().ends_with("settings.local.json"));
629 }
630
631 #[test]
632 #[cfg(target_os = "windows")]
633 fn test_backup_with_long_windows_path() {
634 let temp_dir = TempDir::new().unwrap();
635 let project_root = temp_dir.path();
636
637 fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
638
639 let mut long_path = project_root.to_path_buf();
641 for _ in 0..10 {
642 long_path = long_path.join("very_long_directory_name_that_might_cause_issues");
643 }
644 fs::create_dir_all(&long_path).unwrap();
645
646 let config_path = long_path.join("settings.local.json");
647 fs::write(&config_path, "{}").unwrap();
648
649 let result = generate_backup_path(&config_path, "claude-code");
650
651 match result {
653 Ok(backup_path) => {
654 let canonical_root = std::fs::canonicalize(project_root)
657 .unwrap_or_else(|_| project_root.to_path_buf());
658 assert!(backup_path.starts_with(&canonical_root));
659 }
660 Err(err) => {
661 let error_msg = err.to_string();
663 assert!(error_msg.len() > 10);
664 }
665 }
666 }
667}