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}