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#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
30pub struct Profile {
31 pub root_path: String,
35
36 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#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
81pub struct Repository {
82 pub name: String,
84
85 pub url: String,
87
88 pub desktop_integration: Option<bool>,
91
92 pub pubkey: Option<String>,
94
95 pub enabled: Option<bool>,
98
99 pub signature_verification: Option<bool>,
102
103 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#[derive(Clone, Deserialize, Serialize, Documented, DocumentedFields)]
145pub struct Config {
146 pub default_profile: String,
148
149 pub profile: HashMap<String, Profile>,
151
152 pub repositories: Vec<Repository>,
154
155 pub cache_path: Option<String>,
158
159 pub db_path: Option<String>,
162
163 pub bin_path: Option<String>,
166
167 pub repositories_path: Option<String>,
170
171 pub portable_dirs: Option<String>,
174
175 pub parallel: Option<bool>,
178
179 pub parallel_limit: Option<u32>,
182
183 pub ghcr_concurrency: Option<u64>,
186
187 pub search_limit: Option<usize>,
190
191 pub cross_repo_updates: Option<bool>,
194
195 pub install_patterns: Option<Vec<String>>,
198
199 pub signature_verification: Option<bool>,
201
202 pub desktop_integration: Option<bool>,
204
205 pub sync_interval: Option<String>,
207
208 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 if !repo_info.platforms.contains(¤t_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("{}", ¤t_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 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 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 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}