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}