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