1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{LazyLock, RwLock},
};
use documented::{Documented, DocumentedFields};
use serde::{Deserialize, Serialize};
use soar_utils::path::xdg_config_home;
use toml_edit::DocumentMut;
use tracing::info;
use crate::{
annotations::annotate_toml_table,
error::{ConfigError, Result},
};
/// Path to the packages configuration file
pub static PACKAGES_CONFIG_PATH: LazyLock<RwLock<PathBuf>> = LazyLock::new(|| {
RwLock::new(match std::env::var("SOAR_PACKAGES_CONFIG") {
Ok(path_str) => PathBuf::from(path_str),
Err(_) => xdg_config_home().join("soar").join("packages.toml"),
})
});
/// Declarative package configuration.
/// Defines the desired set of packages to be installed.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct PackagesConfig {
/// Default settings applied to all packages unless overridden.
pub defaults: Option<PackageDefaults>,
/// Map of package names to their specifications.
/// Supports both simple string form (version) and detailed table form.
#[serde(default)]
pub packages: HashMap<String, PackageSpec>,
}
/// Default settings for all packages.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct PackageDefaults {
/// Default profile to use for installations.
pub profile: Option<String>,
/// Whether to install binary only (exclude logs, desktop files, etc).
pub binary_only: Option<bool>,
/// Default install patterns.
pub install_patterns: Option<Vec<String>>,
/// Default sandbox configuration applied to all packages.
/// Per-package `sandbox` blocks override individual fields, and
/// `fs_read` / `fs_write` lists are concatenated with these defaults.
pub sandbox: Option<SandboxConfig>,
}
/// Flexible package specification.
/// Can be either a simple string (version) or a detailed options table.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PackageSpec {
/// Simple form: just a version string (e.g., "1.8.1" or "*" for latest)
Simple(String),
/// Detailed form: full options table
Detailed(Box<PackageOptions>),
}
impl Default for PackageSpec {
fn default() -> Self {
Self::Simple("*".to_string())
}
}
/// Binary mapping for custom symlink creation.
/// Maps a source executable path to a custom symlink name.
#[derive(Clone, Debug, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct BinaryMapping {
/// Path to the executable within the package (relative to install dir).
pub source: String,
/// Name for the symlink in the bin directory.
/// If not specified, uses the filename from source.
pub link_as: Option<String>,
}
/// Hook commands to run at various stages of package installation.
/// Commands are run with environment variables for paths.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct PackageHooks {
/// Command to run after the package is downloaded (before extraction).
pub post_download: Option<String>,
/// Command to run after the package is extracted.
pub post_extract: Option<String>,
/// Command to run after the package is fully installed (symlinks created).
pub post_install: Option<String>,
/// Command to run before the package is removed.
pub pre_remove: Option<String>,
}
/// Build configuration for compiling packages from source.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct BuildConfig {
/// Shell commands to run for building the package.
/// Commands are executed in sequence. If any command fails, the build stops.
/// Available environment variables: $INSTALL_DIR, $PKG_NAME, $PKG_VERSION, $NPROC
pub commands: Vec<String>,
/// Optional list of dependencies required for building.
/// Soar will warn if these are not found in PATH.
#[serde(default)]
pub dependencies: Vec<String>,
}
/// Sandbox configuration for restricting hook and build command execution.
/// Uses Landlock (Linux 5.13+) to restrict filesystem access.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct SandboxConfig {
/// Whether sandboxing is enabled. Defaults to `true` when Landlock is
/// available. Set to `false` to skip sandboxing entirely (overrides
/// `require`).
pub enabled: Option<bool>,
/// Require sandbox - fail if Landlock is not available instead of falling back
/// to unsandboxed execution. Use this for builds you don't trust to run unsandboxed.
pub require: Option<bool>,
/// Additional paths that can be read (beyond defaults like /usr, /lib, etc).
#[serde(default)]
pub fs_read: Vec<String>,
/// Additional paths that can be written (beyond install_dir and /tmp).
#[serde(default)]
pub fs_write: Vec<String>,
/// Whether to allow network access (requires Landlock V4+, kernel 6.7+).
pub network: Option<bool>,
}
impl SandboxConfig {
/// Whether the sandbox should be active. `None` means "yes, when Landlock
/// is available" — only an explicit `Some(false)` disables it.
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
/// Whether the sandbox is required (i.e. fail if Landlock is unavailable).
pub fn is_required(&self) -> bool {
self.require.unwrap_or(false)
}
/// Whether outbound network access is permitted.
pub fn allows_network(&self) -> bool {
self.network.unwrap_or(false)
}
/// Field-level merge: per-package fields override globals; path lists are
/// concatenated (globals first, then per-package).
pub fn merge(
global: Option<&SandboxConfig>,
package: Option<&SandboxConfig>,
) -> Option<SandboxConfig> {
match (global, package) {
(None, None) => None,
(Some(g), None) => Some(g.clone()),
(None, Some(p)) => Some(p.clone()),
(Some(g), Some(p)) => {
let mut fs_read = g.fs_read.clone();
fs_read.extend(p.fs_read.iter().cloned());
let mut fs_write = g.fs_write.clone();
fs_write.extend(p.fs_write.iter().cloned());
Some(SandboxConfig {
enabled: p.enabled.or(g.enabled),
require: p.require.or(g.require),
fs_read,
fs_write,
network: p.network.or(g.network),
})
}
}
}
}
/// Full package options for detailed specification.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct PackageOptions {
/// Specific package ID (for disambiguation when multiple packages share the same name).
pub pkg_id: Option<String>,
/// Specific version to install.
pub version: Option<String>,
/// Repository to install from.
pub repo: Option<String>,
/// Direct URL to download the package from (makes it a "local" package).
pub url: Option<String>,
/// Expected BLAKE3 checksum (hex) of the downloaded artifact, for `url`/`github`/
/// `gitlab` packages. When set, soar verifies the download against it and refuses to
/// install on mismatch.
/// Without it, these user-declared sources install on implicit trust.
/// Has no effect on registry packages, which already ship their own checksum.
pub bsum: Option<String>,
/// GitHub repository in "owner/repo" format for installing from releases.
/// When set, soar fetches the latest release and downloads the matching asset.
pub github: Option<String>,
/// GitLab repository in "owner/repo" format for installing from releases.
/// When set, soar fetches the latest release and downloads the matching asset.
pub gitlab: Option<String>,
/// Glob pattern to match release asset filename (e.g., "*linux*.AppImage").
/// Required when github/gitlab is set to select the correct asset.
pub asset_pattern: Option<String>,
/// Whether to include pre-release versions when using github/gitlab sources.
#[serde(default)]
pub include_prerelease: Option<bool>,
/// Glob pattern to match release tag names (e.g., "v*-stable", "nightly-*").
/// If not set, the first matching release is used.
pub tag_pattern: Option<String>,
/// Custom command to fetch version and download URL.
/// Output format: line 1 = version, line 2 = download URL, line 3 = size in bytes (optional).
/// If not set and github/gitlab is used, version is fetched from releases API.
pub version_command: Option<String>,
/// Package type for URL installs (e.g., appimage, flatimage, archive).
pub pkg_type: Option<String>,
/// Entrypoint executable name (for URL packages where the binary name differs from package name).
pub entrypoint: Option<String>,
/// Multiple binary mappings (source path -> symlink name).
/// Use this when a package provides multiple executables.
pub binaries: Option<Vec<BinaryMapping>>,
/// Path to a nested archive to extract after the main extraction.
/// Useful for packages that contain archives within archives.
pub nested_extract: Option<String>,
/// Subdirectory within the extracted archive to treat as the root.
/// Useful when archives have a versioned root folder like "app-v1.0/".
pub extract_root: Option<String>,
/// Hook commands to run at various stages of installation.
pub hooks: Option<PackageHooks>,
/// Build configuration for compiling from source.
pub build: Option<BuildConfig>,
/// Sandbox configuration for restricting hook and build command execution.
/// When set, commands are restricted to read/write only specific paths.
pub sandbox: Option<SandboxConfig>,
/// Whether to pin this package (prevents automatic updates).
#[serde(default)]
pub pinned: bool,
/// Profile to install to (overrides default).
pub profile: Option<String>,
/// Portable directory configuration.
pub portable: Option<PortableConfig>,
/// Custom install patterns (overrides default).
pub install_patterns: Option<Vec<String>>,
/// Whether to install binary only.
pub binary_only: Option<bool>,
/// Custom architecture name mapping.
/// Maps standard architecture names (e.g., "x86_64", "aarch64") to custom values
/// used by the package source (e.g., "amd64", "arm64", "x64").
pub arch_map: Option<HashMap<String, String>>,
}
/// Portable directory configuration for a package.
#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)]
pub struct PortableConfig {
/// Base portable path (sets portable_home and portable_config).
pub path: Option<String>,
/// Portable home directory.
pub home: Option<String>,
/// Portable config directory.
pub config: Option<String>,
/// Portable share directory.
pub share: Option<String>,
/// Portable cache directory.
pub cache: Option<String>,
}
/// Resolved package specification with name included.
#[derive(Clone, Debug, Default)]
pub struct ResolvedPackage {
pub name: String,
pub pkg_id: Option<String>,
pub version: Option<String>,
pub repo: Option<String>,
pub url: Option<String>,
pub bsum: Option<String>,
pub github: Option<String>,
pub gitlab: Option<String>,
pub asset_pattern: Option<String>,
pub include_prerelease: Option<bool>,
pub tag_pattern: Option<String>,
pub version_command: Option<String>,
pub pkg_type: Option<String>,
pub entrypoint: Option<String>,
pub binaries: Option<Vec<BinaryMapping>>,
pub nested_extract: Option<String>,
pub extract_root: Option<String>,
pub hooks: Option<PackageHooks>,
pub build: Option<BuildConfig>,
pub sandbox: Option<SandboxConfig>,
pub pinned: bool,
pub profile: Option<String>,
pub portable: Option<PortableConfig>,
pub install_patterns: Option<Vec<String>>,
pub binary_only: bool,
pub arch_map: Option<HashMap<String, String>>,
}
impl PackageSpec {
/// Resolve the package specification with defaults applied.
pub fn resolve(&self, name: &str, defaults: Option<&PackageDefaults>) -> ResolvedPackage {
match self {
PackageSpec::Simple(version_str) => {
let version = if version_str == "*" {
None
} else {
Some(version_str.clone())
};
let pinned = version.is_some();
ResolvedPackage {
name: name.to_string(),
pkg_id: None,
version,
repo: None,
url: None,
bsum: None,
github: None,
gitlab: None,
asset_pattern: None,
include_prerelease: None,
tag_pattern: None,
version_command: None,
pkg_type: None,
entrypoint: None,
binaries: None,
nested_extract: None,
extract_root: None,
hooks: None,
build: None,
sandbox: defaults.and_then(|d| d.sandbox.clone()),
pinned,
profile: defaults.and_then(|d| d.profile.clone()),
portable: None,
install_patterns: defaults.and_then(|d| d.install_patterns.clone()),
binary_only: defaults.and_then(|d| d.binary_only).unwrap_or(false),
arch_map: None,
}
}
PackageSpec::Detailed(opts) => {
// Treat "*" as None (latest version)
let version = opts.version.as_ref().filter(|v| v.as_str() != "*").cloned();
// URL/GitHub/GitLab packages: only pinned if explicitly set
// Other packages: pinned if explicitly set or if a specific version is requested
let is_remote =
opts.url.is_some() || opts.github.is_some() || opts.gitlab.is_some();
let pinned = opts.pinned || (version.is_some() && !is_remote);
ResolvedPackage {
name: name.to_string(),
pkg_id: opts.pkg_id.clone(),
version,
repo: opts.repo.clone(),
url: opts.url.clone(),
bsum: opts.bsum.clone(),
github: opts.github.clone(),
gitlab: opts.gitlab.clone(),
asset_pattern: opts.asset_pattern.clone(),
include_prerelease: opts.include_prerelease,
tag_pattern: opts.tag_pattern.clone(),
version_command: opts.version_command.clone(),
pkg_type: opts.pkg_type.clone(),
entrypoint: opts.entrypoint.clone(),
binaries: opts.binaries.clone(),
nested_extract: opts.nested_extract.clone(),
extract_root: opts.extract_root.clone(),
hooks: opts.hooks.clone(),
build: opts.build.clone(),
sandbox: SandboxConfig::merge(
defaults.and_then(|d| d.sandbox.as_ref()),
opts.sandbox.as_ref(),
),
pinned,
profile: opts
.profile
.clone()
.or_else(|| defaults.and_then(|d| d.profile.clone())),
portable: opts.portable.clone(),
install_patterns: opts
.install_patterns
.clone()
.or_else(|| defaults.and_then(|d| d.install_patterns.clone())),
binary_only: opts
.binary_only
.or_else(|| defaults.and_then(|d| d.binary_only))
.unwrap_or(false),
arch_map: opts.arch_map.clone(),
}
}
}
}
}
impl PackagesConfig {
/// Load packages configuration from file.
pub fn load(path: Option<&str>) -> Result<Self> {
let config_path = match path {
Some(p) => PathBuf::from(p),
None => PACKAGES_CONFIG_PATH.read().unwrap().clone(),
};
if !config_path.exists() {
return Err(ConfigError::PackagesConfigNotFound(
config_path.display().to_string(),
));
}
let content = fs::read_to_string(&config_path)?;
let config: PackagesConfig = toml::from_str(&content)?;
Ok(config)
}
/// Get all packages resolved with defaults applied.
pub fn resolved_packages(&self) -> Vec<ResolvedPackage> {
self.packages
.iter()
.map(|(name, spec): (&String, &PackageSpec)| spec.resolve(name, self.defaults.as_ref()))
.collect()
}
/// Create a default configuration.
pub fn default_config() -> Self {
Self {
defaults: Some(PackageDefaults {
profile: Some("default".to_string()),
binary_only: Some(false),
install_patterns: None,
sandbox: None,
}),
packages: HashMap::new(),
}
}
/// Convert config to an annotated TOML document with field documentation.
pub fn to_annotated_document(&self) -> Result<DocumentMut> {
use toml_edit::Item;
let toml_string = toml::to_string_pretty(self)?;
let mut doc = toml_string.parse::<DocumentMut>()?;
let header = r#"# Soar Declarative Package Configuration
# Run `soar apply` to install packages defined here.
# Run `soar apply --prune` to also remove packages not listed.
#
# Package format:
# package_name = "*" # Latest version
# package_name = "1.2.3" # Specific version (pinned)
# package_name = { version = "1.2" } # Same as above
# package_name = { pkg_id = "pkg-bin", repo = "bincache" }
# package_name = { pinned = true, portable = { home = "~/.pkg" } }
"#;
doc.as_table_mut().decor_mut().set_prefix(header);
annotate_toml_table::<PackagesConfig>(doc.as_table_mut(), true)?;
if let Some(Item::Table(defaults_table)) = doc.get_mut("defaults") {
annotate_toml_table::<PackageDefaults>(defaults_table, false)?;
}
Ok(doc)
}
/// Update package fields in packages.toml.
///
/// This preserves comments and formatting in the file.
/// - If `new_url` is provided, the `url` field is updated and `version` is updated only if it already exists.
/// - If only `new_version` is provided, the `version` field is added/updated.
/// - Simple string specs are skipped (user explicitly set a version).
pub fn update_package(
package_name: &str,
new_url: Option<&str>,
new_version: Option<&str>,
config_path: Option<&str>,
) -> Result<()> {
if new_url.is_none() && new_version.is_none() {
return Ok(());
}
let config_path = match config_path {
Some(p) => PathBuf::from(p),
None => PACKAGES_CONFIG_PATH.read().unwrap().clone(),
};
if !config_path.exists() {
return Err(ConfigError::PackagesConfigNotFound(
config_path.display().to_string(),
));
}
let content = fs::read_to_string(&config_path)?;
let mut doc = content.parse::<DocumentMut>()?;
let packages = doc
.get_mut("packages")
.and_then(|p| p.as_table_mut())
.ok_or_else(|| ConfigError::Custom("No [packages] section found".into()))?;
let package = packages.get_mut(package_name).ok_or_else(|| {
ConfigError::Custom(format!("Package '{}' not found in config", package_name))
})?;
if matches!(package, toml_edit::Item::Value(toml_edit::Value::String(_))) {
return Ok(());
}
match package {
toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => {
if let Some(url) = new_url {
table.insert("url", url.into());
}
if let Some(version) = new_version {
table.insert("version", version.into());
}
}
toml_edit::Item::Table(table) => {
if let Some(url) = new_url {
table.insert("url", toml_edit::value(url));
}
if let Some(version) = new_version {
table.insert("version", toml_edit::value(version));
}
}
_ => {
return Err(ConfigError::Custom(format!(
"Unexpected package format for '{}'",
package_name
)));
}
}
fs::write(&config_path, doc.to_string())?;
let updated = match (new_url, new_version) {
(Some(_), Some(v)) => format!("URL and version ({})", v),
(Some(_), None) => "URL".to_string(),
(None, Some(v)) => format!("version to {}", v),
(None, None) => unreachable!(),
};
info!(
"Updated {} for '{}' in {}",
updated,
package_name,
config_path.display()
);
Ok(())
}
}
/// Generate a default packages configuration file.
pub fn generate_default_packages_config() -> Result<()> {
let config_path = PACKAGES_CONFIG_PATH.read().unwrap().clone();
if config_path.exists() {
return Err(ConfigError::PackagesConfigAlreadyExists);
}
let def_config = PackagesConfig::default_config();
let annotated_doc = def_config.to_annotated_document()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&config_path, annotated_doc.to_string())?;
info!(
"Default packages configuration generated at: {}",
config_path.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_package_spec() {
let toml_str = r#"
[packages]
curl = "*"
jq = "1.8.1"
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.packages.len(), 2);
let resolved = config.resolved_packages();
let curl = resolved.iter().find(|p| p.name == "curl").unwrap();
let jq = resolved.iter().find(|p| p.name == "jq").unwrap();
assert_eq!(curl.version, None);
assert_eq!(jq.version, Some("1.8.1".to_string()));
}
#[test]
fn test_detailed_package_spec() {
let toml_str = r#"
[packages]
neovim = { pkg_id = "neovim-appimage", repo = "soarpkgs", pinned = true }
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
assert_eq!(resolved[0].name, "neovim");
assert_eq!(resolved[0].pkg_id, Some("neovim-appimage".to_string()));
assert_eq!(resolved[0].repo, Some("soarpkgs".to_string()));
assert!(resolved[0].pinned);
}
#[test]
fn test_defaults_applied() {
let toml_str = r#"
[defaults]
profile = "work"
binary_only = true
[packages]
curl = "*"
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
assert_eq!(resolved[0].profile, Some("work".to_string()));
assert!(resolved[0].binary_only);
}
#[test]
fn test_package_override_defaults() {
let toml_str = r#"
[defaults]
profile = "default"
[packages]
special = { profile = "isolated" }
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
assert_eq!(resolved[0].profile, Some("isolated".to_string()));
}
#[test]
fn test_bsum_pin_resolved() {
let toml_str = r#"
[packages]
myapp = { url = "https://example.com/myapp-1.0.AppImage", bsum = "abc123" }
nopin = { url = "https://example.com/nopin-1.0.AppImage" }
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
let myapp = resolved.iter().find(|p| p.name == "myapp").unwrap();
let nopin = resolved.iter().find(|p| p.name == "nopin").unwrap();
assert_eq!(myapp.bsum, Some("abc123".to_string()));
assert_eq!(nopin.bsum, None);
}
#[test]
fn test_bsum_none_for_simple_spec() {
let toml_str = r#"
[packages]
curl = "*"
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
assert_eq!(resolved[0].bsum, None);
}
#[test]
fn test_portable_config() {
let toml_str = r#"
[packages]
firefox = { portable = { home = "~/.firefox-home", config = "~/.firefox-config" } }
"#;
let config: PackagesConfig = toml::from_str(toml_str).unwrap();
let resolved = config.resolved_packages();
let portable = resolved[0].portable.as_ref().unwrap();
assert_eq!(portable.home, Some("~/.firefox-home".to_string()));
assert_eq!(portable.config, Some("~/.firefox-config".to_string()));
}
#[test]
fn test_annotated_document() {
let config = PackagesConfig::default_config();
let doc = config.to_annotated_document();
assert!(doc.is_ok());
let doc_str = doc.unwrap().to_string();
// Should contain header
assert!(doc_str.contains("Soar Declarative Package Configuration"));
assert!(doc_str.contains("soar apply"));
// Should contain field documentation comments
assert!(doc_str.contains("#"));
}
}