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 anyhow::Context;
43use std::path::{Path, PathBuf};
44
45pub mod fs;
46pub mod manifest_utils;
47pub mod path_validation;
48pub mod platform;
49pub mod progress;
50pub mod security;
51
52pub use fs::{
53    atomic_write, compare_file_times, copy_dir, create_temp_file, ensure_dir,
54    file_exists_and_readable, get_modified_time, normalize_path, read_json_file, read_text_file,
55    read_toml_file, read_yaml_file, safe_write, write_json_file, write_text_file, write_toml_file,
56    write_yaml_file,
57};
58pub use manifest_utils::{
59    load_and_validate_manifest, load_project_manifest, manifest_exists, manifest_path,
60};
61pub use path_validation::{
62    ensure_directory_exists, ensure_within_directory, find_project_root, safe_canonicalize,
63    safe_relative_path, sanitize_file_name, validate_no_traversal, validate_project_path,
64    validate_resource_path,
65};
66pub use platform::{
67    compute_relative_install_path, get_git_command, get_home_dir, is_windows,
68    normalize_path_for_storage, resolve_path,
69};
70pub use progress::{InstallationPhase, MultiPhaseProgress, ProgressBar, collect_dependency_names};
71
72/// Canonicalize JSON for deterministic hashing.
73///
74/// Uses `serde_json` with `preserve_order` feature to ensure
75/// consistent key ordering across serialization calls. This is
76/// critical for generating stable checksums of template contexts.
77///
78/// # Arguments
79///
80/// * `value` - The JSON value to canonicalize
81///
82/// # Returns
83///
84/// A deterministic string representation of the JSON value
85///
86/// # Errors
87///
88/// Returns an error if the JSON value cannot be serialized (should be rare
89/// for valid `serde_json::Value` instances).
90pub fn canonicalize_json(value: &serde_json::Value) -> anyhow::Result<String> {
91    serialize_json_canonically(value)
92}
93
94/// SHA-256 hash of an empty JSON object `{}`.
95/// This is the default hash when there are no template variables.
96/// Computed lazily to ensure consistency with the hash function.
97pub static EMPTY_VARIANT_INPUTS_HASH: std::sync::LazyLock<String> =
98    std::sync::LazyLock::new(|| {
99        compute_variant_inputs_hash(&serde_json::json!({}))
100            .expect("Failed to compute hash of empty JSON object")
101    });
102
103/// Compute SHA-256 hash of variant_inputs JSON value.
104///
105/// This is the **single source of truth** for computing `variant_inputs_hash` values.
106/// It ensures consistent hashing by serializing the JSON value and hashing the result.
107///
108/// This function MUST be used everywhere `variant_inputs_hash` is computed to ensure
109/// that identity comparisons work correctly across the codebase.
110///
111/// # Arguments
112///
113/// * `variant_inputs` - The variant_inputs JSON value to hash
114///
115/// # Returns
116///
117/// A string in the format "sha256:hexdigest"
118///
119/// # Errors
120///
121/// Returns an error if the JSON value cannot be serialized.
122///
123/// # Example
124///
125/// ```rust,no_run
126/// use agpm_cli::utils::{compute_variant_inputs_hash, EMPTY_VARIANT_INPUTS_HASH};
127/// use serde_json::json;
128///
129/// let hash = compute_variant_inputs_hash(&json!({})).unwrap();
130/// assert_eq!(hash, *EMPTY_VARIANT_INPUTS_HASH);
131/// ```
132pub fn compute_variant_inputs_hash(variant_inputs: &serde_json::Value) -> anyhow::Result<String> {
133    use sha2::{Digest, Sha256};
134
135    // Serialize with sorted keys for deterministic hashing
136    // This ensures {"a": 1, "b": 2} and {"b": 2, "a": 1} have the same hash
137    let serialized = serialize_json_canonically(variant_inputs)?;
138
139    // Hash the serialized version
140    let hash_result = Sha256::digest(serialized.as_bytes());
141    Ok(format!("sha256:{}", hex::encode(hash_result)))
142}
143
144/// Serialize JSON value with sorted keys for deterministic output.
145///
146/// This ensures that semantically identical JSON objects produce identical strings,
147/// regardless of key insertion order.
148fn serialize_json_canonically(value: &serde_json::Value) -> anyhow::Result<String> {
149    match value {
150        serde_json::Value::Object(map) => {
151            // Sort keys alphabetically for determinism
152            let mut sorted_keys: Vec<_> = map.keys().collect();
153            sorted_keys.sort();
154
155            let mut result = String::from("{");
156            for (i, key) in sorted_keys.iter().enumerate() {
157                if i > 0 {
158                    result.push(',');
159                }
160                // Serialize key
161                result.push_str(&serde_json::to_string(key)?);
162                result.push(':');
163                // Recursively serialize value
164                let val = map.get(*key).unwrap();
165                result.push_str(&serialize_json_canonically(val)?);
166            }
167            result.push('}');
168            Ok(result)
169        }
170        serde_json::Value::Array(arr) => {
171            // Arrays: serialize elements in order
172            let mut result = String::from("[");
173            for (i, item) in arr.iter().enumerate() {
174                if i > 0 {
175                    result.push(',');
176                }
177                result.push_str(&serialize_json_canonically(item)?);
178            }
179            result.push(']');
180            Ok(result)
181        }
182        // Primitives: use standard serialization
183        _ => serde_json::to_string(value).context("Failed to serialize JSON value"),
184    }
185}
186
187/// Generates a backup path for tool configuration files.
188///
189/// Creates backup paths in the format: `.agpm/backups/<tool>/<filename>`
190/// at the project root level, not inside tool-specific directories.
191///
192/// # Arguments
193///
194/// * `config_path` - Path to the configuration file being backed up
195/// * `tool_name` - Name of the tool (e.g., "claude-code", "opencode")
196///
197/// # Returns
198///
199/// Full path to the backup file at project root level
200///
201/// # Examples
202///
203/// ```
204/// use std::path::Path;
205/// use agpm_cli::utils::generate_backup_path;
206///
207/// // For .claude/settings.local.json with claude-code tool
208/// let backup_path = generate_backup_path(
209///     Path::new("/project/.claude/settings.local.json"),
210///     "claude-code"
211/// );
212/// // Returns: /project/.agpm/backups/claude-code/settings.local.json
213///
214/// // For .mcp.json with claude-code tool  
215/// let backup_path = generate_backup_path(
216///     Path::new("/project/.mcp.json"),
217///     "claude-code"
218/// );
219/// // Returns: /project/.agpm/backups/claude-code/.mcp.json
220/// ```
221pub fn generate_backup_path(config_path: &Path, tool_name: &str) -> anyhow::Result<PathBuf> {
222    use anyhow::{Context, anyhow};
223
224    // Find project root by looking for agpm.toml
225    let project_root = find_project_root(config_path)
226        .with_context(|| format!("Failed to find project root from: {}", config_path.display()))?;
227
228    // Create backup path: .agpm/backups/<tool>/<filename>
229    let backup_dir = project_root.join(".agpm").join("backups").join(tool_name);
230
231    // Get just the filename from the original config path
232    let filename = config_path
233        .file_name()
234        .ok_or_else(|| anyhow!("Invalid config path: {}", config_path.display()))?;
235
236    Ok(backup_dir.join(filename))
237}
238
239/// Determines if a given URL/path is a local filesystem path (not a Git repository URL).
240///
241/// Local paths are directories on the filesystem that are directly accessible,
242/// as opposed to Git repository URLs that need to be cloned/fetched.
243///
244/// # Examples
245///
246/// ```
247/// use agpm_cli::utils::is_local_path;
248///
249/// // Unix-style paths
250/// assert!(is_local_path("/absolute/path"));
251/// assert!(is_local_path("./relative/path"));
252/// assert!(is_local_path("../parent/path"));
253///
254/// // Windows-style paths (with drive letters or UNC)
255/// assert!(is_local_path("C:/Users/path"));
256/// assert!(is_local_path("C:\\Users\\path"));
257/// assert!(is_local_path("//server/share"));
258/// assert!(is_local_path("\\\\server\\share"));
259///
260/// // Git URLs (not local paths)
261/// assert!(!is_local_path("https://github.com/user/repo.git"));
262/// assert!(!is_local_path("git@github.com:user/repo.git"));
263/// assert!(!is_local_path("file:///path/to/repo.git"));
264/// ```
265#[must_use]
266pub fn is_local_path(url: &str) -> bool {
267    // file:// URLs are Git repository URLs, not local paths
268    if url.starts_with("file://") {
269        return false;
270    }
271
272    // Unix-style absolute or relative paths
273    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
274        return true;
275    }
276
277    // Windows-style paths
278    // Check for drive letter (e.g., C:/ or C:\)
279    if url.len() >= 2 {
280        let chars: Vec<char> = url.chars().collect();
281        if chars[0].is_ascii_alphabetic() && chars[1] == ':' {
282            return true;
283        }
284    }
285
286    // Check for UNC paths (e.g., //server/share or \\server\share)
287    if url.starts_with("//") || url.starts_with("\\\\") {
288        return true;
289    }
290
291    false
292}
293
294/// Determines if a given URL is a Git repository URL (including file:// URLs).
295///
296/// Git repository URLs need to be cloned/fetched, unlike local filesystem paths.
297///
298/// # Examples
299///
300/// ```
301/// use agpm_cli::utils::is_git_url;
302///
303/// assert!(is_git_url("https://github.com/user/repo.git"));
304/// assert!(is_git_url("git@github.com:user/repo.git"));
305/// assert!(is_git_url("file:///path/to/repo.git"));
306/// assert!(is_git_url("ssh://git@server.com/repo.git"));
307/// assert!(!is_git_url("/absolute/path"));
308/// assert!(!is_git_url("./relative/path"));
309/// ```
310#[must_use]
311pub fn is_git_url(url: &str) -> bool {
312    !is_local_path(url)
313}
314
315/// Resolves a file-relative path from a transitive dependency.
316///
317/// This function resolves paths that start with `./` or `../` relative to the
318/// directory containing the parent resource file. This provides a unified way to
319/// resolve transitive dependencies for both Git-backed and path-only resources.
320///
321/// # Arguments
322///
323/// * `parent_file_path` - Absolute path to the file declaring the dependency
324/// * `relative_path` - Path from the transitive dep spec (must start with `./` or `../`)
325///
326/// # Returns
327///
328/// Canonical absolute path to the dependency.
329///
330/// # Errors
331///
332/// Returns an error if:
333/// - `relative_path` doesn't start with `./` or `../`
334/// - The resolved path doesn't exist
335/// - Canonicalization fails
336///
337/// # Examples
338///
339/// ```no_run
340/// use std::path::Path;
341/// use agpm_cli::utils::resolve_file_relative_path;
342///
343/// let parent = Path::new("/project/agents/helper.md");
344/// let resolved = resolve_file_relative_path(parent, "./snippets/utils.md")?;
345/// // Returns: /project/agents/snippets/utils.md
346///
347/// let resolved = resolve_file_relative_path(parent, "../common/base.md")?;
348/// // Returns: /project/common/base.md
349/// # Ok::<(), anyhow::Error>(())
350/// ```
351pub fn resolve_file_relative_path(
352    parent_file_path: &std::path::Path,
353    relative_path: &str,
354) -> anyhow::Result<std::path::PathBuf> {
355    use anyhow::{Context, anyhow};
356
357    // Validate it's a file-relative path
358    if !relative_path.starts_with("./") && !relative_path.starts_with("../") {
359        return Err(anyhow!(
360            "Transitive dependency path must start with './' or '../': {}",
361            relative_path
362        ));
363    }
364
365    // Get parent directory
366    let parent_dir = parent_file_path
367        .parent()
368        .ok_or_else(|| anyhow!("Parent file has no directory: {}", parent_file_path.display()))?;
369
370    // Resolve relative to parent's directory
371    let resolved = parent_dir.join(relative_path);
372
373    // Canonicalize (resolves .. and ., checks existence)
374    resolved.canonicalize().with_context(|| {
375        format!(
376            "Transitive dependency does not exist: {} (resolved from '{}' relative to '{}')",
377            resolved.display(),
378            relative_path,
379            parent_dir.display()
380        )
381    })
382}
383
384/// Resolves a path relative to the manifest directory.
385///
386/// This function handles shell expansion and both relative and absolute paths,
387/// resolving them relative to the directory containing the manifest file.
388///
389/// # Arguments
390///
391/// * `manifest_dir` - The directory containing the agpm.toml manifest
392/// * `rel_path` - The path to resolve (can be relative or absolute)
393///
394/// # Returns
395///
396/// Canonical absolute path to the resource.
397///
398/// # Errors
399///
400/// Returns an error if:
401/// - Shell expansion fails
402/// - The path doesn't exist
403/// - Canonicalization fails
404///
405/// # Examples
406///
407/// ```no_run
408/// use std::path::Path;
409/// use agpm_cli::utils::resolve_path_relative_to_manifest;
410///
411/// let manifest_dir = Path::new("/project");
412/// let resolved = resolve_path_relative_to_manifest(manifest_dir, "../shared/agents/helper.md")?;
413/// // Returns: /shared/agents/helper.md
414/// # Ok::<(), anyhow::Error>(())
415/// ```
416pub fn resolve_path_relative_to_manifest(
417    manifest_dir: &std::path::Path,
418    rel_path: &str,
419) -> anyhow::Result<std::path::PathBuf> {
420    use anyhow::Context;
421
422    let expanded = shellexpand::full(rel_path)
423        .with_context(|| format!("Failed to expand path: {}", rel_path))?;
424    let path = std::path::PathBuf::from(expanded.as_ref());
425
426    let resolved = if path.is_absolute() {
427        path
428    } else {
429        manifest_dir.join(path)
430    };
431
432    resolved.canonicalize().with_context(|| {
433        format!(
434            "Path does not exist: {} (resolved from manifest dir '{}')",
435            resolved.display(),
436            manifest_dir.display()
437        )
438    })
439}
440
441/// Computes a relative path from a base directory to a target path.
442///
443/// This function handles paths both inside and outside the base directory,
444/// using `../` notation when the target is outside. Both paths should be
445/// absolute and canonicalized for correct results.
446///
447/// This is critical for lockfile portability - we must store manifest-relative
448/// paths even when they go outside the project with `../`.
449///
450/// # Arguments
451///
452/// * `base` - The base directory (should be absolute and canonicalized)
453/// * `target` - The target path (should be absolute and canonicalized)
454///
455/// # Returns
456///
457/// A relative path from base to target, using `../` notation if needed.
458///
459/// # Examples
460///
461/// ```no_run
462/// use std::path::Path;
463/// use agpm_cli::utils::compute_relative_path;
464///
465/// let base = Path::new("/project");
466/// let target = Path::new("/project/agents/helper.md");
467/// let relative = compute_relative_path(base, target);
468/// // Returns: "agents/helper.md"
469///
470/// let target_outside = Path::new("/shared/utils.md");
471/// let relative = compute_relative_path(base, target_outside);
472/// // Returns: "../shared/utils.md"
473/// # Ok::<(), anyhow::Error>(())
474/// ```
475pub fn compute_relative_path(base: &std::path::Path, target: &std::path::Path) -> String {
476    use std::path::Component;
477
478    // Try simple strip_prefix first (common case: target inside base)
479    if let Ok(relative) = target.strip_prefix(base) {
480        // Normalize to forward slashes for cross-platform storage
481        return normalize_path_for_storage(relative);
482    }
483
484    // Target is outside base - need to compute path with ../
485    let base_components: Vec<_> = base.components().collect();
486    let target_components: Vec<_> = target.components().collect();
487
488    // Find the common prefix
489    let mut common_prefix_len = 0;
490    for (b, t) in base_components.iter().zip(target_components.iter()) {
491        if b == t {
492            common_prefix_len += 1;
493        } else {
494            break;
495        }
496    }
497
498    // Use slices instead of drain for better performance (avoid reallocation)
499    let base_remainder = &base_components[common_prefix_len..];
500    let target_remainder = &target_components[common_prefix_len..];
501
502    // Build the relative path
503    let mut result = std::path::PathBuf::new();
504
505    // Add ../ for each remaining base component
506    for _ in base_remainder {
507        result.push("..");
508    }
509
510    // Add the remaining target components
511    for component in target_remainder {
512        if let Component::Normal(c) = component {
513            result.push(c);
514        }
515    }
516
517    // Normalize to forward slashes for cross-platform storage
518    normalize_path_for_storage(result)
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::path::Path;
525
526    #[test]
527    fn test_compute_relative_path_inside_base() {
528        // Target inside base directory
529        let base = Path::new("/project");
530        let target = Path::new("/project/agents/helper.md");
531        let result = compute_relative_path(base, target);
532        assert_eq!(result, "agents/helper.md");
533    }
534
535    #[test]
536    fn test_compute_relative_path_outside_base() {
537        // Target outside base directory (sibling)
538        let base = Path::new("/project");
539        let target = Path::new("/shared/utils.md");
540        let result = compute_relative_path(base, target);
541        assert_eq!(result, "../shared/utils.md");
542    }
543
544    #[test]
545    fn test_compute_relative_path_multiple_levels_up() {
546        // Target multiple levels up
547        let base = Path::new("/project/subdir");
548        let target = Path::new("/other/file.md");
549        let result = compute_relative_path(base, target);
550        assert_eq!(result, "../../other/file.md");
551    }
552
553    #[test]
554    fn test_compute_relative_path_same_directory() {
555        // Base and target are the same
556        let base = Path::new("/project");
557        let target = Path::new("/project");
558        let result = compute_relative_path(base, target);
559        assert_eq!(result, "");
560    }
561
562    #[test]
563    fn test_compute_relative_path_nested() {
564        // Complex nesting
565        let base = Path::new("/a/b/c");
566        let target = Path::new("/a/d/e/f.md");
567        let result = compute_relative_path(base, target);
568        assert_eq!(result, "../../d/e/f.md");
569    }
570}
571
572#[cfg(test)]
573mod backup_path_tests {
574    use super::*;
575    use std::fs;
576    use tempfile::TempDir;
577
578    #[test]
579    fn test_generate_backup_path() {
580        use crate::utils::platform::normalize_path_for_storage;
581
582        let temp_dir = TempDir::new().unwrap();
583        let project_root = temp_dir.path();
584
585        // Create agpm.toml to establish project root
586        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
587
588        // Create a config file
589        let config_dir = project_root.join(".claude");
590        fs::create_dir_all(&config_dir).unwrap();
591        let config_path = config_dir.join("settings.local.json");
592        fs::write(&config_path, "{}").unwrap();
593
594        let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
595
596        // Convert both to absolute paths for comparison
597        let project_root = std::fs::canonicalize(project_root).unwrap();
598        assert!(backup_path.starts_with(project_root));
599
600        // Use normalized path for cross-platform comparison
601        let normalized_backup = normalize_path_for_storage(&backup_path);
602        assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
603        assert!(normalized_backup.ends_with("settings.local.json"));
604    }
605
606    #[test]
607    fn test_generate_backup_path_fails_when_no_project_root() {
608        let temp_dir = TempDir::new().unwrap();
609
610        // Create config file WITHOUT agpm.toml in any parent directory
611        let config_path = temp_dir.path().join("orphan-config.json");
612        fs::write(&config_path, "{}").unwrap();
613
614        let result = generate_backup_path(&config_path, "claude-code");
615
616        assert!(result.is_err());
617        let error_msg = result.unwrap_err().to_string();
618        assert!(error_msg.contains("Failed to find project root"));
619        // The actual error from find_project_root mentions "No agpm.toml found"
620        assert!(error_msg.contains("agpm.toml") || error_msg.contains("project root"));
621    }
622
623    #[test]
624    fn test_generate_backup_path_with_nested_config() {
625        use crate::utils::platform::normalize_path_for_storage;
626
627        let temp_dir = TempDir::new().unwrap();
628        let project_root = temp_dir.path();
629
630        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
631
632        // Config in nested directory
633        let config_path = project_root.join(".claude/subdir/settings.local.json");
634        let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
635
636        // Backup should still go to project root, not relative to config
637        let project_root = std::fs::canonicalize(project_root).unwrap();
638        assert!(backup_path.starts_with(project_root));
639
640        // Use normalized path for cross-platform comparison
641        let normalized_backup = normalize_path_for_storage(&backup_path);
642        assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
643        assert!(normalized_backup.ends_with("settings.local.json"));
644
645        // Should NOT include "subdir" in backup path
646        assert!(!normalized_backup.contains("subdir"));
647    }
648
649    #[test]
650    fn test_generate_backup_path_different_tools() {
651        use crate::utils::platform::normalize_path_for_storage;
652
653        let temp_dir = TempDir::new().unwrap();
654        let project_root = temp_dir.path();
655
656        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
657
658        let config_path = project_root.join(".mcp.json");
659
660        // Test different tools
661        let claude_backup = generate_backup_path(&config_path, "claude-code").unwrap();
662        let open_backup = generate_backup_path(&config_path, "opencode").unwrap();
663        let custom_backup = generate_backup_path(&config_path, "my-tool").unwrap();
664
665        // Use normalized paths for cross-platform comparison
666        let normalized_claude = normalize_path_for_storage(&claude_backup);
667        let normalized_open = normalize_path_for_storage(&open_backup);
668        let normalized_custom = normalize_path_for_storage(&custom_backup);
669
670        assert!(normalized_claude.contains(".agpm/backups/claude-code/"));
671        assert!(normalized_open.contains(".agpm/backups/opencode/"));
672        assert!(normalized_custom.contains(".agpm/backups/my-tool/"));
673
674        // All should end with same filename
675        assert!(normalized_claude.ends_with("mcp.json"));
676        assert!(normalized_open.ends_with("mcp.json"));
677        assert!(normalized_custom.ends_with("mcp.json"));
678    }
679
680    #[test]
681    fn test_generate_backup_path_invalid_config_path() {
682        // Test with path that has no filename (root directory)
683        let invalid_path = Path::new("/");
684        let result = generate_backup_path(invalid_path, "claude-code");
685
686        assert!(result.is_err());
687        let error_msg = result.unwrap_err().to_string();
688        // The function checks project root first, then filename
689        assert!(
690            error_msg.contains("Failed to find project root")
691                || error_msg.contains("Invalid config path")
692        );
693    }
694
695    #[test]
696    fn test_backup_path_normalization_cross_platform() {
697        use crate::utils::platform::normalize_path_for_storage;
698
699        let temp_dir = TempDir::new().unwrap();
700        fs::write(temp_dir.path().join("agpm.toml"), "[sources]\n").unwrap();
701
702        // Test both Unix and Windows style paths (should work on any platform)
703        let unix_style = temp_dir.path().join(".claude").join("settings.local.json");
704        let direct_path = temp_dir.path().join(".claude/settings.local.json");
705
706        let backup1 = generate_backup_path(&unix_style, "claude-code").unwrap();
707        let backup2 = generate_backup_path(&direct_path, "claude-code").unwrap();
708
709        // Both should produce the same normalized backup path
710        assert_eq!(backup1, backup2);
711
712        // Verify structure using normalized path (cross-platform)
713        let backup_normalized = normalize_path_for_storage(&backup1);
714        assert!(backup_normalized.contains(".agpm/backups/claude-code/settings.local.json"));
715    }
716
717    #[test]
718    #[cfg(unix)]
719    fn test_backup_with_symlinked_config() {
720        use std::os::unix::fs::symlink;
721
722        let temp_dir = TempDir::new().unwrap();
723        let project_root = temp_dir.path();
724
725        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
726
727        // Create real config file
728        let real_config = project_root.join("real-settings.json");
729        fs::write(&real_config, r#"{"test": "value"}"#).unwrap();
730
731        // Create directory for symlink
732        fs::create_dir_all(project_root.join(".claude")).unwrap();
733
734        // Create symlink
735        let symlink_config = project_root.join(".claude/settings.local.json");
736        symlink(&real_config, &symlink_config).unwrap();
737
738        let backup_path = generate_backup_path(&symlink_config, "claude-code").unwrap();
739
740        // Convert to absolute path for comparison
741        let project_root = std::fs::canonicalize(project_root).unwrap();
742        assert!(backup_path.starts_with(project_root));
743        assert!(backup_path.to_str().unwrap().contains(".agpm/backups/claude-code/"));
744        assert!(backup_path.to_str().unwrap().ends_with("settings.local.json"));
745    }
746
747    #[test]
748    #[cfg(target_os = "windows")]
749    fn test_backup_with_long_windows_path() {
750        let temp_dir = TempDir::new().unwrap();
751        let project_root = temp_dir.path();
752
753        fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
754
755        // Create a very long path (Windows has 260 char limit traditionally)
756        let mut long_path = project_root.to_path_buf();
757        for _ in 0..10 {
758            long_path = long_path.join("very_long_directory_name_that_might_cause_issues");
759        }
760        fs::create_dir_all(&long_path).unwrap();
761
762        let config_path = long_path.join("settings.local.json");
763        fs::write(&config_path, "{}").unwrap();
764
765        let result = generate_backup_path(&config_path, "claude-code");
766
767        // Should either succeed or give a clear error about path length
768        match result {
769            Ok(backup_path) => {
770                // generate_backup_path uses find_project_root which canonicalizes,
771                // so we need to canonicalize project_root for comparison
772                let canonical_root = std::fs::canonicalize(project_root)
773                    .unwrap_or_else(|_| project_root.to_path_buf());
774                assert!(backup_path.starts_with(&canonical_root));
775            }
776            Err(err) => {
777                // Should give a meaningful error, not just "path too long"
778                let error_msg = err.to_string();
779                assert!(error_msg.len() > 10);
780            }
781        }
782    }
783}