agpm_cli/config/
global.rs

1//! Global configuration management for AGPM.
2//!
3//! This module handles the global user configuration file (`~/.agpm/config.toml`) which stores
4//! user-wide settings including authentication tokens for private repositories. The global
5//! configuration provides a secure way to manage credentials without exposing them in
6//! version-controlled project files.
7//!
8//! # Security Model
9//!
10//! The global configuration is designed with security as a primary concern:
11//!
12//! - **Credential Isolation**: Authentication tokens are stored only in the global config
13//! - **Never Committed**: Global config is never part of version control
14//! - **User-Specific**: Each user maintains their own global configuration
15//! - **Platform-Secure**: Uses platform-appropriate secure storage locations
16//!
17//! # Configuration File Location
18//!
19//! The global configuration file is stored in platform-specific locations:
20//!
21//! - **Unix/macOS**: `~/.agpm/config.toml`
22//! - **Windows**: `%LOCALAPPDATA%\agpm\config.toml`
23//!
24//! The location can be overridden using the `AGPM_CONFIG_PATH` environment variable.
25//!
26//! # File Format
27//!
28//! The global configuration uses TOML format:
29//!
30//! ```toml
31//! # Global sources with authentication (never commit this file!)
32//! [sources]
33//! # GitHub with personal access token
34//! private = "https://oauth2:ghp_xxxxxxxxxxxx@github.com/company/private-agpm.git"
35//!
36//! # GitLab with deploy token
37//! enterprise = "https://gitlab-ci-token:token123@gitlab.company.com/ai/resources.git"
38//!
39//! # SSH-based authentication
40//! internal = "git@internal.company.com:team/agpm-resources.git"
41//!
42//! # Basic authentication (not recommended)
43//! legacy = "https://username:password@old-server.com/repo.git"
44//! ```
45//!
46//! # Authentication Methods
47//!
48//! Supported authentication methods for Git repositories:
49//!
50//! ## GitHub Personal Access Token (Recommended)
51//! ```text
52//! https://oauth2:ghp_xxxxxxxxxxxx@github.com/owner/repo.git
53//! ```
54//!
55//! ## GitLab Deploy Token
56//! ```text
57//! https://gitlab-ci-token:token@gitlab.com/group/repo.git
58//! ```
59//!
60//! ## SSH Keys
61//! ```text
62//! git@github.com:owner/repo.git
63//! ```
64//!
65//! ## Basic Authentication (Not Recommended)
66//! ```text
67//! https://username:password@server.com/repo.git
68//! ```
69//!
70//! # Source Resolution Priority
71//!
72//! When resolving sources, AGPM follows this priority order:
73//!
74//! 1. **Global sources** from `~/.agpm/config.toml` (loaded first)
75//! 2. **Project sources** from `agpm.toml` (can override global sources)
76//!
77//! This allows teams to share public sources in `agpm.toml` while keeping
78//! authentication tokens private in individual global configurations.
79//!
80//! # Examples
81//!
82//! ## Basic Usage
83//!
84//! ```rust,no_run
85//! use agpm_cli::config::GlobalConfig;
86//!
87//! # async fn example() -> anyhow::Result<()> {
88//! // Load existing configuration or create default
89//! let mut config = GlobalConfig::load().await?;
90//!
91//! // Add authenticated source
92//! config.add_source(
93//!     "private".to_string(),
94//!     "https://oauth2:token@github.com/company/repo.git".to_string()
95//! );
96//!
97//! // Save changes
98//! config.save().await?;
99//! # Ok(())
100//! # }
101//! ```
102//!
103//! ## Using Configuration Manager
104//!
105//! ```rust,no_run
106//! use agpm_cli::config::GlobalConfigManager;
107//!
108//! # async fn example() -> anyhow::Result<()> {
109//! let mut manager = GlobalConfigManager::new()?;
110//!
111//! // Get configuration with caching
112//! let config = manager.get().await?;
113//! println!("Found {} global sources", config.sources.len());
114//!
115//! // Modify configuration
116//! let config = manager.get_mut().await?;
117//! config.add_source("new".to_string(), "https://example.com/repo.git".to_string());
118//!
119//! // Save changes
120//! manager.save().await?;
121//! # Ok(())
122//! # }
123//! ```
124
125use crate::upgrade::config::UpgradeConfig;
126use anyhow::{Context, Result};
127use serde::{Deserialize, Serialize};
128use std::collections::HashMap;
129use std::path::{Path, PathBuf};
130use tokio::fs;
131
132/// Global configuration structure for AGPM.
133///
134/// This structure represents the global user configuration file stored at `~/.agpm/config.toml`.
135/// It contains user-wide settings including authentication credentials for private Git repositories.
136///
137/// # Security Considerations
138///
139/// - **Never commit** this configuration to version control
140/// - Store **only** in the user's home directory or secure location
141/// - Contains **sensitive data** like authentication tokens
142/// - Should have **restricted file permissions** (600 on Unix systems)
143///
144/// # Structure
145///
146/// Currently contains only source definitions, but designed to be extensible
147/// for future configuration options like:
148/// - Default author information
149/// - Preferred Git configuration
150/// - Cache settings
151/// - Proxy configuration
152///
153/// # Examples
154///
155/// ```rust,no_run
156/// use agpm_cli::config::GlobalConfig;
157/// use std::collections::HashMap;
158///
159/// // Create new configuration
160/// let mut config = GlobalConfig::default();
161///
162/// // Add authenticated source
163/// config.add_source(
164///     "company".to_string(),
165///     "https://oauth2:token@github.com/company/agpm.git".to_string()
166/// );
167///
168/// assert!(config.has_source("company"));
169/// ```
170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
171pub struct GlobalConfig {
172    /// Global Git repository sources with optional authentication.
173    ///
174    /// Maps source names to Git repository URLs. These URLs may contain authentication
175    /// credentials and are kept separate from project manifests for security.
176    ///
177    /// # Authentication URL Formats
178    ///
179    /// - `https://oauth2:token@github.com/owner/repo.git` - GitHub personal access token
180    /// - `https://gitlab-ci-token:token@gitlab.com/group/repo.git` - GitLab deploy token
181    /// - `git@github.com:owner/repo.git` - SSH authentication
182    /// - `https://user:pass@server.com/repo.git` - Basic auth (not recommended)
183    ///
184    /// # Security Notes
185    ///
186    /// - URLs with credentials are **never** logged in plain text
187    /// - The `sources` field is skipped during serialization if empty
188    /// - Authentication details should use tokens rather than passwords when possible
189    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190    pub sources: HashMap<String, String>,
191
192    /// Upgrade configuration settings.
193    ///
194    /// Controls the behavior of the self-upgrade functionality including
195    /// update checks, backup preferences, and verification settings.
196    #[serde(default, skip_serializing_if = "is_default_upgrade_config")]
197    pub upgrade: UpgradeConfig,
198}
199
200const fn is_default_upgrade_config(config: &UpgradeConfig) -> bool {
201    // Skip serializing if it's the default config
202    !config.check_on_startup
203        && config.check_interval == 86400
204        && config.auto_backup
205        && config.verify_checksum
206}
207
208impl GlobalConfig {
209    /// Load global configuration from the default platform-specific location.
210    ///
211    /// Attempts to load the configuration file from the default path. If the file
212    /// doesn't exist, returns a default (empty) configuration instead of an error.
213    ///
214    /// # Default Locations
215    ///
216    /// - **Unix/macOS**: `~/.agpm/config.toml`
217    /// - **Windows**: `%LOCALAPPDATA%\agpm\config.toml`
218    /// - **Override**: Set `AGPM_CONFIG_PATH` environment variable
219    ///
220    /// # Examples
221    ///
222    /// ```rust,no_run
223    /// use agpm_cli::config::GlobalConfig;
224    ///
225    /// # async fn example() -> anyhow::Result<()> {
226    /// let config = GlobalConfig::load().await?;
227    /// println!("Loaded {} global sources", config.sources.len());
228    /// # Ok(())
229    /// # }
230    /// ```
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if:
235    /// - The default path cannot be determined
236    /// - The file exists but cannot be read
237    /// - The file contains invalid TOML syntax
238    pub async fn load() -> Result<Self> {
239        let path = Self::default_path()?;
240        if path.exists() {
241            Self::load_from(&path).await
242        } else {
243            Ok(Self::default())
244        }
245    }
246
247    /// Load global configuration from an optional path.
248    ///
249    /// If a path is provided, loads from that path. Otherwise, loads from the
250    /// default location (`~/.agpm/config.toml` or platform equivalent).
251    ///
252    /// # Parameters
253    ///
254    /// - `path`: Optional path to the configuration file
255    ///
256    /// # Returns
257    ///
258    /// Returns the loaded configuration or a default configuration if the file
259    /// doesn't exist.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if:
264    /// - The file exists but cannot be read
265    /// - The file contains invalid TOML syntax
266    pub async fn load_with_optional(path: Option<PathBuf>) -> Result<Self> {
267        let path = path.unwrap_or_else(|| {
268            Self::default_path().unwrap_or_else(|_| PathBuf::from("~/.agpm/config.toml"))
269        });
270        if path.exists() {
271            Self::load_from(&path).await
272        } else {
273            Ok(Self::default())
274        }
275    }
276
277    /// Load global configuration from a specific file path.
278    ///
279    /// This method is primarily used for testing or when a custom configuration
280    /// location is needed.
281    ///
282    /// # Parameters
283    ///
284    /// - `path`: Path to the configuration file to load
285    ///
286    /// # Examples
287    ///
288    /// ```rust,no_run
289    /// use agpm_cli::config::GlobalConfig;
290    /// use std::path::Path;
291    ///
292    /// # async fn example() -> anyhow::Result<()> {
293    /// let config = GlobalConfig::load_from(Path::new("/custom/config.toml")).await?;
294    /// # Ok(())
295    /// # }
296    /// ```
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if:
301    /// - The file cannot be read (permissions, not found, etc.)
302    /// - The file contains invalid TOML syntax
303    /// - The TOML structure doesn't match the expected schema
304    pub async fn load_from(path: &Path) -> Result<Self> {
305        let content = fs::read_to_string(path)
306            .await
307            .with_context(|| format!("Failed to read global config from {}", path.display()))?;
308
309        toml::from_str(&content)
310            .with_context(|| format!("Failed to parse global config from {}", path.display()))
311    }
312
313    /// Save global configuration to the default platform-specific location.
314    ///
315    /// Saves the current configuration to the default path, creating parent
316    /// directories as needed. The file is written atomically to prevent
317    /// corruption during the write process.
318    ///
319    /// # File Permissions
320    ///
321    /// The configuration file should be created with restricted permissions
322    /// since it may contain sensitive authentication tokens.
323    ///
324    /// # Examples
325    ///
326    /// ```rust,no_run
327    /// use agpm_cli::config::GlobalConfig;
328    ///
329    /// # async fn example() -> anyhow::Result<()> {
330    /// let mut config = GlobalConfig::load().await?;
331    /// config.add_source(
332    ///     "new".to_string(),
333    ///     "https://github.com/owner/repo.git".to_string()
334    /// );
335    /// config.save().await?;
336    /// # Ok(())
337    /// # }
338    /// ```
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if:
343    /// - The default path cannot be determined
344    /// - Parent directories cannot be created
345    /// - The file cannot be written (permissions, disk space, etc.)
346    /// - Serialization to TOML fails
347    pub async fn save(&self) -> Result<()> {
348        let path = Self::default_path()?;
349        self.save_to(&path).await
350    }
351
352    /// Save global configuration to a specific file path.
353    ///
354    /// Creates parent directories as needed and writes the configuration
355    /// as pretty-formatted TOML.
356    ///
357    /// # Parameters
358    ///
359    /// - `path`: Path where the configuration file should be saved
360    ///
361    /// # Examples
362    ///
363    /// ```rust,no_run
364    /// use agpm_cli::config::GlobalConfig;
365    /// use std::path::Path;
366    ///
367    /// # async fn example() -> anyhow::Result<()> {
368    /// let config = GlobalConfig::default();
369    /// config.save_to(Path::new("/tmp/test-config.toml")).await?;
370    /// # Ok(())
371    /// # }
372    /// ```
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if:
377    /// - Parent directories cannot be created
378    /// - The file cannot be written
379    /// - Serialization to TOML fails
380    pub async fn save_to(&self, path: &Path) -> Result<()> {
381        // Ensure parent directory exists
382        if let Some(parent) = path.parent() {
383            fs::create_dir_all(parent).await.with_context(|| {
384                format!("Failed to create config directory: {}", parent.display())
385            })?;
386        }
387
388        let content = toml::to_string_pretty(self).context("Failed to serialize global config")?;
389
390        fs::write(path, content)
391            .await
392            .with_context(|| format!("Failed to write global config to {}", path.display()))?;
393
394        // Set restrictive permissions on Unix systems to protect credentials
395        #[cfg(unix)]
396        {
397            use std::os::unix::fs::PermissionsExt;
398            use tokio::fs as async_fs;
399
400            let mut perms = async_fs::metadata(path)
401                .await
402                .with_context(|| format!("Failed to read permissions for {}", path.display()))?
403                .permissions();
404            perms.set_mode(0o600); // Owner read/write only, no group/other access
405            async_fs::set_permissions(path, perms).await.with_context(|| {
406                format!("Failed to set secure permissions on {}", path.display())
407            })?;
408        }
409
410        Ok(())
411    }
412
413    /// Get the default file path for global configuration.
414    ///
415    /// Returns the platform-appropriate path for storing global configuration.
416    /// This location is chosen to be secure and follow platform conventions.
417    ///
418    /// # Path Resolution
419    ///
420    /// - **Windows**: `%LOCALAPPDATA%\agpm\config.toml`
421    /// - **Unix/macOS**: `~/.agpm/config.toml`
422    ///
423    /// Note: Environment variable overrides are deprecated. Use the `load_with_optional()`
424    /// method with an explicit path instead for better thread safety.
425    ///
426    /// # Examples
427    ///
428    /// ```rust,no_run
429    /// use agpm_cli::config::GlobalConfig;
430    ///
431    /// # fn example() -> anyhow::Result<()> {
432    /// let path = GlobalConfig::default_path()?;
433    /// println!("Global config path: {}", path.display());
434    /// # Ok(())
435    /// # }
436    /// ```
437    ///
438    /// # Errors
439    ///
440    /// Returns an error if:
441    /// - The home directory cannot be determined
442    /// - The local data directory cannot be determined (Windows)
443    pub fn default_path() -> Result<PathBuf> {
444        let config_dir = if cfg!(target_os = "windows") {
445            dirs::data_local_dir()
446                .ok_or_else(|| anyhow::anyhow!("Unable to determine local data directory"))?
447                .join("agpm")
448        } else {
449            dirs::home_dir()
450                .ok_or_else(|| anyhow::anyhow!("Unable to determine home directory"))?
451                .join(".agpm")
452        };
453
454        Ok(config_dir.join("config.toml"))
455    }
456
457    /// Merge global sources with project manifest sources.
458    ///
459    /// Combines the global configuration sources with sources from a project manifest,
460    /// with project sources taking precedence over global sources. This allows users
461    /// to maintain private authentication in global config while projects can override
462    /// with public sources.
463    ///
464    /// # Merge Strategy
465    ///
466    /// 1. Start with all global sources (may include authentication)
467    /// 2. Add/override with local sources from project manifest
468    /// 3. Local sources win in case of name conflicts
469    ///
470    /// # Parameters
471    ///
472    /// - `local_sources`: Sources from project manifest (`agpm.toml`)
473    ///
474    /// # Returns
475    ///
476    /// Combined source map with local sources taking precedence.
477    ///
478    /// # Examples
479    ///
480    /// ```rust,no_run
481    /// use agpm_cli::config::GlobalConfig;
482    /// use std::collections::HashMap;
483    ///
484    /// let mut global = GlobalConfig::default();
485    /// global.add_source(
486    ///     "private".to_string(),
487    ///     "https://token@private.com/repo.git".to_string()
488    /// );
489    ///
490    /// let mut local = HashMap::new();
491    /// local.insert(
492    ///     "public".to_string(),
493    ///     "https://github.com/public/repo.git".to_string()
494    /// );
495    ///
496    /// let merged = global.merge_sources(&local);
497    /// assert_eq!(merged.len(), 2);
498    /// assert!(merged.contains_key("private"));
499    /// assert!(merged.contains_key("public"));
500    /// ```
501    ///
502    /// # Security Note
503    ///
504    /// The merged result may contain authentication credentials from global sources.
505    /// Handle with care and never log or expose in version control.
506    #[must_use]
507    pub fn merge_sources(
508        &self,
509        local_sources: &HashMap<String, String>,
510    ) -> HashMap<String, String> {
511        let mut merged = self.sources.clone();
512
513        // Local sources override global ones
514        for (name, url) in local_sources {
515            merged.insert(name.clone(), url.clone());
516        }
517
518        merged
519    }
520
521    /// Add or update a source in the global configuration.
522    ///
523    /// Adds a new source or updates an existing one with the given name and URL.
524    /// The URL may contain authentication credentials.
525    ///
526    /// # Parameters
527    ///
528    /// - `name`: Unique name for the source (used in manifests)
529    /// - `url`: Git repository URL, optionally with authentication
530    ///
531    /// # Examples
532    ///
533    /// ```rust,no_run
534    /// use agpm_cli::config::GlobalConfig;
535    ///
536    /// let mut config = GlobalConfig::default();
537    ///
538    /// // Add source with authentication
539    /// config.add_source(
540    ///     "private".to_string(),
541    ///     "https://oauth2:token@github.com/company/repo.git".to_string()
542    /// );
543    ///
544    /// // Update existing source
545    /// config.add_source(
546    ///     "private".to_string(),
547    ///     "git@github.com:company/repo.git".to_string()
548    /// );
549    ///
550    /// assert!(config.has_source("private"));
551    /// ```
552    ///
553    /// # Security Note
554    ///
555    /// URLs containing credentials should use tokens rather than passwords when possible.
556    pub fn add_source(&mut self, name: String, url: String) {
557        self.sources.insert(name, url);
558    }
559
560    /// Remove a source from the global configuration.
561    ///
562    /// Removes the source with the given name if it exists.
563    ///
564    /// # Parameters
565    ///
566    /// - `name`: Name of the source to remove
567    ///
568    /// # Returns
569    ///
570    /// - `true` if the source was found and removed
571    /// - `false` if the source didn't exist
572    ///
573    /// # Examples
574    ///
575    /// ```rust,no_run
576    /// use agpm_cli::config::GlobalConfig;
577    ///
578    /// let mut config = GlobalConfig::default();
579    /// config.add_source("test".to_string(), "https://example.com/repo.git".to_string());
580    ///
581    /// assert!(config.remove_source("test"));
582    /// assert!(!config.remove_source("test")); // Already removed
583    /// ```
584    pub fn remove_source(&mut self, name: &str) -> bool {
585        self.sources.remove(name).is_some()
586    }
587
588    /// Check if a source exists in the global configuration.
589    ///
590    /// # Parameters
591    ///
592    /// - `name`: Name of the source to check
593    ///
594    /// # Returns
595    ///
596    /// - `true` if the source exists
597    /// - `false` if the source doesn't exist
598    ///
599    /// # Examples
600    ///
601    /// ```rust,no_run
602    /// use agpm_cli::config::GlobalConfig;
603    ///
604    /// let mut config = GlobalConfig::default();
605    /// assert!(!config.has_source("test"));
606    ///
607    /// config.add_source("test".to_string(), "https://example.com/repo.git".to_string());
608    /// assert!(config.has_source("test"));
609    /// ```
610    #[must_use]
611    pub fn has_source(&self, name: &str) -> bool {
612        self.sources.contains_key(name)
613    }
614
615    /// Get a source URL by name.
616    ///
617    /// Returns a reference to the URL for the specified source name.
618    ///
619    /// # Parameters
620    ///
621    /// - `name`: Name of the source to retrieve
622    ///
623    /// # Returns
624    ///
625    /// - `Some(&String)` with the URL if the source exists
626    /// - `None` if the source doesn't exist
627    ///
628    /// # Examples
629    ///
630    /// ```rust,no_run
631    /// use agpm_cli::config::GlobalConfig;
632    ///
633    /// let mut config = GlobalConfig::default();
634    /// config.add_source(
635    ///     "test".to_string(),
636    ///     "https://example.com/repo.git".to_string()
637    /// );
638    ///
639    /// assert_eq!(
640    ///     config.get_source("test"),
641    ///     Some(&"https://example.com/repo.git".to_string())
642    /// );
643    /// assert_eq!(config.get_source("missing"), None);
644    /// ```
645    ///
646    /// # Security Note
647    ///
648    /// The returned URL may contain authentication credentials. Handle with care.
649    #[must_use]
650    pub fn get_source(&self, name: &str) -> Option<&String> {
651        self.sources.get(name)
652    }
653
654    /// Create a global configuration with example content.
655    ///
656    /// Creates a new configuration populated with example sources to demonstrate
657    /// the expected format. Useful for initial setup or documentation.
658    ///
659    /// # Returns
660    ///
661    /// A new [`GlobalConfig`] with example private source configuration.
662    ///
663    /// # Examples
664    ///
665    /// ```rust,no_run
666    /// use agpm_cli::config::GlobalConfig;
667    ///
668    /// let config = GlobalConfig::init_example();
669    /// assert!(config.has_source("private"));
670    ///
671    /// // The example uses a placeholder token
672    /// let url = config.get_source("private").unwrap();
673    /// assert!(url.contains("YOUR_TOKEN"));
674    /// ```
675    ///
676    /// # Note
677    ///
678    /// The example configuration contains placeholder values that must be
679    /// replaced with actual authentication credentials before use.
680    #[must_use]
681    pub fn init_example() -> Self {
682        let mut sources = HashMap::new();
683        sources.insert(
684            "private".to_string(),
685            "https://oauth2:YOUR_TOKEN@github.com/yourcompany/private-agpm.git".to_string(),
686        );
687
688        Self {
689            sources,
690            upgrade: UpgradeConfig::default(),
691        }
692    }
693}
694
695/// Configuration manager with caching for global configuration.
696///
697/// Provides a higher-level interface for working with global configuration
698/// that includes caching to avoid repeated file I/O operations. This is
699/// particularly useful in command-line applications that may access
700/// configuration multiple times.
701///
702/// # Features
703///
704/// - **Lazy Loading**: Configuration is loaded only when first accessed
705/// - **Caching**: Subsequent accesses use the cached configuration
706/// - **Reload Support**: Can reload from disk when needed
707/// - **Custom Paths**: Supports custom configuration file paths for testing
708///
709/// # Examples
710///
711/// ## Basic Usage
712///
713/// ```rust,no_run
714/// use agpm_cli::config::GlobalConfigManager;
715///
716/// # async fn example() -> anyhow::Result<()> {
717/// let mut manager = GlobalConfigManager::new()?;
718///
719/// // First access loads from disk
720/// let config = manager.get().await?;
721/// println!("Found {} sources", config.sources.len());
722///
723/// // Subsequent accesses use cache
724/// let config2 = manager.get().await?;
725/// # Ok(())
726/// # }
727/// ```
728///
729/// ## Modifying Configuration
730///
731/// ```rust,no_run
732/// use agpm_cli::config::GlobalConfigManager;
733///
734/// # async fn example() -> anyhow::Result<()> {
735/// let mut manager = GlobalConfigManager::new()?;
736///
737/// // Get mutable reference
738/// let config = manager.get_mut().await?;
739/// config.add_source(
740///     "new".to_string(),
741///     "https://github.com/owner/repo.git".to_string()
742/// );
743///
744/// // Save changes to disk
745/// manager.save().await?;
746/// # Ok(())
747/// # }
748/// ```
749pub struct GlobalConfigManager {
750    config: Option<GlobalConfig>,
751    path: PathBuf,
752}
753
754impl GlobalConfigManager {
755    /// Create a new configuration manager using the default global config path.
756    ///
757    /// The manager will use the platform-appropriate default location for
758    /// the global configuration file.
759    ///
760    /// # Examples
761    ///
762    /// ```rust,no_run
763    /// use agpm_cli::config::GlobalConfigManager;
764    ///
765    /// # fn example() -> anyhow::Result<()> {
766    /// let manager = GlobalConfigManager::new()?;
767    /// # Ok(())
768    /// # }
769    /// ```
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if the default configuration path cannot be determined.
774    pub fn new() -> Result<Self> {
775        Ok(Self {
776            config: None,
777            path: GlobalConfig::default_path()?,
778        })
779    }
780
781    /// Create a configuration manager with a custom file path.
782    ///
783    /// This method is primarily useful for testing or when you need to
784    /// use a non-standard location for the configuration file.
785    ///
786    /// # Parameters
787    ///
788    /// - `path`: Custom path for the configuration file
789    ///
790    /// # Examples
791    ///
792    /// ```rust,no_run
793    /// use agpm_cli::config::GlobalConfigManager;
794    /// use std::path::PathBuf;
795    ///
796    /// let manager = GlobalConfigManager::with_path(PathBuf::from("/tmp/test.toml"));
797    /// ```
798    #[must_use]
799    pub const fn with_path(path: PathBuf) -> Self {
800        Self {
801            config: None,
802            path,
803        }
804    }
805
806    /// Get a reference to the global configuration, loading it if necessary.
807    ///
808    /// If the configuration hasn't been loaded yet, this method will load it
809    /// from disk. Subsequent calls will return the cached configuration.
810    ///
811    /// # Returns
812    ///
813    /// A reference to the cached [`GlobalConfig`].
814    ///
815    /// # Examples
816    ///
817    /// ```rust,no_run
818    /// use agpm_cli::config::GlobalConfigManager;
819    ///
820    /// # async fn example() -> anyhow::Result<()> {
821    /// let mut manager = GlobalConfigManager::new()?;
822    /// let config = manager.get().await?;
823    /// println!("Global config has {} sources", config.sources.len());
824    /// # Ok(())
825    /// # }
826    /// ```
827    ///
828    /// # Errors
829    ///
830    /// Returns an error if:
831    /// - The configuration file exists but cannot be read
832    /// - The configuration file contains invalid TOML syntax
833    pub async fn get(&mut self) -> Result<&GlobalConfig> {
834        if self.config.is_none() {
835            self.config = Some(if self.path.exists() {
836                GlobalConfig::load_from(&self.path).await?
837            } else {
838                GlobalConfig::default()
839            });
840        }
841
842        Ok(self.config.as_ref().unwrap())
843    }
844
845    /// Get a mutable reference to the global configuration, loading it if necessary.
846    ///
847    /// Similar to [`get`](Self::get), but returns a mutable reference allowing
848    /// modification of the configuration.
849    ///
850    /// # Returns
851    ///
852    /// A mutable reference to the cached [`GlobalConfig`].
853    ///
854    /// # Examples
855    ///
856    /// ```rust,no_run
857    /// use agpm_cli::config::GlobalConfigManager;
858    ///
859    /// # async fn example() -> anyhow::Result<()> {
860    /// let mut manager = GlobalConfigManager::new()?;
861    /// let config = manager.get_mut().await?;
862    ///
863    /// config.add_source(
864    ///     "new".to_string(),
865    ///     "https://github.com/owner/repo.git".to_string()
866    /// );
867    /// # Ok(())
868    /// # }
869    /// ```
870    ///
871    /// # Errors
872    ///
873    /// Returns an error if:
874    /// - The configuration file exists but cannot be read
875    /// - The configuration file contains invalid TOML syntax
876    pub async fn get_mut(&mut self) -> Result<&mut GlobalConfig> {
877        if self.config.is_none() {
878            self.config = Some(if self.path.exists() {
879                GlobalConfig::load_from(&self.path).await?
880            } else {
881                GlobalConfig::default()
882            });
883        }
884
885        Ok(self.config.as_mut().unwrap())
886    }
887
888    /// Save the current cached configuration to disk.
889    ///
890    /// Writes the current configuration state to the file system. If no
891    /// configuration has been loaded, this method does nothing.
892    ///
893    /// # Examples
894    ///
895    /// ```rust,no_run
896    /// use agpm_cli::config::GlobalConfigManager;
897    ///
898    /// # async fn example() -> anyhow::Result<()> {
899    /// let mut manager = GlobalConfigManager::new()?;
900    ///
901    /// // Modify configuration
902    /// let config = manager.get_mut().await?;
903    /// config.add_source("test".to_string(), "https://test.com/repo.git".to_string());
904    ///
905    /// // Save changes
906    /// manager.save().await?;
907    /// # Ok(())
908    /// # }
909    /// ```
910    ///
911    /// # Errors
912    ///
913    /// Returns an error if:
914    /// - The file cannot be written (permissions, disk space, etc.)
915    /// - Parent directories cannot be created
916    pub async fn save(&self) -> Result<()> {
917        if let Some(config) = &self.config {
918            config.save_to(&self.path).await?;
919        }
920        Ok(())
921    }
922
923    /// Reload the configuration from disk, discarding cached data.
924    ///
925    /// Forces a reload of the configuration from the file system, discarding
926    /// any cached data. Useful when the configuration file may have been
927    /// modified externally.
928    ///
929    /// # Examples
930    ///
931    /// ```rust,no_run
932    /// use agpm_cli::config::GlobalConfigManager;
933    ///
934    /// # async fn example() -> anyhow::Result<()> {
935    /// let mut manager = GlobalConfigManager::new()?;
936    ///
937    /// // Load initial configuration
938    /// let config1 = manager.get().await?;
939    /// let count1 = config1.sources.len();
940    ///
941    /// // Configuration file modified externally...
942    ///
943    /// // Reload to pick up external changes
944    /// manager.reload().await?;
945    /// let config2 = manager.get().await?;
946    /// let count2 = config2.sources.len();
947    /// # Ok(())
948    /// # }
949    /// ```
950    ///
951    /// # Errors
952    ///
953    /// Returns an error if:
954    /// - The configuration file exists but cannot be read
955    /// - The configuration file contains invalid TOML syntax
956    pub async fn reload(&mut self) -> Result<()> {
957        self.config = Some(if self.path.exists() {
958            GlobalConfig::load_from(&self.path).await?
959        } else {
960            GlobalConfig::default()
961        });
962        Ok(())
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use tempfile::TempDir;
970
971    #[tokio::test]
972    async fn test_global_config_default() {
973        let config = GlobalConfig::default();
974        assert!(config.sources.is_empty());
975    }
976
977    #[tokio::test]
978    async fn test_global_config_save_load() {
979        let temp = TempDir::new().unwrap();
980        let config_path = temp.path().join("config.toml");
981
982        let mut config = GlobalConfig::default();
983        config.add_source("test".to_string(), "https://example.com/repo.git".to_string());
984
985        config.save_to(&config_path).await.unwrap();
986
987        let loaded = GlobalConfig::load_from(&config_path).await.unwrap();
988        assert_eq!(loaded.sources.len(), 1);
989        assert_eq!(loaded.get_source("test"), Some(&"https://example.com/repo.git".to_string()));
990    }
991
992    #[tokio::test]
993    async fn test_merge_sources() {
994        let mut global = GlobalConfig::default();
995        global.add_source("private".to_string(), "https://token@private.com/repo.git".to_string());
996        global.add_source("shared".to_string(), "https://shared.com/repo.git".to_string());
997
998        let mut local = HashMap::new();
999        local.insert("shared".to_string(), "https://override.com/repo.git".to_string());
1000        local.insert("public".to_string(), "https://public.com/repo.git".to_string());
1001
1002        let merged = global.merge_sources(&local);
1003
1004        // Global source preserved
1005        assert_eq!(merged.get("private"), Some(&"https://token@private.com/repo.git".to_string()));
1006
1007        // Local override wins
1008        assert_eq!(merged.get("shared"), Some(&"https://override.com/repo.git".to_string()));
1009
1010        // Local-only source included
1011        assert_eq!(merged.get("public"), Some(&"https://public.com/repo.git".to_string()));
1012    }
1013
1014    #[tokio::test]
1015    async fn test_source_operations() {
1016        let mut config = GlobalConfig::default();
1017
1018        // Add source
1019        config.add_source("test".to_string(), "https://test.com/repo.git".to_string());
1020        assert!(config.has_source("test"));
1021        assert_eq!(config.get_source("test"), Some(&"https://test.com/repo.git".to_string()));
1022
1023        // Update source
1024        config.add_source("test".to_string(), "https://updated.com/repo.git".to_string());
1025        assert_eq!(config.get_source("test"), Some(&"https://updated.com/repo.git".to_string()));
1026
1027        // Remove source
1028        assert!(config.remove_source("test"));
1029        assert!(!config.has_source("test"));
1030        assert!(!config.remove_source("test")); // Already removed
1031    }
1032
1033    #[tokio::test]
1034    async fn test_init_example() {
1035        let config = GlobalConfig::init_example();
1036
1037        assert!(config.has_source("private"));
1038        assert_eq!(
1039            config.get_source("private"),
1040            Some(&"https://oauth2:YOUR_TOKEN@github.com/yourcompany/private-agpm.git".to_string())
1041        );
1042    }
1043
1044    #[tokio::test]
1045    #[cfg(unix)]
1046    async fn test_config_file_permissions() {
1047        use std::os::unix::fs::PermissionsExt;
1048        let temp_dir = tempfile::tempdir().unwrap();
1049        let config_path = temp_dir.path().join("test-config.toml");
1050
1051        // Create and save config
1052        let config = GlobalConfig::default();
1053        config.save_to(&config_path).await.unwrap();
1054
1055        // Check permissions
1056        let metadata = tokio::fs::metadata(&config_path).await.unwrap();
1057        let permissions = metadata.permissions();
1058        let mode = permissions.mode() & 0o777; // Get only permission bits
1059
1060        assert_eq!(mode, 0o600, "Config file should have 600 permissions");
1061    }
1062
1063    #[tokio::test]
1064    async fn test_config_manager() {
1065        let temp = TempDir::new().unwrap();
1066        let config_path = temp.path().join("config.toml");
1067
1068        let mut manager = GlobalConfigManager::with_path(config_path.clone());
1069
1070        // Get config (should create default)
1071        let config = manager.get_mut().await.unwrap();
1072        config.add_source("test".to_string(), "https://test.com/repo.git".to_string());
1073
1074        // Save
1075        manager.save().await.unwrap();
1076
1077        // Create new manager and verify it loads
1078        let mut manager2 = GlobalConfigManager::with_path(config_path);
1079        let config2 = manager2.get().await.unwrap();
1080        assert!(config2.has_source("test"));
1081    }
1082}