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}