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}