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