soar_core/
config.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::PathBuf,
5    sync::{LazyLock, RwLock},
6};
7
8use documented::{Documented, DocumentedFields};
9use serde::{de::Error, Deserialize, Serialize};
10use toml_edit::{DocumentMut, Item};
11use tracing::{info, warn};
12
13use crate::{
14    database::migration,
15    error::{ConfigError, SoarError},
16    repositories::get_platform_repositories,
17    toml::{annotate_toml_array_of_tables, annotate_toml_table},
18    utils::{
19        build_path, default_install_patterns, get_platform, home_config_path, home_data_path,
20        parse_duration,
21    },
22    SoarResult,
23};
24use rusqlite::Connection;
25
26type Result<T> = std::result::Result<T, ConfigError>;
27
28/// A profile defines a local package store and its configuration.
29#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
30pub struct Profile {
31    /// Root directory for this profile’s data and packages.
32    ///
33    /// If `packages_path` is not set, packages will be stored in `root_path/packages`.
34    pub root_path: String,
35
36    /// Optional path where packages are stored.
37    ///
38    /// If unset, defaults to `root_path/packages`.
39    pub packages_path: Option<String>,
40}
41
42impl Profile {
43    fn get_bin_path(&self) -> SoarResult<PathBuf> {
44        Ok(self.get_root_path()?.join("bin"))
45    }
46
47    fn get_db_path(&self) -> SoarResult<PathBuf> {
48        Ok(self.get_root_path()?.join("db"))
49    }
50
51    pub fn get_packages_path(&self) -> SoarResult<PathBuf> {
52        if let Some(ref packages_path) = self.packages_path {
53            build_path(packages_path)
54        } else {
55            Ok(self.get_root_path()?.join("packages"))
56        }
57    }
58
59    pub fn get_cache_path(&self) -> SoarResult<PathBuf> {
60        Ok(self.get_root_path()?.join("cache"))
61    }
62
63    fn get_repositories_path(&self) -> SoarResult<PathBuf> {
64        Ok(self.get_root_path()?.join("repos"))
65    }
66
67    fn get_portable_dirs(&self) -> SoarResult<PathBuf> {
68        Ok(self.get_root_path()?.join("portable-dirs"))
69    }
70
71    pub fn get_root_path(&self) -> SoarResult<PathBuf> {
72        if let Ok(env_path) = std::env::var("SOAR_ROOT") {
73            return build_path(&env_path);
74        }
75        build_path(&self.root_path)
76    }
77}
78
79/// Defines a remote repository that provides packages.
80#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
81pub struct Repository {
82    /// Unique name of the repository.
83    pub name: String,
84
85    /// URL to the repository's metadata file.
86    pub url: String,
87
88    /// Enables desktop integration for packages from this repository.
89    /// Default: false
90    pub desktop_integration: Option<bool>,
91
92    /// URL to the repository's public key (for signature verification).
93    pub pubkey: Option<String>,
94
95    /// Whether the repository is enabled.
96    /// Default: true
97    pub enabled: Option<bool>,
98
99    /// Enables signature verification for this repository.
100    /// Default is derived based on the existence of `pubkey`
101    pub signature_verification: Option<bool>,
102
103    /// Optional sync interval (e.g., "1h", "12h", "1d").
104    /// Default: "3h"
105    pub sync_interval: Option<String>,
106}
107
108impl Repository {
109    pub fn get_path(&self) -> std::result::Result<PathBuf, SoarError> {
110        Ok(get_config().get_repositories_path()?.join(&self.name))
111    }
112
113    pub fn is_enabled(&self) -> bool {
114        self.enabled.unwrap_or(true)
115    }
116
117    pub fn signature_verification(&self) -> bool {
118        if let Some(global_override) = get_config().signature_verification {
119            return global_override;
120        }
121        if self.pubkey.is_none() {
122            return false;
123        };
124        self.signature_verification.unwrap_or(true)
125    }
126
127    pub fn sync_interval(&self) -> u128 {
128        match get_config()
129            .sync_interval
130            .clone()
131            .or(self.sync_interval.clone())
132            .as_deref()
133            .unwrap_or("3h")
134        {
135            "always" => 0,
136            "never" => u128::MAX,
137            "auto" => 3 * 3_600_000,
138            value => parse_duration(value).unwrap_or(3_600_000),
139        }
140    }
141}
142
143/// Application's configuration
144#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
145pub struct Config {
146    /// The name of the default profile to use.
147    pub default_profile: String,
148
149    /// A map of profile names to their configurations.
150    pub profile: HashMap<String, Profile>,
151
152    /// List of configured repositories.
153    pub repositories: Vec<Repository>,
154
155    /// Path to the local cache directory.
156    /// Default: $SOAR_ROOT/cache
157    pub cache_path: Option<String>,
158
159    /// Path where the Soar package database is stored.
160    /// Default: $SOAR_ROOT/db
161    pub db_path: Option<String>,
162
163    /// Directory where binary symlinks are placed.
164    /// Default: $SOAR_ROOT/bin
165    pub bin_path: Option<String>,
166
167    /// Path to the local clone of all repositories.
168    /// Default: $SOAR_ROOT/packages
169    pub repositories_path: Option<String>,
170
171    /// Portable dirs path
172    /// Default: $SOAR_ROOT/portable-dirs
173    pub portable_dirs: Option<String>,
174
175    /// If true, enables parallel downloading of packages.
176    /// Default: true
177    pub parallel: Option<bool>,
178
179    /// Maximum number of parallel downloads.
180    /// Default: 4
181    pub parallel_limit: Option<u32>,
182
183    /// Maximum number of concurrent requests for GHCR (GitHub Container Registry).
184    /// Default: 8
185    pub ghcr_concurrency: Option<u64>,
186
187    /// Limits the number of results returned by a search.
188    /// Default: 20
189    pub search_limit: Option<usize>,
190
191    /// Allows packages to be updated across different repositories.
192    /// NOTE: This is not yet implemented
193    pub cross_repo_updates: Option<bool>,
194
195    /// Glob patterns for package files that should be included during install.
196    /// Default: ["!*.log", "!SBUILD", "!*.json", "!*.version"]
197    pub install_patterns: Option<Vec<String>>,
198
199    /// Global override for signature verification
200    pub signature_verification: Option<bool>,
201
202    /// Global override for desktop integration
203    pub desktop_integration: Option<bool>,
204
205    /// Global override for sync interval
206    pub sync_interval: Option<String>,
207
208    /// Sync interval for nests
209    pub nests_sync_interval: Option<String>,
210}
211
212pub static CONFIG: LazyLock<RwLock<Option<Config>>> = LazyLock::new(|| RwLock::new(None));
213pub static CURRENT_PROFILE: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| RwLock::new(None));
214
215pub static CONFIG_PATH: LazyLock<RwLock<PathBuf>> = LazyLock::new(|| {
216    RwLock::new(match std::env::var("SOAR_CONFIG") {
217        Ok(path_str) => PathBuf::from(path_str),
218        Err(_) => PathBuf::from(home_config_path())
219            .join("soar")
220            .join("config.toml"),
221    })
222});
223
224pub fn init() -> Result<()> {
225    let config = Config::new()?;
226    let mut global_config = CONFIG.write().unwrap();
227    *global_config = Some(config);
228    Ok(())
229}
230
231fn ensure_config_initialized() {
232    let mut config_guard = CONFIG.write().unwrap();
233    if config_guard.is_none() {
234        *config_guard = Some(Config::default_config::<&str>(false, &[]));
235    }
236}
237
238pub fn get_config() -> Config {
239    {
240        let config_guard = CONFIG.read().unwrap();
241        if config_guard.is_some() {
242            drop(config_guard);
243            return CONFIG.read().unwrap().as_ref().unwrap().clone();
244        }
245    }
246
247    ensure_config_initialized();
248
249    CONFIG.read().unwrap().as_ref().unwrap().clone()
250}
251
252pub fn get_current_profile() -> String {
253    let current_profile = CURRENT_PROFILE.read().unwrap();
254    current_profile
255        .clone()
256        .unwrap_or_else(|| get_config().default_profile.clone())
257}
258
259pub fn set_current_profile(name: &str) -> Result<()> {
260    let config = get_config();
261    if !config.profile.contains_key(name) {
262        return Err(ConfigError::InvalidProfile(name.to_string()));
263    }
264    let mut profile = CURRENT_PROFILE.write().unwrap();
265    *profile = Some(name.to_string());
266    Ok(())
267}
268
269impl Config {
270    pub fn default_config<T: AsRef<str>>(external: bool, selected_repos: &[T]) -> Self {
271        let soar_root =
272            std::env::var("SOAR_ROOT").unwrap_or_else(|_| format!("{}/soar", home_data_path()));
273
274        let default_profile = Profile {
275            root_path: soar_root.clone(),
276            packages_path: Some(format!("{soar_root}/packages")),
277        };
278        let default_profile_name = "default".to_string();
279
280        let current_platform = get_platform();
281        let mut repositories = Vec::new();
282        let selected_set: HashSet<&str> = selected_repos.iter().map(|s| s.as_ref()).collect();
283
284        for repo_info in get_platform_repositories().into_iter() {
285            // Check if repository supports the current platform
286            if !repo_info.platforms.contains(&current_platform.as_str()) {
287                continue;
288            }
289
290            if repo_info.is_core || external || selected_set.contains(repo_info.name) {
291                repositories.push(Repository {
292                    name: repo_info.name.to_string(),
293                    url: repo_info.url_template.replace("{}", &current_platform),
294                    pubkey: repo_info.pubkey.map(String::from),
295                    desktop_integration: repo_info.desktop_integration,
296                    enabled: repo_info.enabled,
297                    signature_verification: repo_info.signature_verification,
298                    sync_interval: repo_info.sync_interval.map(String::from),
299                });
300            }
301        }
302
303        // Filter by selected repositories if specified
304        let repositories = if selected_repos.is_empty() {
305            repositories
306        } else {
307            repositories
308                .into_iter()
309                .filter(|repo| selected_set.contains(repo.name.as_str()))
310                .collect()
311        };
312
313        // Show warning if no repositories are available for this platform
314        if repositories.is_empty() {
315            if selected_repos.is_empty() {
316                warn!(
317                    "No official repositories available for {}. You can add custom repositories in your config file.",
318                    current_platform
319                );
320            } else {
321                warn!("No repositories enabled.");
322            }
323        }
324
325        Self {
326            profile: HashMap::from([(default_profile_name.clone(), default_profile)]),
327            default_profile: default_profile_name,
328
329            bin_path: Some(format!("{soar_root}/bin")),
330            cache_path: Some(format!("{soar_root}/cache")),
331            db_path: Some(format!("{soar_root}/db")),
332            repositories_path: Some(format!("{soar_root}/repos")),
333            portable_dirs: Some(format!("{soar_root}/portable-dirs")),
334
335            repositories,
336            parallel: Some(true),
337            parallel_limit: Some(4),
338            search_limit: Some(20),
339            ghcr_concurrency: Some(8),
340            cross_repo_updates: Some(false),
341            install_patterns: Some(default_install_patterns()),
342
343            signature_verification: None,
344            desktop_integration: None,
345            sync_interval: None,
346            nests_sync_interval: None,
347        }
348    }
349
350    /// Creates a new configuration by loading it from the configuration file.
351    /// If the configuration file is not found, it uses the default configuration.
352    pub fn new() -> Result<Self> {
353        if std::env::var("SOAR_STEALTH").is_ok() {
354            return Ok(Self::default_config::<&str>(false, &[]));
355        }
356
357        let config_path = CONFIG_PATH.read().unwrap().to_path_buf();
358
359        let mut config = match fs::read_to_string(&config_path) {
360            Ok(content) => match toml::from_str(&content) {
361                Ok(c) => Ok(c),
362                Err(err) => Err(ConfigError::TomlDeError(err)),
363            },
364            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
365                Ok(Self::default_config::<&str>(false, &[]))
366            }
367            Err(err) => Err(ConfigError::IoError(err)),
368        }?;
369
370        config.resolve()?;
371
372        Ok(config)
373    }
374
375    pub fn resolve(&mut self) -> Result<()> {
376        if !self.profile.contains_key(&self.default_profile) {
377            return Err(ConfigError::MissingDefaultProfile(
378                self.default_profile.clone(),
379            ));
380        }
381
382        if self.parallel.unwrap_or(true) {
383            self.parallel_limit.get_or_insert(4);
384        }
385
386        if self.install_patterns.is_none() {
387            self.install_patterns = Some(default_install_patterns());
388        }
389
390        self.ghcr_concurrency.get_or_insert(8);
391        self.search_limit.get_or_insert(20);
392        self.cross_repo_updates.get_or_insert(false);
393
394        let mut seen_repos = HashSet::new();
395
396        for repo in &mut self.repositories {
397            if repo.name == "local" {
398                return Err(ConfigError::ReservedRepositoryName);
399            }
400            if repo.name.starts_with("nest") {
401                return Err(ConfigError::Custom(
402                    "Repository name cannot start with `nest`".to_string(),
403                ));
404            }
405            if !seen_repos.insert(&repo.name) {
406                return Err(ConfigError::DuplicateRepositoryName(repo.name.clone()));
407            }
408
409            repo.enabled.get_or_insert(true);
410
411            if repo.desktop_integration.is_none() {
412                match repo.name.as_str() {
413                    "bincache" => repo.desktop_integration = Some(false),
414                    "pkgcache" | "ivan-hc-am" | "appimage.github.io" => {
415                        repo.desktop_integration = Some(true)
416                    }
417                    _ => {}
418                }
419            }
420
421            if repo.pubkey.is_none() {
422                match repo.name.as_str() {
423                    "bincache" => {
424                        repo.pubkey =
425                            Some("https://meta.pkgforge.dev/bincache/minisign.pub".to_string())
426                    }
427                    "pkgcache" => {
428                        repo.pubkey =
429                            Some("https://meta.pkgforge.dev/pkgcache/minisign.pub".to_string())
430                    }
431                    _ => {}
432                }
433            }
434        }
435
436        Ok(())
437    }
438
439    pub fn default_profile(&self) -> Result<&Profile> {
440        self.profile
441            .get(&self.default_profile)
442            .ok_or_else(|| unreachable!())
443    }
444
445    pub fn get_profile(&self, name: &str) -> Result<&Profile> {
446        self.profile
447            .get(name)
448            .ok_or(ConfigError::MissingProfile(name.to_string()))
449    }
450
451    pub fn get_bin_path(&self) -> SoarResult<PathBuf> {
452        if let Ok(env_path) = std::env::var("SOAR_BIN") {
453            return build_path(&env_path);
454        }
455        if let Some(bin_path) = &self.bin_path {
456            return build_path(bin_path);
457        }
458        self.default_profile()?.get_bin_path()
459    }
460
461    pub fn get_db_path(&self) -> SoarResult<PathBuf> {
462        if let Ok(env_path) = std::env::var("SOAR_DB") {
463            return build_path(&env_path);
464        }
465        if let Some(soar_db) = &self.db_path {
466            return build_path(soar_db);
467        }
468        self.default_profile()?.get_db_path()
469    }
470
471    pub fn get_packages_path(&self, profile_name: Option<String>) -> SoarResult<PathBuf> {
472        if let Ok(env_path) = std::env::var("SOAR_PACKAGES") {
473            return build_path(&env_path);
474        }
475        let profile_name = profile_name.unwrap_or_else(get_current_profile);
476        self.get_profile(&profile_name)?.get_packages_path()
477    }
478
479    pub fn get_cache_path(&self) -> SoarResult<PathBuf> {
480        if let Ok(env_path) = std::env::var("SOAR_CACHE") {
481            return build_path(&env_path);
482        }
483        if let Some(soar_cache) = &self.cache_path {
484            return build_path(soar_cache);
485        }
486        self.get_profile(&get_current_profile())?.get_cache_path()
487    }
488
489    pub fn get_repositories_path(&self) -> SoarResult<PathBuf> {
490        if let Ok(env_path) = std::env::var("SOAR_REPOSITORIES") {
491            return build_path(&env_path);
492        }
493        if let Some(repositories_path) = &self.repositories_path {
494            return build_path(repositories_path);
495        }
496        self.default_profile()?.get_repositories_path()
497    }
498
499    pub fn get_portable_dirs(&self) -> SoarResult<PathBuf> {
500        if let Ok(env_path) = std::env::var("SOAR_PORTABLE_DIRS") {
501            return build_path(&env_path);
502        }
503
504        if let Some(portable_dirs) = &self.portable_dirs {
505            return build_path(portable_dirs);
506        }
507        self.default_profile()?.get_portable_dirs()
508    }
509
510    pub fn get_nests_db_conn(&self) -> SoarResult<Connection> {
511        let path = self.get_db_path()?.join("nests.db");
512        let conn = Connection::open(&path)?;
513        migration::run_nests(conn)
514            .map_err(|e| SoarError::Custom(format!("creating nests migration: {}", e)))?;
515        let conn = Connection::open(&path)?;
516        Ok(conn)
517    }
518
519    pub fn get_nests_sync_interval(&self) -> u128 {
520        match get_config().nests_sync_interval.as_deref().unwrap_or("3h") {
521            "always" => 0,
522            "never" => u128::MAX,
523            "auto" => 3 * 3_600_000,
524            value => parse_duration(value).unwrap_or(3_600_000),
525        }
526    }
527
528    pub fn get_repository(&self, repo_name: &str) -> Option<&Repository> {
529        self.repositories
530            .iter()
531            .find(|repo| repo.name == repo_name && repo.is_enabled())
532    }
533
534    pub fn has_desktop_integration(&self, repo_name: &str) -> bool {
535        if let Some(global_override) = self.desktop_integration {
536            return global_override;
537        }
538        self.get_repository(repo_name)
539            .is_some_and(|repo| repo.desktop_integration.unwrap_or(false))
540    }
541
542    pub fn save(&self) -> Result<()> {
543        let config_path = CONFIG_PATH.read().unwrap().to_path_buf();
544        let serialized = toml::to_string_pretty(self)?;
545        if let Some(parent) = config_path.parent() {
546            fs::create_dir_all(parent)?;
547        }
548        fs::write(&config_path, serialized)?;
549        info!("Configuration saved to {}", config_path.display());
550        Ok(())
551    }
552
553    pub fn to_annotated_document(&self) -> Result<DocumentMut> {
554        let toml_string = toml::to_string_pretty(self).map_err(ConfigError::TomlSerError)?;
555        let mut doc = toml_string
556            .parse::<DocumentMut>()
557            .map_err(|e| ConfigError::TomlDeError(toml::de::Error::custom(e.to_string())))?;
558
559        annotate_toml_table::<Config>(doc.as_table_mut(), true)?;
560
561        if let Some(profiles_map_table_item) = doc.get_mut("profile") {
562            if let Some(profiles_map_table) = profiles_map_table_item.as_table_mut() {
563                for (_profile_name, profile_item) in profiles_map_table.iter_mut() {
564                    if let Item::Table(profile_table) = profile_item {
565                        annotate_toml_table::<Profile>(profile_table, false)?;
566                    }
567                }
568            }
569        }
570
571        if let Some(repositories_item) = doc.get_mut("repositories") {
572            if let Some(repositories_array) = repositories_item.as_array_of_tables_mut() {
573                annotate_toml_array_of_tables::<Repository>(repositories_array)?;
574            }
575        }
576
577        Ok(doc)
578    }
579}
580
581pub fn generate_default_config<T: AsRef<str>>(external: bool, repos: &[T]) -> Result<()> {
582    let config_path = CONFIG_PATH.read().unwrap().to_path_buf();
583
584    if config_path.exists() {
585        return Err(ConfigError::ConfigAlreadyExists);
586    }
587
588    fs::create_dir_all(config_path.parent().unwrap())?;
589
590    let def_config = Config::default_config(external, repos);
591    let annotated_doc = def_config.to_annotated_document()?;
592
593    if let Some(parent) = config_path.parent() {
594        fs::create_dir_all(parent)?;
595    }
596
597    fs::write(&config_path, annotated_doc.to_string())?;
598    info!(
599        "Default configuration file generated with documentation at: {}",
600        config_path.display()
601    );
602    Ok(())
603}