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