agpm_cli/source/
mod.rs

1//! Source repository management
2//!
3//! This module manages source repositories that contain Claude Code resources (agents, snippets, etc.).
4//! Sources are Git repositories that are cloned/cached locally for efficient access and installation.
5//! The module provides secure, efficient, and cross-platform repository handling with comprehensive
6//! caching and authentication support.
7//!
8//! # Architecture Overview
9//!
10//! The source management system is built around two main components:
11//!
12//! - [`Source`] - Represents an individual repository with metadata and caching information
13//! - [`SourceManager`] - Manages multiple sources with operations for syncing, verification, and caching
14//!
15//! # Source Configuration
16//!
17//! Sources can be defined in two locations with different purposes:
18//!
19//! 1. **Project manifest** (`agpm.toml`) - Committed to version control, shared with team
20//!    ```toml
21//!    [sources]
22//!    community = "https://github.com/example/agpm-community.git"
23//!    official = "https://github.com/example/agpm-official.git"
24//!    ```
25//!
26//! 2. **Global config** (`~/.agpm/config.toml`) - User-specific with authentication tokens
27//!    ```toml
28//!    [sources]
29//!    private = "https://oauth2:ghp_xxxx@github.com/company/private-agpm.git"
30//!    ```
31//!
32//! ## Source Priority and Security
33//!
34//! When sources are defined in both locations with the same name:
35//! - Global sources are loaded first (contain authentication tokens)
36//! - Local sources override global ones (for project-specific customization)
37//! - Authentication tokens are kept separate from version control for security
38//!
39//! # Caching Architecture
40//!
41//! The caching system provides efficient repository management:
42//!
43//! ## Cache Directory Structure
44//!
45//! ```text
46//! ~/.agpm/cache/
47//! └── sources/
48//!     ├── owner1_repo1/          # Cached repository
49//!     │   ├── .git/              # Git metadata
50//!     │   ├── agents/            # Resource files
51//!     │   └── snippets/
52//!     └── owner2_repo2/
53//!         └── ...
54//! ```
55//!
56//! ## Cache Naming Convention
57//!
58//! Cache directories are named using the pattern `{owner}_{repository}` parsed from the Git URL.
59//! For invalid URLs, falls back to `unknown_{source_name}`.
60//!
61//! ## Caching Strategy
62//!
63//! - **First Access**: Repository is cloned to cache directory
64//! - **Subsequent Access**: Use cached copy, fetch updates if needed  
65//! - **Validation**: Cache integrity is verified before use
66//! - **Cleanup**: Invalid cache directories are automatically removed and re-cloned
67//!
68//! # Authentication Integration
69//!
70//! Authentication is handled transparently through the global configuration:
71//!
72//! - **Public repositories**: No authentication required
73//! - **Private repositories**: Authentication tokens embedded in URLs in global config
74//! - **Security**: Tokens never stored in project manifests or committed to version control
75//! - **Format**: Standard Git URL format with embedded credentials
76//!
77//! ## Supported Authentication Methods
78//!
79//! - OAuth tokens: `https://oauth2:token@github.com/repo.git`
80//! - Personal access tokens: `https://username:token@github.com/repo.git`
81//! - SSH keys: `git@github.com:owner/repo.git` (uses system SSH configuration)
82//!
83//! # Repository Types
84//!
85//! The module supports multiple repository types:
86//!
87//! ## Remote Repositories
88//! - **HTTPS**: `https://github.com/owner/repo.git`
89//! - **SSH**: `git@github.com:owner/repo.git`
90//!
91//! ## Local Repositories
92//! - **Absolute paths**: `/path/to/local/repo`
93//! - **Relative paths**: `../local-repo` or `./local-repo`
94//! - **File URLs**: `file:///absolute/path/to/repo`
95//!
96//! # Synchronization Operations
97//!
98//! Synchronization ensures local caches are up-to-date with remote repositories:
99//!
100//! ## Sync Operations
101//! - **Clone**: First-time repository retrieval
102//! - **Fetch**: Update remote references without merging
103//! - **Validation**: Verify repository integrity and accessibility
104//! - **Parallel**: Multiple repositories can be synced concurrently
105//!
106//! ## Offline Capabilities
107//! - Cached repositories can be used offline
108//! - Sync operations gracefully handle network failures
109//! - Local repositories work without network access
110//!
111//! # Error Handling
112//!
113//! The module provides comprehensive error handling for common scenarios:
114//!
115//! - **Network failures**: Graceful degradation with cached repositories
116//! - **Authentication failures**: Clear error messages with resolution hints
117//! - **Invalid repositories**: Automatic cleanup and re-cloning
118//! - **Path issues**: Cross-platform path handling and validation
119//!
120//! # Performance Considerations
121//!
122//! ## Optimization Strategies
123//! - **Lazy loading**: Sources are only cloned when needed
124//! - **Incremental updates**: Only fetch changes, not full re-clone
125//! - **Parallel operations**: Multiple repositories synced concurrently
126//! - **Cache reuse**: Minimize redundant network operations
127//!
128//! ## Resource Management
129//! - **Memory efficient**: Repositories are accessed on-demand
130//! - **Disk usage**: Cache cleanup for removed sources
131//! - **Network optimization**: Minimal data transfer through Git's efficient protocol
132//!
133//! # Cross-Platform Compatibility
134//!
135//! Full support for Windows, macOS, and Linux:
136//! - **Path handling**: Correct path separators and absolute path resolution
137//! - **Git command**: Uses system git with platform-specific optimizations
138//! - **File permissions**: Proper handling across different filesystems
139//! - **Authentication**: Works with platform-specific credential managers
140//!
141//! # Usage Examples
142//!
143//! ## Basic Source Management
144//! ```rust,no_run
145//! use agpm_cli::source::{Source, SourceManager};
146//! use agpm_cli::manifest::Manifest;
147//! use std::path::Path;
148//!
149//! # async fn example() -> anyhow::Result<()> {
150//! // Load from manifest with global config integration
151//! let manifest = Manifest::load(Path::new("agpm.toml"))?;
152//! let mut manager = SourceManager::from_manifest_with_global(&manifest).await?;
153//!
154//! // Sync a specific source
155//! let repo = manager.sync("community").await?;
156//! println!("Repository ready at: {:?}", repo.path());
157//!
158//! // List all available sources
159//! for source in manager.list() {
160//!     println!("Source: {} -> {}", source.name, source.url);
161//! }
162//! # Ok(())
163//! # }
164//! ```
165//!
166//! ## Progress Monitoring
167//! ```rust,no_run
168//! use agpm_cli::source::SourceManager;
169//! use indicatif::ProgressBar;
170//!
171//! # async fn example(manager: &mut SourceManager) -> anyhow::Result<()> {
172//! let progress = ProgressBar::new(100);
173//! progress.set_message("Syncing repositories...");
174//!
175//! // Sync all sources
176//! manager.sync_all().await?;
177//!
178//! progress.finish_with_message("All sources synced successfully");
179//! # Ok(())
180//! # }
181//! ```
182//!
183//! ## Direct URL Operations
184//! ```rust,no_run
185//! use agpm_cli::source::SourceManager;
186//!
187//! # async fn example(manager: &mut SourceManager) -> anyhow::Result<()> {
188//! // Sync a repository by URL (for direct dependencies)
189//! let repo = manager.sync_by_url(
190//!     "https://github.com/example/dependency.git"
191//! ).await?;
192//!
193//! // Access the cached repository
194//! let cache_path = manager.get_cached_path(
195//!     "https://github.com/example/dependency.git"
196//! )?;
197//! # Ok(())
198//! # }
199//! ```
200
201use crate::cache::lock::CacheLock;
202use crate::config::GlobalConfig;
203use crate::core::AgpmError;
204use crate::git::{GitRepo, parse_git_url};
205use crate::manifest::Manifest;
206use crate::utils::fs::ensure_dir;
207use crate::utils::security::validate_path_security;
208use anyhow::{Context, Result};
209use futures::future::join_all;
210use serde::{Deserialize, Serialize};
211use std::collections::HashMap;
212use std::path::{Path, PathBuf};
213
214/// Represents a Git repository source containing Claude Code resources.
215///
216/// A [`Source`] defines a repository location and metadata for accessing Claude Code
217/// resources like agents and snippets. Sources can be remote repositories (GitHub, GitLab, etc.)
218/// or local file paths, and support various authentication mechanisms.
219///
220/// # Fields
221///
222/// - `name`: Unique identifier for the source (used in manifests and commands)
223/// - `url`: Repository location (HTTPS, SSH, file://, or local path)
224/// - `description`: Optional human-readable description
225/// - `enabled`: Whether this source should be used for operations
226/// - `local_path`: Runtime cache location (not serialized, set during sync operations)
227///
228/// # Repository URL Formats
229///
230/// ## Remote Repositories
231/// - HTTPS: `https://github.com/owner/repo.git`
232/// - SSH: `git@github.com:owner/repo.git`
233/// - HTTPS with auth: `https://token@github.com/owner/repo.git`
234///
235/// ## Local Repositories
236/// - Absolute path: `/path/to/repository`
237/// - Relative path: `../relative/path` or `./local-path`
238/// - File URL: `file:///absolute/path/to/repository`
239///
240/// # Security Considerations
241///
242/// Authentication tokens should never be stored in [`Source`] instances that are
243/// serialized to project manifests. Use the global configuration for credentials.
244///
245/// # Examples
246///
247/// ```rust,no_run
248/// use agpm_cli::source::Source;
249///
250/// // Public repository
251/// let source = Source::new(
252///     "community".to_string(),
253///     "https://github.com/example/agpm-community.git".to_string()
254/// ).with_description("Community resources".to_string());
255///
256/// // Local development repository
257/// let local = Source::new(
258///     "local-dev".to_string(),
259///     "/path/to/local/repo".to_string()
260/// );
261/// ```
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct Source {
264    /// Unique identifier for this source
265    pub name: String,
266    /// Repository URL or local path
267    pub url: String,
268    /// Optional human-readable description
269    pub description: Option<String>,
270    /// Whether this source is enabled for operations
271    pub enabled: bool,
272    /// Runtime path to cached repository (not serialized)
273    #[serde(skip)]
274    pub local_path: Option<PathBuf>,
275}
276
277impl Source {
278    /// Creates a new source with the given name and URL.
279    ///
280    /// The source is created with default settings:
281    /// - No description
282    /// - Enabled by default
283    /// - No local path (will be set during sync operations)
284    ///
285    /// # Arguments
286    ///
287    /// * `name` - Unique identifier for this source
288    /// * `url` - Repository URL or local path
289    ///
290    /// # Examples
291    ///
292    /// ```rust,no_run
293    /// use agpm_cli::source::Source;
294    ///
295    /// let source = Source::new(
296    ///     "official".to_string(),
297    ///     "https://github.com/example/agpm-official.git".to_string()
298    /// );
299    ///
300    /// assert_eq!(source.name, "official");
301    /// assert!(source.enabled);
302    /// assert!(source.description.is_none());
303    /// ```
304    #[must_use]
305    pub const fn new(name: String, url: String) -> Self {
306        Self {
307            name,
308            url,
309            description: None,
310            enabled: true,
311            local_path: None,
312        }
313    }
314
315    /// Adds a human-readable description to this source.
316    ///
317    /// This is a builder pattern method that consumes the source and returns it
318    /// with the description field set. Descriptions help users understand the
319    /// purpose and contents of each source.
320    ///
321    /// # Arguments
322    ///
323    /// * `desc` - Human-readable description of the source
324    ///
325    /// # Examples
326    ///
327    /// ```rust,no_run
328    /// use agpm_cli::source::Source;
329    ///
330    /// let source = Source::new(
331    ///     "community".to_string(),
332    ///     "https://github.com/example/agpm-community.git".to_string()
333    /// ).with_description("Community-contributed agents and snippets".to_string());
334    ///
335    /// assert_eq!(source.description, Some("Community-contributed agents and snippets".to_string()));
336    /// ```
337    #[must_use]
338    pub fn with_description(mut self, desc: String) -> Self {
339        self.description = Some(desc);
340        self
341    }
342
343    /// Generates the cache directory path for this source.
344    ///
345    /// Creates a unique cache directory name based on the repository URL to avoid
346    /// conflicts between sources. The directory name follows the pattern `{owner}_{repo}`
347    /// parsed from the Git URL.
348    ///
349    /// # Cache Directory Structure
350    ///
351    /// - For `https://github.com/owner/repo.git` → `{base_dir}/sources/owner_repo`
352    /// - For invalid URLs → `{base_dir}/sources/unknown_{source_name}`
353    ///
354    /// # Arguments
355    ///
356    /// * `base_dir` - Base cache directory (typically `~/.agpm/cache`)
357    ///
358    /// # Returns
359    ///
360    /// [`PathBuf`] pointing to the cache directory for this source
361    ///
362    /// # Examples
363    ///
364    /// ```rust,no_run
365    /// use agpm_cli::source::Source;
366    /// use std::path::Path;
367    ///
368    /// let source = Source::new(
369    ///     "community".to_string(),
370    ///     "https://github.com/example/agpm-community.git".to_string()
371    /// );
372    ///
373    /// let base_dir = Path::new("/home/user/.agpm/cache");
374    /// let cache_dir = source.cache_dir(base_dir);
375    ///
376    /// assert_eq!(
377    ///     cache_dir,
378    ///     Path::new("/home/user/.agpm/cache/sources/example_agpm-community")
379    /// );
380    /// ```
381    #[must_use]
382    pub fn cache_dir(&self, base_dir: &Path) -> PathBuf {
383        let (owner, repo) =
384            parse_git_url(&self.url).unwrap_or(("unknown".to_string(), self.name.clone()));
385        base_dir.join("sources").join(format!("{owner}_{repo}"))
386    }
387}
388
389/// Manages multiple source repositories with caching, synchronization, and verification.
390///
391/// [`SourceManager`] is the central component for handling source repositories in AGPM.
392/// It provides operations for adding, removing, syncing, and verifying sources while
393/// maintaining a local cache for efficient access. The manager handles both remote
394/// repositories and local file paths with comprehensive error handling and progress reporting.
395///
396/// # Core Responsibilities
397///
398/// - **Source Registry**: Maintains a collection of named sources
399/// - **Cache Management**: Handles local caching of repository content
400/// - **Synchronization**: Keeps cached repositories up-to-date
401/// - **Verification**: Ensures repositories are accessible and valid
402/// - **Authentication**: Integrates with global configuration for private repositories
403/// - **Progress Reporting**: Provides feedback during long-running operations
404///
405/// # Cache Management
406///
407/// The manager maintains a cache directory (typically `~/.agpm/cache/sources/`) where
408/// each source is stored in a subdirectory named after the repository owner and name.
409/// The cache provides:
410///
411/// - **Persistence**: Repositories remain cached between operations
412/// - **Efficiency**: Avoid re-downloading unchanged repositories
413/// - **Offline Access**: Use cached content when network is unavailable
414/// - **Integrity**: Validate cache consistency and auto-repair when needed
415///
416/// # Thread Safety
417///
418/// [`SourceManager`] is designed for single-threaded use but can be cloned for use
419/// across multiple operations. For concurrent access, wrap in appropriate synchronization
420/// primitives like `Arc` and `Mutex`.
421///
422/// # Examples
423///
424/// ## Basic Usage
425/// ```rust,no_run
426/// use agpm_cli::source::{Source, SourceManager};
427/// use anyhow::Result;
428///
429/// # async fn example() -> Result<()> {
430/// // Create a new manager
431/// let mut manager = SourceManager::new()?;
432///
433/// // Add a source
434/// let source = Source::new(
435///     "community".to_string(),
436///     "https://github.com/example/agpm-community.git".to_string()
437/// );
438/// manager.add(source)?;
439///
440/// // Sync the repository
441/// let repo = manager.sync("community").await?;
442/// println!("Repository synced to: {:?}", repo.path());
443/// # Ok(())
444/// # }
445/// ```
446///
447/// ## Loading from Manifest
448/// ```rust,no_run
449/// use agpm_cli::source::SourceManager;
450/// use agpm_cli::manifest::Manifest;
451/// use std::path::Path;
452///
453/// # async fn example() -> anyhow::Result<()> {
454/// // Load sources from project manifest and global config
455/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
456/// let manager = SourceManager::from_manifest_with_global(&manifest).await?;
457///
458/// println!("Loaded {} sources", manager.list().len());
459/// # Ok(())
460/// # }
461/// ```
462#[derive(Debug, Clone)]
463pub struct SourceManager {
464    /// Collection of managed sources, indexed by name
465    sources: HashMap<String, Source>,
466    /// Base directory for caching repositories
467    cache_dir: PathBuf,
468}
469
470/// Helper function to detect if a URL represents a local filesystem path
471fn is_local_filesystem_path(url: &str) -> bool {
472    // Unix-style relative paths
473    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
474        return true;
475    }
476
477    // Windows absolute paths (e.g., C:\path or C:/path)
478    #[cfg(windows)]
479    {
480        // Check for drive letter pattern: X:\ or X:/
481        if url.len() >= 3 {
482            let chars: Vec<char> = url.chars().collect();
483            if chars.len() >= 3
484                && chars[0].is_ascii_alphabetic()
485                && chars[1] == ':'
486                && (chars[2] == '\\' || chars[2] == '/')
487            {
488                return true;
489            }
490        }
491        // UNC paths (\\server\share)
492        if url.starts_with("\\\\") {
493            return true;
494        }
495    }
496
497    false
498}
499
500impl SourceManager {
501    /// Creates a new source manager with the default cache directory.
502    ///
503    /// The cache directory is determined by the system configuration, typically
504    /// `~/.agpm/cache/` on Unix systems or `%APPDATA%\agpm\cache\` on Windows.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error if the cache directory cannot be determined or created.
509    ///
510    /// # Examples
511    ///
512    /// ```rust,no_run
513    /// use agpm_cli::source::SourceManager;
514    ///
515    /// # fn example() -> anyhow::Result<()> {
516    /// let manager = SourceManager::new()?;
517    /// println!("Manager created with {} sources", manager.list().len());
518    /// # Ok(())
519    /// # }
520    /// ```
521    pub fn new() -> Result<Self> {
522        let cache_dir = crate::config::get_cache_dir()?;
523        Ok(Self {
524            sources: HashMap::new(),
525            cache_dir,
526        })
527    }
528
529    /// Creates a new source manager with a custom cache directory.
530    ///
531    /// This constructor is primarily used for testing and scenarios where a specific
532    /// cache location is required. For normal usage, prefer [`SourceManager::new()`].
533    ///
534    /// # Arguments
535    ///
536    /// * `cache_dir` - Custom directory for caching repositories
537    ///
538    /// # Examples
539    ///
540    /// ```rust,no_run
541    /// use agpm_cli::source::SourceManager;
542    /// use std::path::PathBuf;
543    ///
544    /// let custom_cache = PathBuf::from("/custom/cache/location");
545    /// let manager = SourceManager::new_with_cache(custom_cache);
546    /// ```
547    #[must_use]
548    pub fn new_with_cache(cache_dir: PathBuf) -> Self {
549        Self {
550            sources: HashMap::new(),
551            cache_dir,
552        }
553    }
554
555    /// Creates a source manager from a manifest file (without global config integration).
556    ///
557    /// This method loads only sources defined in the project manifest, without merging
558    /// with global configuration. Use [`from_manifest_with_global()`] for full integration
559    /// that includes authentication tokens and private repositories.
560    ///
561    /// This method is primarily for backward compatibility and testing scenarios.
562    ///
563    /// # Arguments
564    ///
565    /// * `manifest` - Project manifest containing source definitions
566    ///
567    /// # Errors
568    ///
569    /// Returns an error if the cache directory cannot be determined.
570    ///
571    /// # Examples
572    ///
573    /// ```rust,no_run
574    /// use agpm_cli::source::SourceManager;
575    /// use agpm_cli::manifest::Manifest;
576    /// use std::path::Path;
577    ///
578    /// # fn example() -> anyhow::Result<()> {
579    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
580    /// let manager = SourceManager::from_manifest(&manifest)?;
581    ///
582    /// println!("Loaded {} sources from manifest", manager.list().len());
583    /// # Ok(())
584    /// # }
585    /// ```
586    ///
587    /// [`from_manifest_with_global()`]: SourceManager::from_manifest_with_global
588    pub fn from_manifest(manifest: &Manifest) -> Result<Self> {
589        let cache_dir = crate::config::get_cache_dir()?;
590        let mut manager = Self::new_with_cache(cache_dir);
591
592        // Load all sources from the manifest
593        for (name, url) in &manifest.sources {
594            let source = Source::new(name.clone(), url.clone());
595            manager.sources.insert(name.clone(), source);
596        }
597
598        Ok(manager)
599    }
600
601    /// Creates a source manager from manifest with global configuration integration.
602    ///
603    /// This is the recommended method for creating a [`SourceManager`] in production use.
604    /// It merges sources from both the project manifest and global configuration, enabling:
605    ///
606    /// - **Authentication**: Access to private repositories with embedded credentials
607    /// - **User customization**: Global sources that extend project-defined sources
608    /// - **Security**: Credentials stored safely outside version control
609    ///
610    /// # Source Resolution Priority
611    ///
612    /// 1. **Global sources**: Loaded first (may contain authentication tokens)
613    /// 2. **Local sources**: Override global sources with same names
614    /// 3. **Merged result**: Final source collection used by the manager
615    ///
616    /// # Arguments
617    ///
618    /// * `manifest` - Project manifest containing source definitions
619    ///
620    /// # Errors
621    ///
622    /// Returns an error if:
623    /// - Cache directory cannot be determined
624    /// - Global configuration cannot be loaded (though this is non-fatal)
625    ///
626    /// # Examples
627    ///
628    /// ```rust,no_run
629    /// use agpm_cli::source::SourceManager;
630    /// use agpm_cli::manifest::Manifest;
631    /// use std::path::Path;
632    ///
633    /// # async fn example() -> anyhow::Result<()> {
634    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
635    /// let manager = SourceManager::from_manifest_with_global(&manifest).await?;
636    ///
637    /// // Manager now includes both project and global sources
638    /// for source in manager.list() {
639    ///     println!("Available source: {} -> {}", source.name, source.url);
640    /// }
641    /// # Ok(())
642    /// # }
643    /// ```
644    pub async fn from_manifest_with_global(manifest: &Manifest) -> Result<Self> {
645        let cache_dir = crate::config::get_cache_dir()?;
646        let mut manager = Self::new_with_cache(cache_dir);
647
648        // Load global config and merge sources
649        let global_config = GlobalConfig::load().await.unwrap_or_default();
650        let merged_sources = global_config.merge_sources(&manifest.sources);
651
652        // Load all merged sources
653        for (name, url) in &merged_sources {
654            let source = Source::new(name.clone(), url.clone());
655            manager.sources.insert(name.clone(), source);
656        }
657
658        Ok(manager)
659    }
660
661    /// Creates a source manager from manifest with a custom cache directory.
662    ///
663    /// This method is primarily used for testing where a specific cache location is needed.
664    /// It loads only sources from the manifest without global configuration integration.
665    ///
666    /// # Arguments
667    ///
668    /// * `manifest` - Project manifest containing source definitions  
669    /// * `cache_dir` - Custom directory for caching repositories
670    ///
671    /// # Examples
672    ///
673    /// ```rust,no_run
674    /// use agpm_cli::source::SourceManager;
675    /// use agpm_cli::manifest::Manifest;
676    /// use std::path::{Path, PathBuf};
677    ///
678    /// # fn example() -> anyhow::Result<()> {
679    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
680    /// let custom_cache = PathBuf::from("/tmp/test-cache");
681    /// let manager = SourceManager::from_manifest_with_cache(&manifest, custom_cache);
682    /// # Ok(())
683    /// # }
684    /// ```
685    #[must_use]
686    pub fn from_manifest_with_cache(manifest: &Manifest, cache_dir: PathBuf) -> Self {
687        let mut manager = Self::new_with_cache(cache_dir);
688
689        // Load all sources from the manifest
690        for (name, url) in &manifest.sources {
691            let source = Source::new(name.clone(), url.clone());
692            manager.sources.insert(name.clone(), source);
693        }
694
695        manager
696    }
697
698    /// Adds a new source to the manager.
699    ///
700    /// The source name must be unique within this manager. Adding a source with an
701    /// existing name will return an error.
702    ///
703    /// # Arguments
704    ///
705    /// * `source` - The source to add to the manager
706    ///
707    /// # Errors
708    ///
709    /// Returns [`AgpmError::ConfigError`] if a source with the same name already exists.
710    ///
711    /// # Examples
712    ///
713    /// ```rust,no_run
714    /// use agpm_cli::source::{Source, SourceManager};
715    ///
716    /// # fn example() -> anyhow::Result<()> {
717    /// let mut manager = SourceManager::new()?;
718    ///
719    /// let source = Source::new(
720    ///     "community".to_string(),
721    ///     "https://github.com/example/agpm-community.git".to_string()
722    /// );
723    ///
724    /// manager.add(source)?;
725    /// assert!(manager.get("community").is_some());
726    /// # Ok(())
727    /// # }
728    /// ```
729    pub fn add(&mut self, source: Source) -> Result<()> {
730        if self.sources.contains_key(&source.name) {
731            return Err(AgpmError::ConfigError {
732                message: format!("Source '{}' already exists", source.name),
733            }
734            .into());
735        }
736
737        self.sources.insert(source.name.clone(), source);
738        Ok(())
739    }
740
741    /// Removes a source from the manager and cleans up its cache.
742    ///
743    /// This operation permanently removes the source from the manager and deletes
744    /// its cached repository data from disk. This cannot be undone, though the
745    /// repository can be re-added and will be cloned again on next sync.
746    ///
747    /// # Arguments
748    ///
749    /// * `name` - Name of the source to remove
750    ///
751    /// # Errors
752    ///
753    /// Returns an error if:
754    /// - The source does not exist ([`AgpmError::SourceNotFound`])
755    /// - The cache directory cannot be removed due to filesystem permissions
756    ///
757    /// # Examples
758    ///
759    /// ```rust,no_run
760    /// use agpm_cli::source::{Source, SourceManager};
761    ///
762    /// # async fn example() -> anyhow::Result<()> {
763    /// let mut manager = SourceManager::new()?;
764    ///
765    /// // Add and then remove a source
766    /// let source = Source::new("temp".to_string(), "https://github.com/temp/repo.git".to_string());
767    /// manager.add(source)?;
768    /// manager.remove("temp").await?;
769    ///
770    /// assert!(manager.get("temp").is_none());
771    /// # Ok(())
772    /// # }
773    /// ```
774    pub async fn remove(&mut self, name: &str) -> Result<()> {
775        if !self.sources.contains_key(name) {
776            return Err(AgpmError::SourceNotFound {
777                name: name.to_string(),
778            }
779            .into());
780        }
781
782        self.sources.remove(name);
783
784        let source_cache = self.cache_dir.join("sources").join(name);
785        if source_cache.exists() {
786            tokio::fs::remove_dir_all(&source_cache)
787                .await
788                .context("Failed to remove source cache")?;
789        }
790
791        Ok(())
792    }
793
794    /// Gets a reference to a source by name.
795    ///
796    /// Returns [`None`] if no source with the given name exists.
797    ///
798    /// # Arguments
799    ///
800    /// * `name` - Name of the source to retrieve
801    ///
802    /// # Examples
803    ///
804    /// ```rust,no_run
805    /// use agpm_cli::source::{Source, SourceManager};
806    ///
807    /// # fn example() -> anyhow::Result<()> {
808    /// let mut manager = SourceManager::new()?;
809    /// let source = Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
810    /// manager.add(source)?;
811    ///
812    /// if let Some(source) = manager.get("test") {
813    ///     println!("Found source: {} -> {}", source.name, source.url);
814    /// }
815    /// # Ok(())
816    /// # }
817    /// ```
818    #[must_use]
819    pub fn get(&self, name: &str) -> Option<&Source> {
820        self.sources.get(name)
821    }
822
823    /// Gets a mutable reference to a source by name.
824    ///
825    /// Returns [`None`] if no source with the given name exists. Use this method
826    /// when you need to modify source properties like description or enabled status.
827    ///
828    /// # Arguments
829    ///
830    /// * `name` - Name of the source to retrieve
831    ///
832    /// # Examples
833    ///
834    /// ```rust,no_run
835    /// use agpm_cli::source::{Source, SourceManager};
836    ///
837    /// # fn example() -> anyhow::Result<()> {
838    /// let mut manager = SourceManager::new()?;
839    /// let source = Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
840    /// manager.add(source)?;
841    ///
842    /// if let Some(source) = manager.get_mut("test") {
843    ///     source.description = Some("Updated description".to_string());
844    ///     source.enabled = false;
845    /// }
846    /// # Ok(())
847    /// # }
848    /// ```
849    pub fn get_mut(&mut self, name: &str) -> Option<&mut Source> {
850        self.sources.get_mut(name)
851    }
852
853    /// Returns a list of all sources managed by this manager.
854    ///
855    /// The returned vector contains references to all sources, both enabled and disabled.
856    /// For only enabled sources, use [`list_enabled()`].
857    ///
858    /// # Examples
859    ///
860    /// ```rust,no_run
861    /// use agpm_cli::source::{Source, SourceManager};
862    ///
863    /// # fn example() -> anyhow::Result<()> {
864    /// let manager = SourceManager::new()?;
865    ///
866    /// for source in manager.list() {
867    ///     println!("Source: {} -> {} (enabled: {})",
868    ///         source.name, source.url, source.enabled);
869    /// }
870    /// # Ok(())
871    /// # }
872    /// ```
873    ///
874    /// [`list_enabled()`]: SourceManager::list_enabled
875    #[must_use]
876    pub fn list(&self) -> Vec<&Source> {
877        self.sources.values().collect()
878    }
879
880    /// Returns a list of enabled sources managed by this manager.
881    ///
882    /// Only sources with `enabled: true` are included in the result. This is useful
883    /// for operations that should only work with active sources.
884    ///
885    /// # Examples
886    ///
887    /// ```rust,no_run
888    /// use agpm_cli::source::{Source, SourceManager};
889    ///
890    /// # fn example() -> anyhow::Result<()> {
891    /// let manager = SourceManager::new()?;
892    ///
893    /// println!("Enabled sources: {}", manager.list_enabled().len());
894    /// for source in manager.list_enabled() {
895    ///     println!("  {} -> {}", source.name, source.url);
896    /// }
897    /// # Ok(())
898    /// # }
899    /// ```
900    #[must_use]
901    pub fn list_enabled(&self) -> Vec<&Source> {
902        self.sources.values().filter(|s| s.enabled).collect()
903    }
904
905    /// Gets the URL of a source by name.
906    ///
907    /// Returns the repository URL for the named source, or [`None`] if the source doesn't exist.
908    /// This is useful for logging and debugging purposes.
909    ///
910    /// # Arguments
911    ///
912    /// * `name` - Name of the source to get the URL for
913    ///
914    /// # Examples
915    ///
916    /// ```rust,no_run
917    /// use agpm_cli::source::{Source, SourceManager};
918    ///
919    /// # fn example() -> anyhow::Result<()> {
920    /// let mut manager = SourceManager::new()?;
921    /// let source = Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
922    /// manager.add(source)?;
923    ///
924    /// if let Some(url) = manager.get_source_url("test") {
925    ///     println!("Source URL: {}", url);
926    /// }
927    /// # Ok(())
928    /// # }
929    /// ```
930    #[must_use]
931    pub fn get_source_url(&self, name: &str) -> Option<String> {
932        self.sources.get(name).map(|s| s.url.clone())
933    }
934
935    /// Synchronizes a source repository to the local cache.
936    ///
937    /// This is the core method for ensuring a source repository is available locally.
938    /// It handles both initial cloning and subsequent updates, with intelligent caching
939    /// and error recovery.
940    ///
941    /// # Synchronization Process
942    ///
943    /// 1. **Validation**: Check that the source exists and is enabled
944    /// 2. **Cache Check**: Determine if repository is already cached
945    /// 3. **Repository Type Detection**: Handle remote vs local repositories
946    /// 4. **Sync Operation**:
947    ///    - **First time**: Clone the repository to cache
948    ///    - **Subsequent**: Fetch updates from remote
949    ///    - **Invalid cache**: Remove corrupted cache and re-clone
950    /// 5. **Cache Update**: Update source's `local_path` with cache location
951    ///
952    /// # Repository Types Supported
953    ///
954    /// ## Remote Repositories
955    /// - **HTTPS**: `https://github.com/owner/repo.git`  
956    /// - **SSH**: `git@github.com:owner/repo.git`
957    ///
958    /// ## Local Repositories  
959    /// - **Absolute paths**: `/absolute/path/to/repo`
960    /// - **Relative paths**: `../relative/path` or `./local-path`
961    /// - **File URLs**: `file:///absolute/path/to/repo`
962    ///
963    /// # Authentication
964    ///
965    /// Authentication is handled transparently through URLs with embedded credentials
966    /// from the global configuration. Private repositories should have their authentication
967    /// tokens configured in `~/.agpm/config.toml`.
968    ///
969    /// # Error Handling
970    ///
971    /// The method provides comprehensive error handling for common scenarios:
972    /// - **Source not found**: Clear error with source name
973    /// - **Disabled source**: Prevents operations on disabled sources  
974    /// - **Network failures**: Graceful handling with context
975    /// - **Invalid repositories**: Validation of Git repository structure
976    /// - **Cache corruption**: Automatic cleanup and re-cloning
977    ///
978    /// # Arguments
979    ///
980    /// * `name` - Name of the source to synchronize
981    /// * `progress` - Optional progress bar for user feedback during long operations
982    ///
983    /// # Returns
984    ///
985    /// Returns a [`GitRepo`] instance pointing to the synchronized repository cache.
986    ///
987    /// # Errors
988    ///
989    /// Returns an error if:
990    /// - Source doesn't exist ([`AgpmError::SourceNotFound`])
991    /// - Source is disabled ([`AgpmError::ConfigError`])
992    /// - Repository is not accessible (network, permissions, etc.)
993    /// - Local path doesn't exist or isn't a Git repository
994    /// - Cache directory cannot be created
995    ///
996    /// # Examples
997    ///
998    /// ## Basic Synchronization
999    /// ```rust,no_run
1000    /// use agpm_cli::source::{Source, SourceManager};
1001    ///
1002    /// # async fn example() -> anyhow::Result<()> {
1003    /// let mut manager = SourceManager::new()?;
1004    /// let source = Source::new(
1005    ///     "community".to_string(),
1006    ///     "https://github.com/example/agpm-community.git".to_string()
1007    /// );
1008    /// manager.add(source)?;
1009    ///
1010    /// // Sync without progress feedback
1011    /// let repo = manager.sync("community").await?;
1012    /// println!("Repository available at: {:?}", repo.path());
1013    /// # Ok(())
1014    /// # }
1015    /// ```
1016    ///
1017    /// ## Synchronization with Progress
1018    /// ```rust,no_run
1019    /// use agpm_cli::source::{Source, SourceManager};
1020    /// use indicatif::ProgressBar;
1021    ///
1022    /// # async fn example() -> anyhow::Result<()> {
1023    /// let mut manager = SourceManager::new()?;
1024    /// let source = Source::new(
1025    ///     "large-repo".to_string(),
1026    ///     "https://github.com/example/large-repository.git".to_string()
1027    /// );
1028    /// manager.add(source)?;
1029    ///
1030    /// // Sync repository
1031    /// let progress = ProgressBar::new(100);
1032    /// progress.set_message("Syncing large repository...");
1033    ///
1034    /// let repo = manager.sync("large-repo").await?;
1035    /// progress.finish_with_message("Repository synced successfully");
1036    /// # Ok(())
1037    /// # }
1038    /// ```
1039    pub async fn sync(&mut self, name: &str) -> Result<GitRepo> {
1040        let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
1041            name: name.to_string(),
1042        })?;
1043
1044        if !source.enabled {
1045            return Err(AgpmError::ConfigError {
1046                message: format!("Source '{name}' is disabled"),
1047            }
1048            .into());
1049        }
1050
1051        let cache_path = source.cache_dir(&self.cache_dir);
1052        ensure_dir(cache_path.parent().unwrap())?;
1053
1054        // Use the URL directly (auth tokens are already embedded in URLs from global config)
1055        let url = source.url.clone();
1056
1057        // Distinguish between plain directories and git repositories
1058        let is_local_path = is_local_filesystem_path(&url);
1059        let is_file_url = url.starts_with("file://");
1060
1061        // Acquire lock for this source to prevent concurrent git operations
1062        // This prevents issues like concurrent "git remote set-url" commands
1063        let _lock = CacheLock::acquire(&self.cache_dir, name).await?;
1064
1065        let repo = if is_local_path {
1066            // Local paths are treated as plain directories (not git repositories)
1067            // Apply security validation for local paths
1068            let resolved_path = crate::utils::platform::resolve_path(&url)?;
1069
1070            // Security check: Validate path against blacklist and symlinks BEFORE canonicalization
1071            validate_path_security(&resolved_path, true)?;
1072
1073            let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
1074                .map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
1075
1076            // For local paths, we just return a GitRepo pointing to the local directory
1077            // No cloning or fetching needed - these are treated as plain directories
1078            GitRepo::new(canonical_path)
1079        } else if is_file_url {
1080            // file:// URLs must point to valid git repositories
1081            let path_str = url.strip_prefix("file://").unwrap();
1082
1083            // On Windows, convert forward slashes back to backslashes
1084            #[cfg(windows)]
1085            let path_str = path_str.replace('/', "\\");
1086            #[cfg(not(windows))]
1087            let path_str = path_str.to_string();
1088
1089            let abs_path = PathBuf::from(path_str);
1090
1091            // Check if the local path exists and is a git repo
1092            if !abs_path.exists() {
1093                return Err(anyhow::anyhow!(
1094                    "Local repository path does not exist or is not accessible: {}",
1095                    abs_path.display()
1096                ));
1097            }
1098
1099            // Check if it's a git repository (either regular or bare)
1100            if !crate::git::is_git_repository(&abs_path) {
1101                return Err(anyhow::anyhow!(
1102                    "Specified path is not a git repository. file:// URLs must point to valid git repositories."
1103                ));
1104            }
1105
1106            if cache_path.exists() {
1107                let repo = GitRepo::new(&cache_path);
1108                if repo.is_git_repo() {
1109                    // For file:// repos, fetch to get latest changes
1110                    repo.fetch(Some(&url)).await?;
1111                    repo
1112                } else {
1113                    tokio::fs::remove_dir_all(&cache_path)
1114                        .await
1115                        .context("Failed to remove invalid cache directory")?;
1116                    GitRepo::clone(&url, &cache_path).await?
1117                }
1118            } else {
1119                GitRepo::clone(&url, &cache_path).await?
1120            }
1121        } else if cache_path.exists() {
1122            let repo = GitRepo::new(&cache_path);
1123            if repo.is_git_repo() {
1124                // Always fetch for all URLs to get latest changes
1125                repo.fetch(Some(&url)).await?;
1126                repo
1127            } else {
1128                tokio::fs::remove_dir_all(&cache_path)
1129                    .await
1130                    .context("Failed to remove invalid cache directory")?;
1131                GitRepo::clone(&url, &cache_path).await?
1132            }
1133        } else {
1134            GitRepo::clone(&url, &cache_path).await?
1135        };
1136
1137        if let Some(source) = self.sources.get_mut(name) {
1138            source.local_path = Some(cache_path);
1139        }
1140
1141        Ok(repo)
1142    }
1143
1144    /// Synchronizes a repository by URL without adding it as a named source.
1145    ///
1146    /// This method is used for direct Git dependencies that are referenced by URL rather
1147    /// than by source name. It's particularly useful for one-off repository access or
1148    /// when dealing with dependencies that don't need to be permanently registered.
1149    ///
1150    /// # Key Differences from `sync()`
1151    ///
1152    /// - **No source registration**: Repository is not added to the manager's source list
1153    /// - **URL-based caching**: Cache directory is derived from the URL structure
1154    /// - **Direct access**: Bypasses source name resolution and enablement checks
1155    /// - **Temporary usage**: Ideal for short-lived or one-time repository access
1156    ///
1157    /// # Cache Management
1158    ///
1159    /// The cache directory is generated using the same pattern as named sources:
1160    /// `{cache_dir}/sources/{owner}_{repository}` where owner and repository are
1161    /// parsed from the Git URL.
1162    ///
1163    /// # Repository Types
1164    ///
1165    /// Supports the same repository types as `sync()`:
1166    /// - Remote HTTPS/SSH repositories
1167    /// - Local file paths and file:// URLs
1168    /// - Proper validation for all repository types
1169    ///
1170    /// # Arguments
1171    ///
1172    /// * `url` - Repository URL or local path to synchronize
1173    /// * `progress` - Optional progress bar for user feedback
1174    ///
1175    /// # Returns
1176    ///
1177    /// Returns a [`GitRepo`] instance pointing to the cached repository.
1178    ///
1179    /// # Errors
1180    ///
1181    /// Returns an error if:
1182    /// - Repository URL is invalid or inaccessible
1183    /// - Local path doesn't exist or isn't a Git repository  
1184    /// - Network connectivity issues for remote repositories
1185    /// - Filesystem permission issues
1186    ///
1187    /// # Examples
1188    ///
1189    /// ## Direct Repository Access
1190    /// ```rust,no_run
1191    /// use agpm_cli::source::SourceManager;
1192    ///
1193    /// # async fn example() -> anyhow::Result<()> {
1194    /// let mut manager = SourceManager::new()?;
1195    ///
1196    /// // Sync a repository directly by URL
1197    /// let repo = manager.sync_by_url(
1198    ///     "https://github.com/example/direct-dependency.git"
1199    /// ).await?;
1200    ///
1201    /// println!("Direct repository available at: {:?}", repo.path());
1202    /// # Ok(())
1203    /// # }
1204    /// ```
1205    ///
1206    /// ## Local Repository Access
1207    /// ```rust,no_run
1208    /// use agpm_cli::source::SourceManager;
1209    /// use std::env;
1210    ///
1211    /// # async fn example() -> anyhow::Result<()> {
1212    /// let mut manager = SourceManager::new()?;
1213    ///
1214    /// // Access a local development repository
1215    /// let local_path = env::temp_dir().join("development").join("repo");
1216    /// let repo = manager.sync_by_url(
1217    ///     &local_path.to_string_lossy()
1218    /// ).await?;
1219    /// # Ok(())
1220    /// # }
1221    /// ```
1222    pub async fn sync_by_url(&self, url: &str) -> Result<GitRepo> {
1223        // Generate a cache directory based on the URL
1224        let (owner, repo_name) =
1225            parse_git_url(url).unwrap_or(("direct".to_string(), "repo".to_string()));
1226        let cache_path = self.cache_dir.join("sources").join(format!("{owner}_{repo_name}"));
1227        ensure_dir(cache_path.parent().unwrap())?;
1228
1229        // Check URL type
1230        let is_local_path = is_local_filesystem_path(url);
1231        let is_file_url = url.starts_with("file://");
1232
1233        // Handle local paths (not git repositories, just directories)
1234        if is_local_path {
1235            // Apply security validation for local paths
1236            let resolved_path = crate::utils::platform::resolve_path(url)?;
1237
1238            // Security check: Validate path against blacklist and symlinks BEFORE canonicalization
1239            validate_path_security(&resolved_path, true)?;
1240
1241            let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
1242                .map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
1243
1244            // For local paths, we just return a GitRepo pointing to the local directory
1245            // No cloning or fetching needed - these are treated as plain directories
1246            return Ok(GitRepo::new(canonical_path));
1247        }
1248
1249        // For file:// URLs, verify they're git repositories
1250        if is_file_url {
1251            let path_str = url.strip_prefix("file://").unwrap();
1252
1253            // On Windows, convert forward slashes back to backslashes
1254            #[cfg(windows)]
1255            let path_str = path_str.replace('/', "\\");
1256            #[cfg(not(windows))]
1257            let path_str = path_str.to_string();
1258
1259            let abs_path = PathBuf::from(path_str);
1260
1261            if !abs_path.exists() {
1262                return Err(anyhow::anyhow!(
1263                    "Local repository path does not exist or is not accessible: {}",
1264                    abs_path.display()
1265                ));
1266            }
1267
1268            // Check if it's a git repository (either regular or bare)
1269            if !crate::git::is_git_repository(&abs_path) {
1270                return Err(anyhow::anyhow!(
1271                    "Specified path is not a git repository. file:// URLs must point to valid git repositories."
1272                ));
1273            }
1274        }
1275
1276        // Acquire lock for this URL-based source to prevent concurrent git operations
1277        // Use a deterministic lock name based on owner and repo
1278        let lock_name = format!("{owner}_{repo_name}");
1279        let _lock = CacheLock::acquire(&self.cache_dir, &lock_name).await?;
1280
1281        // Use the URL directly (auth tokens are already embedded in URLs from global config)
1282        let authenticated_url = url.to_string();
1283
1284        let repo = if cache_path.exists() {
1285            let repo = GitRepo::new(&cache_path);
1286            if repo.is_git_repo() {
1287                // For file:// URLs, always fetch to update refs
1288                // For remote URLs, also fetch
1289                repo.fetch(Some(&authenticated_url)).await?;
1290                repo
1291            } else {
1292                tokio::fs::remove_dir_all(&cache_path)
1293                    .await
1294                    .context("Failed to remove invalid cache directory")?;
1295                GitRepo::clone(&authenticated_url, &cache_path).await?
1296            }
1297        } else {
1298            GitRepo::clone(&authenticated_url, &cache_path).await?
1299        };
1300
1301        Ok(repo)
1302    }
1303
1304    /// Synchronizes all enabled sources by fetching latest changes
1305    ///
1306    /// This method iterates through all enabled sources and synchronizes each one
1307    /// by fetching the latest changes from their remote repositories.
1308    ///
1309    /// # Arguments
1310    ///
1311    /// * `progress` - Optional progress bar for displaying sync progress
1312    ///
1313    /// # Returns
1314    ///
1315    /// Returns `Ok(())` if all sources sync successfully
1316    ///
1317    /// # Errors
1318    ///
1319    /// Returns an error if any source fails to sync
1320    pub async fn sync_all(&mut self) -> Result<()> {
1321        let enabled_sources: Vec<String> =
1322            self.list_enabled().iter().map(|s| s.name.clone()).collect();
1323
1324        for name in enabled_sources {
1325            self.sync(&name).await?;
1326        }
1327
1328        Ok(())
1329    }
1330
1331    /// Sync multiple sources by URL in parallel
1332    ///
1333    /// Executes all sync operations concurrently using tokio tasks. Each sync operation
1334    /// uses file-level locking via `CacheLock` to ensure thread safety, preventing
1335    /// concurrent modifications to the same repository.
1336    ///
1337    /// # Performance
1338    ///
1339    /// This method provides significant performance improvements when syncing multiple
1340    /// repositories, especially over network connections. All sync operations execute
1341    /// concurrently, limited only by system resources and network bandwidth.
1342    ///
1343    /// # Thread Safety
1344    ///
1345    /// - Each repository sync acquires a file-based lock to prevent concurrent access
1346    /// - Different repositories can sync simultaneously without blocking each other
1347    /// - Lock contention only occurs if the same repository is synced multiple times
1348    ///
1349    /// # Examples
1350    ///
1351    /// ```rust,no_run
1352    /// # use agpm_cli::source::SourceManager;
1353    /// # use tempfile::TempDir;
1354    /// # async fn example() -> anyhow::Result<()> {
1355    /// # let temp = TempDir::new()?;
1356    /// # let mut manager = SourceManager::new_with_cache(temp.path().to_path_buf());
1357    /// let urls = vec![
1358    ///     "https://github.com/example/repo1.git".to_string(),
1359    ///     "https://github.com/example/repo2.git".to_string(),
1360    ///     "https://github.com/example/repo3.git".to_string(),
1361    /// ];
1362    /// let repos = manager.sync_multiple_by_url(&urls).await?;
1363    /// # Ok(())
1364    /// # }
1365    /// ```
1366    pub async fn sync_multiple_by_url(&self, urls: &[String]) -> Result<Vec<GitRepo>> {
1367        if urls.is_empty() {
1368            return Ok(Vec::new());
1369        }
1370
1371        // Create async tasks for each URL
1372        let futures: Vec<_> =
1373            urls.iter().map(|url| async move { self.sync_by_url(url).await }).collect();
1374
1375        // Execute all syncs in parallel and collect results
1376        let results = join_all(futures).await;
1377
1378        // Convert Vec<Result<GitRepo>> to Result<Vec<GitRepo>>
1379        results.into_iter().collect()
1380    }
1381
1382    /// Enables a source for use in operations.
1383    ///
1384    /// Enabled sources are included in operations like [`sync_all()`] and [`verify_all()`].
1385    /// Sources are enabled by default when created.
1386    ///
1387    /// # Arguments
1388    ///
1389    /// * `name` - Name of the source to enable
1390    ///
1391    /// # Errors
1392    ///
1393    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
1394    ///
1395    /// # Examples
1396    ///
1397    /// ```rust,no_run
1398    /// use agpm_cli::source::{Source, SourceManager};
1399    ///
1400    /// # fn example() -> anyhow::Result<()> {
1401    /// let mut manager = SourceManager::new()?;
1402    /// let source = Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
1403    /// manager.add(source)?;
1404    ///
1405    /// // Disable then re-enable
1406    /// manager.disable("test")?;
1407    /// manager.enable("test")?;
1408    ///
1409    /// assert!(manager.get("test").unwrap().enabled);
1410    /// # Ok(())
1411    /// # }
1412    /// ```
1413    ///
1414    /// [`sync_all()`]: SourceManager::sync_all
1415    /// [`verify_all()`]: SourceManager::verify_all
1416    pub fn enable(&mut self, name: &str) -> Result<()> {
1417        let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
1418            name: name.to_string(),
1419        })?;
1420
1421        source.enabled = true;
1422        Ok(())
1423    }
1424
1425    /// Disables a source to exclude it from operations.
1426    ///
1427    /// Disabled sources are excluded from bulk operations like [`sync_all()`] and
1428    /// [`verify_all()`], and cannot be synced individually. This is useful for
1429    /// temporarily disabling problematic sources without removing them entirely.
1430    ///
1431    /// # Arguments
1432    ///
1433    /// * `name` - Name of the source to disable
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
1438    ///
1439    /// # Examples
1440    ///
1441    /// ```rust,no_run
1442    /// use agpm_cli::source::{Source, SourceManager};
1443    ///
1444    /// # fn example() -> anyhow::Result<()> {
1445    /// let mut manager = SourceManager::new()?;
1446    /// let source = Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
1447    /// manager.add(source)?;
1448    ///
1449    /// // Disable the source
1450    /// manager.disable("test")?;
1451    ///
1452    /// assert!(!manager.get("test").unwrap().enabled);
1453    /// assert_eq!(manager.list_enabled().len(), 0);
1454    /// # Ok(())
1455    /// # }
1456    /// ```
1457    ///
1458    /// [`sync_all()`]: SourceManager::sync_all
1459    /// [`verify_all()`]: SourceManager::verify_all
1460    pub fn disable(&mut self, name: &str) -> Result<()> {
1461        let source = self.sources.get_mut(name).ok_or_else(|| AgpmError::SourceNotFound {
1462            name: name.to_string(),
1463        })?;
1464
1465        source.enabled = false;
1466        Ok(())
1467    }
1468
1469    /// Gets the cache directory path for a source by URL.
1470    ///
1471    /// Searches through managed sources to find one with a matching URL and returns
1472    /// its cache directory path. This is useful when you have a URL and need to
1473    /// determine where its cached content would be stored.
1474    ///
1475    /// # Arguments
1476    ///
1477    /// * `url` - Repository URL to look up
1478    ///
1479    /// # Returns
1480    ///
1481    /// [`PathBuf`] pointing to the cache directory for the source.
1482    ///
1483    /// # Errors
1484    ///
1485    /// Returns [`AgpmError::SourceNotFound`] if no source with the given URL exists.
1486    ///
1487    /// # Examples
1488    ///
1489    /// ```rust,no_run
1490    /// use agpm_cli::source::{Source, SourceManager};
1491    ///
1492    /// # fn example() -> anyhow::Result<()> {
1493    /// let mut manager = SourceManager::new()?;
1494    /// let url = "https://github.com/example/repo.git".to_string();
1495    /// let source = Source::new("example".to_string(), url.clone());
1496    /// manager.add(source)?;
1497    ///
1498    /// let cache_path = manager.get_cached_path(&url)?;
1499    /// println!("Cache path: {:?}", cache_path);
1500    /// # Ok(())
1501    /// # }
1502    /// ```
1503    pub fn get_cached_path(&self, url: &str) -> Result<PathBuf> {
1504        // Try to find the source by URL
1505        let source = self.sources.values().find(|s| s.url == url).ok_or_else(|| {
1506            AgpmError::SourceNotFound {
1507                name: url.to_string(),
1508            }
1509        })?;
1510
1511        Ok(source.cache_dir(&self.cache_dir))
1512    }
1513
1514    /// Gets the cache directory path for a source by name.
1515    ///
1516    /// Returns the cache directory path where the named source's repository
1517    /// content is or would be stored.
1518    ///
1519    /// # Arguments
1520    ///
1521    /// * `name` - Name of the source to get the cache path for
1522    ///
1523    /// # Returns
1524    ///
1525    /// [`PathBuf`] pointing to the cache directory for the source.
1526    ///
1527    /// # Errors
1528    ///
1529    /// Returns [`AgpmError::SourceNotFound`] if no source with the given name exists.
1530    ///
1531    /// # Examples
1532    ///
1533    /// ```rust,no_run
1534    /// use agpm_cli::source::{Source, SourceManager};
1535    ///
1536    /// # fn example() -> anyhow::Result<()> {
1537    /// let mut manager = SourceManager::new()?;
1538    /// let source = Source::new(
1539    ///     "community".to_string(),
1540    ///     "https://github.com/example/agpm-community.git".to_string()
1541    /// );
1542    /// manager.add(source)?;
1543    ///
1544    /// let cache_path = manager.get_cached_path_by_name("community")?;
1545    /// println!("Community cache: {:?}", cache_path);
1546    /// # Ok(())
1547    /// # }
1548    /// ```
1549    pub fn get_cached_path_by_name(&self, name: &str) -> Result<PathBuf> {
1550        let source = self.sources.get(name).ok_or_else(|| AgpmError::SourceNotFound {
1551            name: name.to_string(),
1552        })?;
1553
1554        Ok(source.cache_dir(&self.cache_dir))
1555    }
1556
1557    /// Verifies that all enabled sources are accessible.
1558    ///
1559    /// This method performs lightweight verification checks on all enabled sources
1560    /// without performing full synchronization. It's useful for validating source
1561    /// configurations and network connectivity before attempting operations.
1562    ///
1563    /// # Verification Process
1564    ///
1565    /// For each enabled source:
1566    /// 1. **URL validation**: Check URL format and structure
1567    /// 2. **Connectivity test**: Verify remote repositories are reachable
1568    /// 3. **Local path validation**: Ensure local repositories exist and are Git repos
1569    /// 4. **Authentication check**: Validate credentials for private repositories
1570    ///
1571    /// # Performance Characteristics
1572    ///
1573    /// - **Lightweight**: No cloning or downloading of repository content
1574    /// - **Fast**: Quick network checks rather than full Git operations
1575    /// - **Sequential**: Sources verified one at a time for clear error reporting
1576    ///
1577    /// # Arguments
1578    ///
1579    /// * `progress` - Optional progress bar for user feedback
1580    ///
1581    /// # Errors
1582    ///
1583    /// Returns an error if any enabled source fails verification:
1584    /// - Network connectivity issues
1585    /// - Authentication failures
1586    /// - Invalid repository URLs
1587    /// - Local paths that don't exist or aren't Git repositories
1588    ///
1589    /// # Examples
1590    ///
1591    /// ```rust,no_run
1592    /// use agpm_cli::source::{Source, SourceManager};
1593    ///
1594    /// # async fn example() -> anyhow::Result<()> {
1595    /// let mut manager = SourceManager::new()?;
1596    ///
1597    /// // Add some sources
1598    /// manager.add(Source::new(
1599    ///     "community".to_string(),
1600    ///     "https://github.com/example/agpm-community.git".to_string()
1601    /// ))?;
1602    ///
1603    /// // Verify all sources
1604    /// manager.verify_all().await?;
1605    ///
1606    /// println!("All sources verified successfully");
1607    /// # Ok(())
1608    /// # }
1609    /// ```
1610    pub async fn verify_all(&self) -> Result<()> {
1611        let enabled_sources: Vec<&Source> = self.list_enabled();
1612
1613        if enabled_sources.is_empty() {
1614            return Ok(());
1615        }
1616
1617        for source in enabled_sources {
1618            // Check if source URL is reachable by attempting a quick operation
1619            self.verify_source(&source.url).await?;
1620        }
1621
1622        Ok(())
1623    }
1624
1625    /// Verifies that a single source URL is accessible.
1626    ///
1627    /// Performs a lightweight check to determine if a repository URL is accessible
1628    /// without downloading content. The verification method depends on the URL type:
1629    ///
1630    /// - **file:// URLs**: Check if the local path exists
1631    /// - **Remote URLs**: Perform network connectivity check
1632    /// - **Local paths**: Validate path exists and is a Git repository
1633    ///
1634    /// # Arguments
1635    ///
1636    /// * `url` - Repository URL or local path to verify
1637    ///
1638    /// # Errors
1639    ///
1640    /// Returns an error if the source is not accessible, with specific error
1641    /// messages based on the failure type (network, authentication, path, etc.).
1642    async fn verify_source(&self, url: &str) -> Result<()> {
1643        // For file:// URLs (used in tests), just check if the path exists
1644        if url.starts_with("file://") {
1645            let path = url.strip_prefix("file://").unwrap();
1646            if std::path::Path::new(path).exists() {
1647                return Ok(());
1648            }
1649            return Err(anyhow::anyhow!("Local path does not exist: {path}"));
1650        }
1651
1652        // For other URLs, try to create a GitRepo object and verify it's accessible
1653        // This is a lightweight check - we don't actually clone the repo
1654        match crate::git::GitRepo::verify_url(url).await {
1655            Ok(()) => Ok(()),
1656            Err(e) => Err(anyhow::anyhow!("Source not accessible: {e}")),
1657        }
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664    use tempfile::TempDir;
1665
1666    #[test]
1667    fn test_source_creation() {
1668        let source =
1669            Source::new("test".to_string(), "https://github.com/user/repo.git".to_string())
1670                .with_description("Test source".to_string());
1671
1672        assert_eq!(source.name, "test");
1673        assert_eq!(source.url, "https://github.com/user/repo.git");
1674        assert_eq!(source.description, Some("Test source".to_string()));
1675        assert!(source.enabled);
1676    }
1677
1678    #[tokio::test]
1679    async fn test_source_manager_add_remove() {
1680        let temp_dir = TempDir::new().unwrap();
1681        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1682
1683        let source =
1684            Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
1685
1686        manager.add(source.clone()).unwrap();
1687        assert!(manager.get("test").is_some());
1688
1689        let result = manager.add(source);
1690        assert!(result.is_err());
1691
1692        manager.remove("test").await.unwrap();
1693        assert!(manager.get("test").is_none());
1694
1695        let result = manager.remove("test").await;
1696        assert!(result.is_err());
1697    }
1698
1699    #[test]
1700    fn test_source_enable_disable() {
1701        let temp_dir = TempDir::new().unwrap();
1702        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1703
1704        let source =
1705            Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
1706
1707        manager.add(source).unwrap();
1708        assert!(manager.get("test").unwrap().enabled);
1709
1710        manager.disable("test").unwrap();
1711        assert!(!manager.get("test").unwrap().enabled);
1712
1713        manager.enable("test").unwrap();
1714        assert!(manager.get("test").unwrap().enabled);
1715    }
1716
1717    #[test]
1718    fn test_list_enabled() {
1719        let temp_dir = TempDir::new().unwrap();
1720        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1721
1722        manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
1723        manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
1724        manager.add(Source::new("source3".to_string(), "url3".to_string())).unwrap();
1725
1726        assert_eq!(manager.list_enabled().len(), 3);
1727
1728        manager.disable("source2").unwrap();
1729        assert_eq!(manager.list_enabled().len(), 2);
1730    }
1731
1732    #[test]
1733    fn test_source_cache_dir() {
1734        let temp_dir = TempDir::new().unwrap();
1735        let base_dir = temp_dir.path();
1736
1737        let source =
1738            Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
1739
1740        let cache_dir = source.cache_dir(base_dir);
1741        assert!(cache_dir.to_string_lossy().contains("sources"));
1742        assert!(cache_dir.to_string_lossy().contains("user_repo"));
1743    }
1744
1745    #[test]
1746    fn test_source_cache_dir_invalid_url() {
1747        let temp_dir = TempDir::new().unwrap();
1748        let base_dir = temp_dir.path();
1749
1750        let source = Source::new("test".to_string(), "not-a-valid-url".to_string());
1751
1752        let cache_dir = source.cache_dir(base_dir);
1753        assert!(cache_dir.to_string_lossy().contains("sources"));
1754        assert!(cache_dir.to_string_lossy().contains("unknown_test"));
1755    }
1756
1757    #[test]
1758    fn test_from_manifest() {
1759        let mut manifest = Manifest::new();
1760        manifest.add_source(
1761            "official".to_string(),
1762            "https://github.com/example-org/agpm-official.git".to_string(),
1763        );
1764        manifest.add_source(
1765            "community".to_string(),
1766            "https://github.com/example-org/agpm-community.git".to_string(),
1767        );
1768
1769        let temp_dir = TempDir::new().unwrap();
1770        let manager =
1771            SourceManager::from_manifest_with_cache(&manifest, temp_dir.path().to_path_buf());
1772
1773        assert_eq!(manager.list().len(), 2);
1774        assert!(manager.get("official").is_some());
1775        assert!(manager.get("community").is_some());
1776    }
1777
1778    #[test]
1779    fn test_source_manager_list() {
1780        let temp_dir = TempDir::new().unwrap();
1781        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1782
1783        assert_eq!(manager.list().len(), 0);
1784
1785        manager.add(Source::new("source1".to_string(), "url1".to_string())).unwrap();
1786        manager.add(Source::new("source2".to_string(), "url2".to_string())).unwrap();
1787
1788        assert_eq!(manager.list().len(), 2);
1789    }
1790
1791    #[test]
1792    fn test_source_manager_get_mut() {
1793        let temp_dir = TempDir::new().unwrap();
1794        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1795
1796        manager.add(Source::new("test".to_string(), "url".to_string())).unwrap();
1797
1798        if let Some(source) = manager.get_mut("test") {
1799            source.description = Some("Updated description".to_string());
1800        }
1801
1802        assert_eq!(
1803            manager.get("test").unwrap().description,
1804            Some("Updated description".to_string())
1805        );
1806    }
1807
1808    #[test]
1809    fn test_source_manager_enable_disable_errors() {
1810        let temp_dir = TempDir::new().unwrap();
1811        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1812
1813        let result = manager.enable("nonexistent");
1814        assert!(result.is_err());
1815
1816        let result = manager.disable("nonexistent");
1817        assert!(result.is_err());
1818    }
1819
1820    #[tokio::test]
1821    async fn test_source_manager_sync_disabled() {
1822        let temp_dir = TempDir::new().unwrap();
1823        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1824
1825        let source =
1826            Source::new("test".to_string(), "https://github.com/user/repo.git".to_string());
1827        manager.add(source).unwrap();
1828        manager.disable("test").unwrap();
1829
1830        let result = manager.sync("test").await;
1831        assert!(result.is_err());
1832    }
1833
1834    #[tokio::test]
1835    async fn test_source_manager_sync_nonexistent() {
1836        let temp_dir = TempDir::new().unwrap();
1837        let mut manager = SourceManager::new_with_cache(temp_dir.path().to_path_buf());
1838
1839        let result = manager.sync("nonexistent").await;
1840        assert!(result.is_err());
1841    }
1842
1843    #[tokio::test]
1844    async fn test_source_manager_sync_local_repo() {
1845        use std::process::Command;
1846
1847        // In coverage/CI environments, current dir might not exist, so set a safe one first
1848
1849        let temp_dir = TempDir::new().unwrap();
1850        let cache_dir = temp_dir.path().join("cache");
1851        let repo_dir = temp_dir.path().join("repo");
1852
1853        // Create a local git repo
1854        std::fs::create_dir(&repo_dir).unwrap();
1855        Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
1856        Command::new("git")
1857            .args(["config", "user.email", "test@example.com"])
1858            .current_dir(&repo_dir)
1859            .output()
1860            .unwrap();
1861        Command::new("git")
1862            .args(["config", "user.name", "Test User"])
1863            .current_dir(&repo_dir)
1864            .output()
1865            .unwrap();
1866        std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
1867        Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
1868        Command::new("git")
1869            .args(["commit", "-m", "Initial commit"])
1870            .current_dir(&repo_dir)
1871            .output()
1872            .unwrap();
1873
1874        let mut manager = SourceManager::new_with_cache(cache_dir.clone());
1875        let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
1876        manager.add(source).unwrap();
1877
1878        // First sync (clone)
1879        let result = manager.sync("test").await;
1880        assert!(result.is_ok());
1881        let repo = result.unwrap();
1882        assert!(repo.is_git_repo());
1883
1884        // Second sync (fetch + pull)
1885        let result = manager.sync("test").await;
1886        assert!(result.is_ok());
1887    }
1888
1889    #[tokio::test]
1890    async fn test_source_manager_sync_all() {
1891        use std::process::Command;
1892
1893        // In coverage/CI environments, current dir might not exist, so set a safe one first
1894
1895        let temp_dir = TempDir::new().unwrap();
1896        let cache_dir = temp_dir.path().join("cache");
1897
1898        // Create two local git repos
1899        let repo1_dir = temp_dir.path().join("repo1");
1900        let repo2_dir = temp_dir.path().join("repo2");
1901
1902        for repo_dir in &[&repo1_dir, &repo2_dir] {
1903            std::fs::create_dir(repo_dir).unwrap();
1904            Command::new("git").args(["init"]).current_dir(repo_dir).output().unwrap();
1905            Command::new("git")
1906                .args(["config", "user.email", "test@example.com"])
1907                .current_dir(repo_dir)
1908                .output()
1909                .unwrap();
1910            Command::new("git")
1911                .args(["config", "user.name", "Test User"])
1912                .current_dir(repo_dir)
1913                .output()
1914                .unwrap();
1915            std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
1916            Command::new("git").args(["add", "."]).current_dir(repo_dir).output().unwrap();
1917            Command::new("git")
1918                .args(["commit", "-m", "Initial commit"])
1919                .current_dir(repo_dir)
1920                .output()
1921                .unwrap();
1922        }
1923
1924        let mut manager = SourceManager::new_with_cache(cache_dir.clone());
1925
1926        manager
1927            .add(Source::new("repo1".to_string(), format!("file://{}", repo1_dir.display())))
1928            .unwrap();
1929
1930        manager
1931            .add(Source::new("repo2".to_string(), format!("file://{}", repo2_dir.display())))
1932            .unwrap();
1933
1934        // Sync all
1935        let result = manager.sync_all().await;
1936        assert!(result.is_ok());
1937
1938        // Verify both repos were cloned
1939        let source1_cache = manager.get("repo1").unwrap().cache_dir(&cache_dir);
1940        let source2_cache = manager.get("repo2").unwrap().cache_dir(&cache_dir);
1941        assert!(source1_cache.exists());
1942        assert!(source2_cache.exists());
1943    }
1944
1945    // Additional error path tests
1946
1947    #[tokio::test]
1948    async fn test_sync_non_existent_local_path() {
1949        let temp_dir = TempDir::new().unwrap();
1950        let cache_dir = temp_dir.path().join("cache");
1951        let mut manager = SourceManager::new_with_cache(cache_dir);
1952
1953        let source = Source::new("test".to_string(), "/non/existent/path".to_string());
1954        manager.add(source).unwrap();
1955
1956        let result = manager.sync("test").await;
1957        assert!(result.is_err());
1958        assert!(result.unwrap_err().to_string().contains("does not exist"));
1959    }
1960
1961    #[tokio::test]
1962    async fn test_sync_non_git_directory() {
1963        let temp_dir = TempDir::new().unwrap();
1964        let cache_dir = temp_dir.path().join("cache");
1965        let non_git_dir = temp_dir.path().join("not_git");
1966        std::fs::create_dir(&non_git_dir).unwrap();
1967
1968        let mut manager = SourceManager::new_with_cache(cache_dir);
1969        let source = Source::new("test".to_string(), non_git_dir.to_str().unwrap().to_string());
1970        manager.add(source).unwrap();
1971
1972        // Local paths are now treated as plain directories, so sync should succeed
1973        let result = manager.sync("test").await;
1974        if let Err(ref e) = result {
1975            eprintln!("Test failed with error: {e}");
1976            eprintln!("Path was: {non_git_dir:?}");
1977        }
1978        assert!(result.is_ok(), "Failed to sync: {result:?}");
1979        let repo = result.unwrap();
1980        // Should point to the canonicalized local directory
1981        assert_eq!(repo.path(), crate::utils::safe_canonicalize(&non_git_dir).unwrap());
1982    }
1983
1984    #[tokio::test]
1985    async fn test_sync_invalid_cache_directory() {
1986        use std::process::Command;
1987
1988        // Ensure stable test environment
1989        // In coverage/CI environments, current dir might not exist, so set a safe one first
1990
1991        let temp_dir = TempDir::new().unwrap();
1992        let cache_dir = temp_dir.path().join("cache");
1993        let repo_dir = temp_dir.path().join("repo");
1994
1995        // Create a valid git repo
1996        std::fs::create_dir(&repo_dir).unwrap();
1997        Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
1998        Command::new("git")
1999            .args(["config", "user.email", "test@example.com"])
2000            .current_dir(&repo_dir)
2001            .output()
2002            .unwrap();
2003        Command::new("git")
2004            .args(["config", "user.name", "Test User"])
2005            .current_dir(&repo_dir)
2006            .output()
2007            .unwrap();
2008        std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
2009        Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
2010        Command::new("git")
2011            .args(["commit", "-m", "Initial"])
2012            .current_dir(&repo_dir)
2013            .output()
2014            .unwrap();
2015
2016        let mut manager = SourceManager::new_with_cache(cache_dir.clone());
2017        let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
2018        manager.add(source).unwrap();
2019
2020        // Create an invalid cache directory (not a git repo)
2021        let source_cache_dir = manager.get("test").unwrap().cache_dir(&cache_dir);
2022        std::fs::create_dir_all(&source_cache_dir).unwrap();
2023        std::fs::write(source_cache_dir.join("file.txt"), "not a git repo").unwrap();
2024
2025        // Sync should detect invalid cache and re-clone
2026        let result = manager.sync("test").await;
2027        assert!(result.is_ok());
2028        assert!(crate::git::is_git_repository(&source_cache_dir));
2029    }
2030
2031    #[tokio::test]
2032    async fn test_sync_by_url_invalid_url() {
2033        let temp_dir = TempDir::new().unwrap();
2034        let cache_dir = temp_dir.path().join("cache");
2035        let manager = SourceManager::new_with_cache(cache_dir);
2036
2037        let result = manager.sync_by_url("not-a-valid-url").await;
2038        assert!(result.is_err());
2039    }
2040
2041    #[tokio::test]
2042    async fn test_sync_multiple_by_url_empty() {
2043        let temp_dir = TempDir::new().unwrap();
2044        let cache_dir = temp_dir.path().join("cache");
2045        let manager = SourceManager::new_with_cache(cache_dir);
2046
2047        let result = manager.sync_multiple_by_url(&[]).await;
2048        assert!(result.is_ok());
2049        assert_eq!(result.unwrap().len(), 0);
2050    }
2051
2052    #[tokio::test]
2053    async fn test_sync_multiple_by_url_with_failures() {
2054        use std::process::Command;
2055
2056        let temp_dir = TempDir::new().unwrap();
2057        let cache_dir = temp_dir.path().join("cache");
2058        let repo_dir = temp_dir.path().join("repo");
2059
2060        // Create one valid repo
2061        std::fs::create_dir(&repo_dir).unwrap();
2062        Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
2063        Command::new("git")
2064            .args(["config", "user.email", "test@example.com"])
2065            .current_dir(&repo_dir)
2066            .output()
2067            .unwrap();
2068        Command::new("git")
2069            .args(["config", "user.name", "Test User"])
2070            .current_dir(&repo_dir)
2071            .output()
2072            .unwrap();
2073        std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
2074        Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
2075        Command::new("git")
2076            .args(["commit", "-m", "Initial"])
2077            .current_dir(&repo_dir)
2078            .output()
2079            .unwrap();
2080
2081        let manager = SourceManager::new_with_cache(cache_dir);
2082
2083        let urls = vec![format!("file://{}", repo_dir.display()), "invalid-url".to_string()];
2084
2085        // Should fail on invalid URL
2086        let result = manager.sync_multiple_by_url(&urls).await;
2087        assert!(result.is_err());
2088    }
2089
2090    #[tokio::test]
2091    async fn test_get_cached_path_not_found() {
2092        let temp_dir = TempDir::new().unwrap();
2093        let cache_dir = temp_dir.path().join("cache");
2094        let manager = SourceManager::new_with_cache(cache_dir);
2095
2096        let result = manager.get_cached_path("https://unknown/url.git");
2097        assert!(result.is_err());
2098        // Just check that it returns an error - the message format may vary
2099    }
2100
2101    #[tokio::test]
2102    async fn test_get_cached_path_by_name_not_found() {
2103        let temp_dir = TempDir::new().unwrap();
2104        let cache_dir = temp_dir.path().join("cache");
2105        let manager = SourceManager::new_with_cache(cache_dir);
2106
2107        let result = manager.get_cached_path_by_name("nonexistent");
2108        assert!(result.is_err());
2109        // Just check that it returns an error - the message format may vary
2110    }
2111
2112    #[tokio::test]
2113    async fn test_verify_all_no_sources() {
2114        let temp_dir = TempDir::new().unwrap();
2115        let cache_dir = temp_dir.path().join("cache");
2116        let manager = SourceManager::new_with_cache(cache_dir);
2117
2118        let result = manager.verify_all().await;
2119        assert!(result.is_ok());
2120    }
2121
2122    #[tokio::test]
2123    async fn test_verify_all_with_disabled_sources() {
2124        let temp_dir = TempDir::new().unwrap();
2125        let cache_dir = temp_dir.path().join("cache");
2126        let mut manager = SourceManager::new_with_cache(cache_dir);
2127
2128        // Add but disable a source
2129        let source =
2130            Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
2131        manager.add(source).unwrap();
2132        manager.disable("test").unwrap();
2133
2134        // Verify should skip disabled sources
2135        let result = manager.verify_all().await;
2136        assert!(result.is_ok());
2137    }
2138
2139    #[tokio::test]
2140    async fn test_verify_source_file_url_not_exist() {
2141        let temp_dir = TempDir::new().unwrap();
2142        let cache_dir = temp_dir.path().join("cache");
2143        let manager = SourceManager::new_with_cache(cache_dir);
2144
2145        let result = manager.verify_source("file:///non/existent/path").await;
2146        assert!(result.is_err());
2147        assert!(result.unwrap_err().to_string().contains("does not exist"));
2148    }
2149
2150    #[tokio::test]
2151    async fn test_verify_source_invalid_remote() {
2152        let temp_dir = TempDir::new().unwrap();
2153        let cache_dir = temp_dir.path().join("cache");
2154        let manager = SourceManager::new_with_cache(cache_dir);
2155
2156        let result = manager.verify_source("https://invalid-host-9999.test/repo.git").await;
2157        assert!(result.is_err());
2158        assert!(result.unwrap_err().to_string().contains("not accessible"));
2159    }
2160
2161    #[tokio::test]
2162    async fn test_remove_with_cache_cleanup() {
2163        let temp_dir = TempDir::new().unwrap();
2164        let cache_dir = temp_dir.path().join("cache");
2165        let mut manager = SourceManager::new_with_cache(cache_dir.clone());
2166
2167        let source =
2168            Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
2169        manager.add(source).unwrap();
2170
2171        // Create cache directory
2172        let source_cache = cache_dir.join("sources").join("test");
2173        std::fs::create_dir_all(&source_cache).unwrap();
2174        std::fs::write(source_cache.join("file.txt"), "cached").unwrap();
2175        assert!(source_cache.exists());
2176
2177        // Remove should clean up cache
2178        manager.remove("test").await.unwrap();
2179        assert!(!source_cache.exists());
2180    }
2181
2182    #[tokio::test]
2183    async fn test_get_source_url() {
2184        let temp_dir = TempDir::new().unwrap();
2185        let cache_dir = temp_dir.path().join("cache");
2186        let mut manager = SourceManager::new_with_cache(cache_dir);
2187
2188        let source =
2189            Source::new("test".to_string(), "https://github.com/test/repo.git".to_string());
2190        manager.add(source).unwrap();
2191
2192        let url = manager.get_source_url("test");
2193        assert_eq!(url, Some("https://github.com/test/repo.git".to_string()));
2194
2195        let url = manager.get_source_url("nonexistent");
2196        assert_eq!(url, None);
2197    }
2198
2199    #[test]
2200    fn test_source_with_description() {
2201        let source =
2202            Source::new("test".to_string(), "https://github.com/test/repo.git".to_string())
2203                .with_description("Test description".to_string());
2204
2205        assert_eq!(source.description, Some("Test description".to_string()));
2206    }
2207
2208    #[tokio::test]
2209    async fn test_sync_with_progress() {
2210        use std::process::Command;
2211
2212        // Ensure stable test environment
2213        // In coverage/CI environments, current dir might not exist, so set a safe one first
2214
2215        let temp_dir = TempDir::new().unwrap();
2216        let cache_dir = temp_dir.path().join("cache");
2217        let repo_dir = temp_dir.path().join("repo");
2218
2219        // Create a git repo
2220        std::fs::create_dir(&repo_dir).unwrap();
2221        Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
2222        Command::new("git")
2223            .args(["config", "user.email", "test@example.com"])
2224            .current_dir(&repo_dir)
2225            .output()
2226            .unwrap();
2227        Command::new("git")
2228            .args(["config", "user.name", "Test User"])
2229            .current_dir(&repo_dir)
2230            .output()
2231            .unwrap();
2232        std::fs::write(repo_dir.join("README.md"), "Test").unwrap();
2233        Command::new("git").args(["add", "."]).current_dir(&repo_dir).output().unwrap();
2234        Command::new("git")
2235            .args(["commit", "-m", "Initial"])
2236            .current_dir(&repo_dir)
2237            .output()
2238            .unwrap();
2239
2240        let mut manager = SourceManager::new_with_cache(cache_dir);
2241        let source = Source::new("test".to_string(), format!("file://{}", repo_dir.display()));
2242        manager.add(source).unwrap();
2243
2244        let result = manager.sync("test").await;
2245        assert!(result.is_ok());
2246    }
2247
2248    #[tokio::test]
2249    async fn test_from_manifest_with_global() {
2250        let manifest = Manifest::new();
2251        let result = SourceManager::from_manifest_with_global(&manifest).await;
2252        assert!(result.is_ok());
2253    }
2254
2255    #[test]
2256    fn test_new_source_manager() {
2257        let result = SourceManager::new();
2258        // May fail if cache dir can't be created, but should handle gracefully
2259        if let Ok(manager) = result {
2260            assert!(manager.sources.is_empty());
2261        }
2262    }
2263
2264    #[tokio::test]
2265    async fn test_sync_local_path_directory() {
2266        // Test that local paths (not file:// URLs) are treated as plain directories
2267        let temp_dir = TempDir::new().unwrap();
2268        let cache_dir = temp_dir.path().join("cache");
2269        let local_dir = temp_dir.path().join("local_deps");
2270
2271        // Create a plain directory with some files (not a git repo)
2272        std::fs::create_dir(&local_dir).unwrap();
2273        std::fs::write(local_dir.join("agent.md"), "# Test Agent").unwrap();
2274        std::fs::write(local_dir.join("snippet.md"), "# Test Snippet").unwrap();
2275
2276        let mut manager = SourceManager::new_with_cache(cache_dir.clone());
2277
2278        // Add source with local path
2279        let source = Source::new("local".to_string(), local_dir.to_string_lossy().to_string());
2280        manager.add(source).unwrap();
2281
2282        // Sync should work with plain directory (not require git)
2283        let result = manager.sync("local").await;
2284        assert!(result.is_ok());
2285
2286        let repo = result.unwrap();
2287        // The returned GitRepo should point to the canonicalized local directory
2288        // On macOS, /var is a symlink to /private/var, so we need to compare canonical paths
2289        assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
2290    }
2291
2292    #[tokio::test]
2293    async fn test_sync_by_url_local_path() {
2294        // Test sync_by_url with local paths
2295        let temp_dir = TempDir::new().unwrap();
2296        let cache_dir = temp_dir.path().join("cache");
2297        let local_dir = temp_dir.path().join("local_deps");
2298
2299        // Create a plain directory with files
2300        std::fs::create_dir(&local_dir).unwrap();
2301        std::fs::write(local_dir.join("test.md"), "# Test Resource").unwrap();
2302
2303        let manager = SourceManager::new_with_cache(cache_dir);
2304
2305        // Test absolute path
2306        let result = manager.sync_by_url(&local_dir.to_string_lossy()).await;
2307        assert!(result.is_ok());
2308        let repo = result.unwrap();
2309        assert_eq!(repo.path(), crate::utils::safe_canonicalize(&local_dir).unwrap());
2310
2311        // Test relative path
2312        {
2313            // In coverage/CI environments, current dir might not exist, so set a safe one first
2314            let result = manager.sync_by_url("./local_deps").await;
2315            assert!(result.is_ok());
2316            // Guard will restore directory when dropped
2317        }
2318    }
2319
2320    #[tokio::test]
2321    async fn test_sync_local_path_not_exist() {
2322        let temp_dir = TempDir::new().unwrap();
2323        let cache_dir = temp_dir.path().join("cache");
2324        let manager = SourceManager::new_with_cache(cache_dir);
2325
2326        // Try to sync non-existent local path
2327        let result = manager.sync_by_url("/non/existent/path").await;
2328        assert!(result.is_err());
2329        assert!(result.unwrap_err().to_string().contains("does not exist"));
2330    }
2331
2332    #[tokio::test]
2333    async fn test_file_url_requires_git() {
2334        // Test that file:// URLs require valid git repositories
2335        let temp_dir = TempDir::new().unwrap();
2336        let cache_dir = temp_dir.path().join("cache");
2337        let plain_dir = temp_dir.path().join("plain_dir");
2338
2339        // Create a plain directory (not a git repo)
2340        std::fs::create_dir(&plain_dir).unwrap();
2341        std::fs::write(plain_dir.join("test.md"), "# Test").unwrap();
2342
2343        let manager = SourceManager::new_with_cache(cache_dir);
2344
2345        // file:// URL should fail for non-git directory
2346        let file_url = format!("file://{}", plain_dir.display());
2347        let result = manager.sync_by_url(&file_url).await;
2348        assert!(result.is_err());
2349        assert!(result.unwrap_err().to_string().contains("not a git repository"));
2350    }
2351
2352    #[tokio::test]
2353    async fn test_path_traversal_attack_prevention() {
2354        // Test that access to blacklisted system directories is prevented
2355        let temp_dir = TempDir::new().unwrap();
2356        let cache_dir = temp_dir.path().join("cache");
2357
2358        let manager = SourceManager::new_with_cache(cache_dir.clone());
2359
2360        // Test that blacklisted system paths are blocked
2361        let blacklisted_paths = vec!["/etc/passwd", "/System/Library", "/private/etc/hosts"];
2362
2363        for malicious_path in blacklisted_paths {
2364            // Skip if path doesn't exist (e.g., /System on Linux)
2365            if !std::path::Path::new(malicious_path).exists() {
2366                continue;
2367            }
2368
2369            let result = manager.sync_by_url(malicious_path).await;
2370            assert!(result.is_err(), "Blacklisted path not detected for: {malicious_path}");
2371            let err_msg = result.unwrap_err().to_string();
2372            assert!(
2373                err_msg.contains("Security error") || err_msg.contains("not allowed"),
2374                "Expected security error for blacklisted path: {malicious_path}, got: {err_msg}"
2375            );
2376        }
2377
2378        // Test that normal paths in temp directories work fine
2379        let safe_dir = temp_dir.path().join("safe_dir");
2380        std::fs::create_dir(&safe_dir).unwrap();
2381
2382        let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
2383        assert!(result.is_ok(), "Safe path was incorrectly blocked: {result:?}");
2384    }
2385
2386    #[cfg(unix)]
2387    #[tokio::test]
2388    async fn test_symlink_attack_prevention() {
2389        // Test that symlink attacks are prevented
2390        let temp_dir = TempDir::new().unwrap();
2391        let cache_dir = temp_dir.path().join("cache");
2392        let project_dir = temp_dir.path().join("project");
2393        let deps_dir = project_dir.join("deps");
2394        let sensitive_dir = temp_dir.path().join("sensitive");
2395
2396        // Create directories
2397        std::fs::create_dir(&project_dir).unwrap();
2398        std::fs::create_dir(&deps_dir).unwrap();
2399        std::fs::create_dir(&sensitive_dir).unwrap();
2400        std::fs::write(sensitive_dir.join("secret.txt"), "secret data").unwrap();
2401
2402        // Create a symlink pointing to sensitive directory
2403        use std::os::unix::fs::symlink;
2404        let symlink_path = deps_dir.join("malicious_link");
2405        symlink(&sensitive_dir, &symlink_path).unwrap();
2406
2407        let manager = SourceManager::new_with_cache(cache_dir);
2408
2409        // Try to access the symlink directly as a local path
2410        let result = manager.sync_by_url(symlink_path.to_str().unwrap()).await;
2411        assert!(result.is_err());
2412        let err_msg = result.unwrap_err().to_string();
2413        assert!(
2414            err_msg.contains("Symlinks are not allowed") || err_msg.contains("Security error"),
2415            "Expected symlink error, got: {err_msg}"
2416        );
2417    }
2418
2419    #[tokio::test]
2420    async fn test_absolute_path_restriction() {
2421        // Test that blacklisted absolute paths are blocked
2422        let temp_dir = TempDir::new().unwrap();
2423        let cache_dir = temp_dir.path().join("cache");
2424
2425        let manager = SourceManager::new_with_cache(cache_dir);
2426
2427        // With blacklist approach, temp directories are allowed
2428        // So this test verifies that normal development paths work
2429        let safe_dir = temp_dir.path().join("project");
2430        std::fs::create_dir(&safe_dir).unwrap();
2431        std::fs::write(safe_dir.join("file.txt"), "content").unwrap();
2432
2433        let result = manager.sync_by_url(&safe_dir.to_string_lossy()).await;
2434
2435        // Temp directories should work fine with blacklist approach
2436        assert!(result.is_ok(), "Safe temp path was incorrectly blocked: {result:?}");
2437    }
2438
2439    #[test]
2440    fn test_error_message_sanitization() {
2441        // Test that error messages don't leak sensitive path information
2442        // This is a compile-time test to ensure error messages are properly sanitized
2443
2444        // Check that we're not including full paths in error messages
2445        let error_msg = "Local path is not accessible or does not exist";
2446        assert!(!error_msg.contains("/home"));
2447        assert!(!error_msg.contains("/Users"));
2448        assert!(!error_msg.contains("C:\\"));
2449
2450        let security_msg =
2451            "Security error: Local path must be within the project directory or AGPM cache";
2452        assert!(!security_msg.contains("{:?}"));
2453        assert!(!security_msg.contains("{}"));
2454    }
2455}