agpm_cli/utils/
mod.rs

1//! Cross-platform utilities and helpers
2//!
3//! This module provides utility functions for file operations, platform-specific
4//! code, and user interface elements like progress bars. All utilities are designed
5//! to work consistently across Windows, macOS, and Linux.
6//!
7//! # Modules
8//!
9//! - [`fs`] - File system operations with atomic writes and safe copying
10//! - [`manifest_utils`] - Utilities for loading and validating manifests
11//! - [`platform`] - Platform-specific helpers and path resolution
12//! - [`progress`] - Multi-phase progress tracking for long-running operations
13//!
14//! # Cross-Platform Considerations
15//!
16//! All utilities handle platform differences:
17//! - Path separators (`/` vs `\`)
18//! - Line endings (`\n` vs `\r\n`)
19//! - File permissions and attributes
20//! - Shell commands and environment variables
21//!
22//! # Example
23//!
24//! ```rust,no_run
25//! use agpm_cli::utils::{ensure_dir, atomic_write, MultiPhaseProgress, InstallationPhase};
26//! use std::path::Path;
27//!
28//! # async fn example() -> anyhow::Result<()> {
29//! // Ensure directory exists
30//! ensure_dir(Path::new("output/agents"))?;
31//!
32//! // Write file atomically
33//! atomic_write(Path::new("output/config.toml"), b"content")?;
34//!
35//! // Show progress with phases
36//! let progress = MultiPhaseProgress::new(true);
37//! progress.start_phase(InstallationPhase::Installing, Some("Processing files"));
38//! # Ok(())
39//! # }
40//! ```
41
42use 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
71/// Generates a backup path for tool configuration files.
72///
73/// Creates backup paths in the format: `.agpm/backups/<tool>/<filename>`
74/// at the project root level, not inside tool-specific directories.
75///
76/// # Arguments
77///
78/// * `config_path` - Path to the configuration file being backed up
79/// * `tool_name` - Name of the tool (e.g., "claude-code", "opencode")
80///
81/// # Returns
82///
83/// Full path to the backup file at project root level
84///
85/// # Examples
86///
87/// ```
88/// use std::path::Path;
89/// use agpm_cli::utils::generate_backup_path;
90///
91/// // For .claude/settings.local.json with claude-code tool
92/// let backup_path = generate_backup_path(
93///     Path::new("/project/.claude/settings.local.json"),
94///     "claude-code"
95/// );
96/// // Returns: /project/.agpm/backups/claude-code/settings.local.json
97///
98/// // For .mcp.json with claude-code tool  
99/// let backup_path = generate_backup_path(
100///     Path::new("/project/.mcp.json"),
101///     "claude-code"
102/// );
103/// // Returns: /project/.agpm/backups/claude-code/.mcp.json
104/// ```
105pub fn generate_backup_path(config_path: &Path, tool_name: &str) -> anyhow::Result<PathBuf> {
106    use anyhow::{Context, anyhow};
107
108    // Find project root by looking for agpm.toml
109    let project_root = find_project_root(config_path)
110        .with_context(|| format!("Failed to find project root from: {}", config_path.display()))?;
111
112    // Create backup path: .agpm/backups/<tool>/<filename>
113    let backup_dir = project_root.join(".agpm").join("backups").join(tool_name);
114
115    // Get just the filename from the original config path
116    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/// Determines if a given URL/path is a local filesystem path (not a Git repository URL).
124///
125/// Local paths are directories on the filesystem that are directly accessible,
126/// as opposed to Git repository URLs that need to be cloned/fetched.
127///
128/// # Examples
129///
130/// ```
131/// use agpm_cli::utils::is_local_path;
132///
133/// // Unix-style paths
134/// assert!(is_local_path("/absolute/path"));
135/// assert!(is_local_path("./relative/path"));
136/// assert!(is_local_path("../parent/path"));
137///
138/// // Windows-style paths (with drive letters or UNC)
139/// assert!(is_local_path("C:/Users/path"));
140/// assert!(is_local_path("C:\\Users\\path"));
141/// assert!(is_local_path("//server/share"));
142/// assert!(is_local_path("\\\\server\\share"));
143///
144/// // Git URLs (not local paths)
145/// assert!(!is_local_path("https://github.com/user/repo.git"));
146/// assert!(!is_local_path("git@github.com:user/repo.git"));
147/// assert!(!is_local_path("file:///path/to/repo.git"));
148/// ```
149#[must_use]
150pub fn is_local_path(url: &str) -> bool {
151    // file:// URLs are Git repository URLs, not local paths
152    if url.starts_with("file://") {
153        return false;
154    }
155
156    // Unix-style absolute or relative paths
157    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
158        return true;
159    }
160
161    // Windows-style paths
162    // Check for drive letter (e.g., C:/ or C:\)
163    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    // Check for UNC paths (e.g., //server/share or \\server\share)
171    if url.starts_with("//") || url.starts_with("\\\\") {
172        return true;
173    }
174
175    false
176}
177
178/// Determines if a given URL is a Git repository URL (including file:// URLs).
179///
180/// Git repository URLs need to be cloned/fetched, unlike local filesystem paths.
181///
182/// # Examples
183///
184/// ```
185/// use agpm_cli::utils::is_git_url;
186///
187/// assert!(is_git_url("https://github.com/user/repo.git"));
188/// assert!(is_git_url("git@github.com:user/repo.git"));
189/// assert!(is_git_url("file:///path/to/repo.git"));
190/// assert!(is_git_url("ssh://git@server.com/repo.git"));
191/// assert!(!is_git_url("/absolute/path"));
192/// assert!(!is_git_url("./relative/path"));
193/// ```
194#[must_use]
195pub fn is_git_url(url: &str) -> bool {
196    !is_local_path(url)
197}
198
199/// Resolves a file-relative path from a transitive dependency.
200///
201/// This function resolves paths that start with `./` or `../` relative to the
202/// directory containing the parent resource file. This provides a unified way to
203/// resolve transitive dependencies for both Git-backed and path-only resources.
204///
205/// # Arguments
206///
207/// * `parent_file_path` - Absolute path to the file declaring the dependency
208/// * `relative_path` - Path from the transitive dep spec (must start with `./` or `../`)
209///
210/// # Returns
211///
212/// Canonical absolute path to the dependency.
213///
214/// # Errors
215///
216/// Returns an error if:
217/// - `relative_path` doesn't start with `./` or `../`
218/// - The resolved path doesn't exist
219/// - Canonicalization fails
220///
221/// # Examples
222///
223/// ```no_run
224/// use std::path::Path;
225/// use agpm_cli::utils::resolve_file_relative_path;
226///
227/// let parent = Path::new("/project/agents/helper.md");
228/// let resolved = resolve_file_relative_path(parent, "./snippets/utils.md")?;
229/// // Returns: /project/agents/snippets/utils.md
230///
231/// let resolved = resolve_file_relative_path(parent, "../common/base.md")?;
232/// // Returns: /project/common/base.md
233/// # Ok::<(), anyhow::Error>(())
234/// ```
235pub 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    // Validate it's a file-relative path
242    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    // Get parent directory
250    let parent_dir = parent_file_path
251        .parent()
252        .ok_or_else(|| anyhow!("Parent file has no directory: {}", parent_file_path.display()))?;
253
254    // Resolve relative to parent's directory
255    let resolved = parent_dir.join(relative_path);
256
257    // Canonicalize (resolves .. and ., checks existence)
258    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
268/// Resolves a path relative to the manifest directory.
269///
270/// This function handles shell expansion and both relative and absolute paths,
271/// resolving them relative to the directory containing the manifest file.
272///
273/// # Arguments
274///
275/// * `manifest_dir` - The directory containing the agpm.toml manifest
276/// * `rel_path` - The path to resolve (can be relative or absolute)
277///
278/// # Returns
279///
280/// Canonical absolute path to the resource.
281///
282/// # Errors
283///
284/// Returns an error if:
285/// - Shell expansion fails
286/// - The path doesn't exist
287/// - Canonicalization fails
288///
289/// # Examples
290///
291/// ```no_run
292/// use std::path::Path;
293/// use agpm_cli::utils::resolve_path_relative_to_manifest;
294///
295/// let manifest_dir = Path::new("/project");
296/// let resolved = resolve_path_relative_to_manifest(manifest_dir, "../shared/agents/helper.md")?;
297/// // Returns: /shared/agents/helper.md
298/// # Ok::<(), anyhow::Error>(())
299/// ```
300pub 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
325/// Computes a relative path from a base directory to a target path.
326///
327/// This function handles paths both inside and outside the base directory,
328/// using `../` notation when the target is outside. Both paths should be
329/// absolute and canonicalized for correct results.
330///
331/// This is critical for lockfile portability - we must store manifest-relative
332/// paths even when they go outside the project with `../`.
333///
334/// # Arguments
335///
336/// * `base` - The base directory (should be absolute and canonicalized)
337/// * `target` - The target path (should be absolute and canonicalized)
338///
339/// # Returns
340///
341/// A relative path from base to target, using `../` notation if needed.
342///
343/// # Examples
344///
345/// ```no_run
346/// use std::path::Path;
347/// use agpm_cli::utils::compute_relative_path;
348///
349/// let base = Path::new("/project");
350/// let target = Path::new("/project/agents/helper.md");
351/// let relative = compute_relative_path(base, target);
352/// // Returns: "agents/helper.md"
353///
354/// let target_outside = Path::new("/shared/utils.md");
355/// let relative = compute_relative_path(base, target_outside);
356/// // Returns: "../shared/utils.md"
357/// # Ok::<(), anyhow::Error>(())
358/// ```
359pub fn compute_relative_path(base: &std::path::Path, target: &std::path::Path) -> String {
360    use std::path::Component;
361
362    // Try simple strip_prefix first (common case: target inside base)
363    if let Ok(relative) = target.strip_prefix(base) {
364        // Normalize to forward slashes for cross-platform storage
365        return normalize_path_for_storage(relative);
366    }
367
368    // Target is outside base - need to compute path with ../
369    let base_components: Vec<_> = base.components().collect();
370    let target_components: Vec<_> = target.components().collect();
371
372    // Find the common prefix
373    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    // Use slices instead of drain for better performance (avoid reallocation)
383    let base_remainder = &base_components[common_prefix_len..];
384    let target_remainder = &target_components[common_prefix_len..];
385
386    // Build the relative path
387    let mut result = std::path::PathBuf::new();
388
389    // Add ../ for each remaining base component
390    for _ in base_remainder {
391        result.push("..");
392    }
393
394    // Add the remaining target components
395    for component in target_remainder {
396        if let Component::Normal(c) = component {
397            result.push(c);
398        }
399    }
400
401    // Normalize to forward slashes for cross-platform storage
402    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        // Target inside base directory
413        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        // Target outside base directory (sibling)
422        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        // Target multiple levels up
431        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        // Base and target are the same
440        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        // Complex nesting
449        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        // Create agpm.toml to establish project root
470        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
471
472        // Create a config file
473        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        // Convert both to absolute paths for comparison
481        let project_root = std::fs::canonicalize(project_root).unwrap();
482        assert!(backup_path.starts_with(project_root));
483
484        // Use normalized path for cross-platform comparison
485        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        // Create config file WITHOUT agpm.toml in any parent directory
495        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        // The actual error from find_project_root mentions "No agpm.toml found"
504        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        // Config in nested directory
517        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        // Backup should still go to project root, not relative to config
521        let project_root = std::fs::canonicalize(project_root).unwrap();
522        assert!(backup_path.starts_with(project_root));
523
524        // Use normalized path for cross-platform comparison
525        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        // Should NOT include "subdir" in backup path
530        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        // Test different tools
545        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        // Use normalized paths for cross-platform comparison
550        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        // All should end with same filename
559        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        // Test with path that has no filename (root directory)
567        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        // The function checks project root first, then filename
573        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        // Test both Unix and Windows style paths (should work on any platform)
587        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        // Both should produce the same normalized backup path
594        assert_eq!(backup1, backup2);
595
596        // Verify structure using normalized path (cross-platform)
597        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        // Create real config file
612        let real_config = project_root.join("real-settings.json");
613        fs::write(&real_config, r#"{"test": "value"}"#).unwrap();
614
615        // Create directory for symlink
616        fs::create_dir_all(project_root.join(".claude")).unwrap();
617
618        // Create symlink
619        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        // Convert to absolute path for comparison
625        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        // Create a very long path (Windows has 260 char limit traditionally)
640        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        // Should either succeed or give a clear error about path length
652        match result {
653            Ok(backup_path) => {
654                // generate_backup_path uses find_project_root which canonicalizes,
655                // so we need to canonicalize project_root for comparison
656                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                // Should give a meaningful error, not just "path too long"
662                let error_msg = err.to_string();
663                assert!(error_msg.len() > 10);
664            }
665        }
666    }
667}