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