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