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}