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}