use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ShunConfig {
pub product: ProductIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webview2: Option<Webview2Strategy>,
pub targets: Vec<TargetConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<ShellUiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SourceConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update: Option<UpdateWatchConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<AttachmentConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license_sysl: Option<LicenseSyslConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub msix: Option<MsixConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<PathBuf>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub license_locales: BTreeMap<String, PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom_steps: Vec<CustomStepConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steps: Option<Vec<StepConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing: Option<SigningConfig>,
}
impl ShunConfig {
pub fn from_cargo_manifest(cargo_toml: &Path) -> Result<Self, crate::error::ShunError> {
let raw = std::fs::read_to_string(cargo_toml)?;
let draft: CargoTomlDraft = toml::from_str(&raw)
.map_err(|e| crate::error::ShunError::Config(format!("manifest parse: {e}")))?;
let package = draft.package;
let (name, version, metadata) = match package.version {
Some(toml::Value::String(version)) => (package.name, version, package.metadata),
Some(table) if table.get("workspace").and_then(toml::Value::as_bool) == Some(true) => {
let inherited = cargo_toml
.ancestors()
.skip(1) .map(|dir| dir.join("Cargo.toml"))
.filter_map(|path| std::fs::read_to_string(path).ok())
.filter_map(|raw| toml::from_str::<toml::Value>(&raw).ok())
.find_map(|value| {
value
.get("workspace")?
.get("package")?
.get("version")?
.as_str()
.map(String::from)
});
let version = inherited.ok_or_else(|| {
crate::error::ShunError::Config(
"version.workspace = true, but the workspace root declares no version"
.into(),
)
})?;
(package.name, version, package.metadata)
}
_ => {
return Err(crate::error::ShunError::Config(
"package.version missing or not a string".into(),
));
}
};
let base = cargo_toml.parent().unwrap_or(Path::new(""));
let shun_meta = metadata.and_then(|m| m.shun).unwrap_or_default();
Ok(shun_meta.into_config(name, version, base))
}
pub fn from_any(path: &Path) -> Result<Self, crate::error::ShunError> {
if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
Self::from_cargo_manifest(path)
} else {
Self::from_path(path)
}
}
pub fn from_path(path: &Path) -> Result<Self, crate::error::ShunError> {
let raw = std::fs::read_to_string(path)?;
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"json" => serde_json::from_str(&raw)
.map_err(|e| crate::error::ShunError::Config(format!("config parse: {e}"))),
"toml" => {
let draft: ShunMetadataDraft = toml::from_str(&raw)
.map_err(|e| crate::error::ShunError::Config(format!("config parse: {e}")))?;
let name = draft
.product
.clone()
.unwrap_or_else(|| "shun-product".to_string());
Ok(draft.into_config(
name,
"0.0.0".to_string(),
path.parent().unwrap_or(Path::new("")),
))
}
other => Err(crate::error::ShunError::Config(format!(
"unsupported config extension: {other}",
))),
}
}
pub fn resolve_steps(
&self,
base: &Path,
locale: Option<&str>,
) -> Result<Vec<ResolvedStep>, crate::error::ShunError> {
let config_error = |message: &str| crate::error::ShunError::Config(message.to_string());
let read_markdown = |path: &str, what: &str| -> Result<String, crate::error::ShunError> {
std::fs::read_to_string(base.join(path)).map_err(|e| {
crate::error::ShunError::Config(format!("{what} document `{path}`: {e}"))
})
};
let pipeline: Vec<StepConfig> = match &self.steps {
Some(steps) => {
if !self.custom_steps.is_empty() {
return Err(config_error(
"declare one of `steps` or `custom-steps`, not both",
));
}
let installs = steps.iter().filter(|s| s.kind == StepKind::Install).count();
match installs {
1 => {
for step in steps {
if step.kind == StepKind::Content
&& (step.title.is_none() || step.markdown.is_none())
{
return Err(config_error(
"content steps need both `title` and `markdown`",
));
}
}
steps.clone()
}
0 => {
return Err(config_error(
"the `steps` pipeline must contain one `install` step",
));
}
n => {
return Err(config_error(&format!(
"the `steps` pipeline contains {n} `install` steps; \
exactly one is allowed"
)));
}
}
}
None => {
let mut steps = vec![bare_step(StepKind::Mode)];
steps.extend(
self.custom_steps
.iter()
.filter(|c| c.after == "mode")
.map(custom_to_step),
);
if self.license.is_some() || !self.license_locales.is_empty() {
steps.push(bare_step(StepKind::License));
steps.extend(
self.custom_steps
.iter()
.filter(|c| c.after == "license")
.map(custom_to_step),
);
}
steps.push(bare_step(StepKind::Install));
steps.extend(
self.custom_steps
.iter()
.filter(|c| c.after == "install")
.map(custom_to_step),
);
steps
}
};
let license_body = || -> Result<Option<String>, crate::error::ShunError> {
let path = locale
.and_then(|l| self.license_locales.get(l))
.or(self.license.as_ref());
match path {
Some(path) => read_markdown(&path.display().to_string(), "license").map(Some),
None => Ok(None),
}
};
pipeline
.into_iter()
.map(|step| {
let markdown = match (step.kind, step.markdown.as_deref()) {
(StepKind::License, _) => license_body()?,
(StepKind::Content, Some(markdown)) => {
Some(read_markdown(markdown, "content step")?)
}
_ => None,
};
let columns = match step.kind {
StepKind::Mode => match step.columns {
Some(n @ 2..=4) => Some(n),
Some(other) => {
return Err(config_error(&format!(
"mode step columns must be 2..=4, got {other}"
)));
}
None => None,
},
_ => None,
};
Ok(ResolvedStep {
align: step.align.unwrap_or_else(|| step.kind.default_align()),
kind: step.kind,
title: step.title.unwrap_or_default(),
body: markdown,
columns,
})
})
.collect()
}
}
fn bare_step(kind: StepKind) -> StepConfig {
StepConfig {
kind,
align: None,
title: None,
markdown: None,
columns: None,
}
}
fn custom_to_step(custom: &CustomStepConfig) -> StepConfig {
StepConfig {
kind: StepKind::Content,
align: None,
title: Some(custom.title.clone()),
markdown: Some(custom.markdown.clone()),
columns: None,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProductIdentity {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Webview2Strategy {
Skip,
EvergreenInstaller,
FixedVersion {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TargetConfig {
Install(InstallConfig),
Flash(FlashConfig),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct InstallConfig {
#[serde(default = "default_true")]
pub local: bool,
#[serde(default = "default_true")]
pub portable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub portable_marker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_exe: Option<PathBuf>,
#[serde(default)]
pub desktop_shortcut: DesktopShortcutPolicy,
#[serde(default = "start_menu_shortcut_default")]
pub start_menu_shortcut: ShortcutPolicy,
#[serde(default)]
pub scope: ScopePolicy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub verbs: Vec<VerbConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deep_links: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aumid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<PathBuf>,
}
impl Default for InstallConfig {
fn default() -> Self {
Self {
local: true,
portable: true,
portable_marker: None,
main_exe: None,
desktop_shortcut: DesktopShortcutPolicy::Ask,
start_menu_shortcut: DesktopShortcutPolicy::Always,
scope: ScopePolicy::User,
verbs: Vec::new(),
deep_links: Vec::new(),
aumid: None,
icon: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum DesktopShortcutPolicy {
#[default]
Ask,
Always,
Never,
}
pub type ShortcutPolicy = DesktopShortcutPolicy;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "target", rename_all = "kebab-case")]
pub enum VerbConfig {
DataFolder {
key: String,
display: String,
},
Uninstall {
key: String,
display: String,
},
App {
key: String,
display: String,
#[serde(default)]
arguments: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct FlashConfig {
#[serde(default = "default_true")]
pub require_removable: bool,
}
fn default_true() -> bool {
true
}
fn start_menu_shortcut_default() -> DesktopShortcutPolicy {
DesktopShortcutPolicy::Always
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct ShellUiConfig {
#[serde(default)]
pub timeline: Option<TimelineOrientation>,
#[serde(default)]
pub theme: Option<ThemeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_level: Option<LogVerbosity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_order: Option<LogOrder>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum LogOrder {
#[default]
Newest,
Oldest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum LogVerbosity {
#[default]
All,
Files,
Scripts,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TimelineOrientation {
#[default]
Top,
Left,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct ThemeConfig {
#[serde(default)]
pub mode: Option<ThemeMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<[u8; 3]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeMode {
#[default]
System,
Light,
Dark,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum SourceConfig {
#[default]
Embedded,
Online {
url: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct UpdateWatchConfig {
pub sources: Vec<String>,
pub files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AttachmentOnlineConfig {
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LicenseSyslConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub locales: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AttachmentConfig {
pub key: String,
pub title: String,
pub dest: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
pub online: AttachmentOnlineConfig,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct CustomStepConfig {
pub key: String,
pub after: String,
pub title: String,
pub markdown: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct StepConfig {
pub kind: StepKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub align: Option<StepAlign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub markdown: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StepAlign {
Center,
Start,
}
impl StepKind {
pub fn default_align(&self) -> StepAlign {
match self {
StepKind::License | StepKind::Content => StepAlign::Start,
_ => StepAlign::Center,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResolvedStep {
pub kind: StepKind,
pub align: StepAlign,
pub title: String,
pub body: Option<String>,
pub columns: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StepKind {
Mode,
Scope,
License,
Content,
Install,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ScopePolicy {
#[default]
User,
Machine,
Ask,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct SigningConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub windows: Option<WindowsSigningConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub macos: Option<MacSigningConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct WindowsSigningConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbprint: Option<String>,
#[serde(default = "default_timestamp_url")]
pub timestamp_url: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct MacSigningConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
}
fn default_timestamp_url() -> String {
"http://timestamp.digicert.com".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct MsixConfig {
pub identity_name: String,
pub publisher: String,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executable: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_background: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CargoTomlDraft {
package: PackageDraft,
}
#[derive(Debug, Deserialize)]
struct PackageDraft {
name: String,
#[serde(default)]
version: Option<toml::Value>,
#[serde(default)]
metadata: Option<MetadataDraft>,
}
#[derive(Debug, Deserialize)]
struct MetadataDraft {
#[serde(default)]
shun: Option<ShunMetadataDraft>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ShunMetadataDraft {
#[serde(default)]
product: Option<String>,
#[serde(default)]
publisher: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
payload: Option<String>,
#[serde(default)]
main_exe: Option<String>,
#[serde(default)]
webview2: Option<Webview2Strategy>,
#[serde(default)]
install: Option<InstallConfig>,
#[serde(default)]
flash: Option<FlashConfig>,
#[serde(default)]
shell: Option<ShellUiConfig>,
#[serde(default)]
source: Option<SourceConfig>,
#[serde(default)]
update: Option<UpdateWatchConfig>,
#[serde(default)]
attachments: Option<Vec<AttachmentConfig>>,
#[serde(default)]
license_sysl: Option<LicenseSyslConfig>,
#[serde(default)]
license: Option<String>,
#[serde(default, rename = "license-locales")]
license_locales: Option<BTreeMap<String, String>>,
#[serde(default)]
custom_steps: Option<Vec<CustomStepConfig>>,
#[serde(default)]
steps: Option<Vec<StepConfig>>,
#[serde(default)]
signing: Option<SigningConfig>,
#[serde(default)]
msix: Option<MsixConfig>,
}
impl ShunMetadataDraft {
fn into_config(self, product_name: String, version: String, _base: &Path) -> ShunConfig {
let mut targets = Vec::new();
match self.install {
Some(mut install) => {
if install.main_exe.is_none() {
install.main_exe = self.main_exe.clone().map(PathBuf::from);
}
targets.push(TargetConfig::Install(install));
}
None => targets.push(TargetConfig::Install(InstallConfig {
main_exe: self.main_exe.clone().map(PathBuf::from),
..InstallConfig::default()
})),
}
if let Some(flash) = self.flash {
targets.push(TargetConfig::Flash(flash));
}
ShunConfig {
product: ProductIdentity {
name: self.product.unwrap_or(product_name),
version,
publisher: self.publisher,
logo: self.logo,
},
payload: self.payload.map(PathBuf::from),
webview2: self.webview2,
targets,
shell: self.shell,
source: self.source,
update: self.update,
attachments: self.attachments.unwrap_or_default(),
license_sysl: self.license_sysl,
license: self.license.map(PathBuf::from),
license_locales: self
.license_locales
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, PathBuf::from(v)))
.collect(),
custom_steps: self.custom_steps.unwrap_or_default(),
steps: self.steps,
signing: self.signing,
msix: self.msix,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ShunConfig {
ShunConfig {
product: ProductIdentity {
name: "ShunDemo".into(),
version: "0.1.0".into(),
publisher: Some("celestia-island".into()),
logo: Some("logo.webp".into()),
},
payload: Some("examples/demo_payload".into()),
webview2: Some(Webview2Strategy::FixedVersion {
path: "WebView2Runtime".into(),
}),
targets: vec![
TargetConfig::Install(InstallConfig::default()),
TargetConfig::Flash(FlashConfig::default()),
],
shell: None,
source: None,
update: None,
attachments: Vec::new(),
license_sysl: None,
license: None,
license_locales: BTreeMap::new(),
custom_steps: Vec::new(),
steps: None,
signing: None,
msix: None,
}
}
#[test]
fn webview2_strategy_uses_kebab_case_tags() {
let json = serde_json::to_value(sample()).unwrap();
assert_eq!(json["webview2"]["type"], "fixed-version");
}
#[test]
fn targets_use_kebab_case_kinds() {
let json = serde_json::to_value(sample()).unwrap();
assert_eq!(json["targets"][0]["kind"], "install");
assert_eq!(json["targets"][1]["kind"], "flash");
}
#[test]
fn install_modes_default_to_enabled() {
let json = serde_json::to_value(sample()).unwrap();
assert_eq!(json["targets"][0]["local"], true);
assert_eq!(json["targets"][0]["portable"], true);
}
#[test]
fn loads_from_cargo_manifest_metadata() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "shun-demo-shell"
version = "0.3.1"
edition = "2024"
[package.metadata.shun]
product = "ShunDemo"
publisher = "celestia-island"
logo = "docs/logo.webp"
payload = "examples/demo_payload"
main-exe = "bin/shun-demo.cmd"
[package.metadata.shun.webview2]
type = "fixed-version"
path = "WebView2Runtime"
[package.metadata.shun.flash]
require-removable = true
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.product.name, "ShunDemo");
assert_eq!(config.product.version, "0.3.1");
assert_eq!(config.product.publisher.as_deref(), Some("celestia-island"));
assert_eq!(
config.payload.as_deref(),
Some(Path::new("examples/demo_payload"))
);
let [TargetConfig::Install(install), TargetConfig::Flash(flash)] = &config.targets[..]
else {
panic!("expected install + flash targets");
};
assert!(install.local && install.portable);
assert_eq!(
install.main_exe.as_deref(),
Some(Path::new("bin/shun-demo.cmd"))
);
assert!(flash.require_removable);
}
#[test]
fn attachments_parse_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "attach-demo"
version = "0.1.0"
[package.metadata.shun]
product = "AttachDemo"
payload = "payload"
[[package.metadata.shun.attachments]]
key = "models"
title = "2D/3D model pack"
dest = "models"
size = 123
[package.metadata.shun.attachments.online]
url = "https://example.test/models.shun"
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.attachments.len(), 1);
let attachment = &config.attachments[0];
assert_eq!(attachment.key, "models");
assert_eq!(attachment.title, "2D/3D model pack");
assert_eq!(attachment.dest, Path::new("models"));
assert_eq!(attachment.size, Some(123));
assert_eq!(attachment.online.url, "https://example.test/models.shun");
}
#[test]
fn update_watch_parses_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "watch-demo"
version = "0.1.0"
[package.metadata.shun]
product = "WatchDemo"
[package.metadata.shun.update]
sources = [
"https://mirror.example.test/watch-demo/",
"https://releases.example.test/watch-demo",
]
files = ["latest", "app-setup.exe"]
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
let update = config.update.expect("update table parsed");
assert_eq!(
update.sources,
vec![
"https://mirror.example.test/watch-demo/".to_string(),
"https://releases.example.test/watch-demo".to_string(),
]
);
assert_eq!(
update.files,
vec!["latest".to_string(), "app-setup.exe".to_string()]
);
}
#[test]
fn inherits_workspace_version_from_a_nested_member() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("repo");
let member = root.join("packages").join("installer-shell");
std::fs::create_dir_all(&member).unwrap();
std::fs::write(
root.join("Cargo.toml"),
r#"
[workspace]
resolver = "2"
members = ["packages/installer-shell"]
[workspace.package]
version = "1.2.3"
"#,
)
.unwrap();
let manifest = member.join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "nested-shell"
version.workspace = true
edition = "2024"
[package.metadata.shun]
product = "Nested"
payload = "payload"
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.product.version, "1.2.3");
}
#[test]
fn install_registration_knobs_parse_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "shun-demo"
version = "0.3.1"
[package.metadata.shun]
main-exe = "bin/shun-demo.exe"
[package.metadata.shun.install]
desktop-shortcut = "always"
start-menu-shortcut = "never"
aumid = "celestia-island.ShunDemo"
icon = "assets/icon.png"
deep-links = ["shundemo"]
[[package.metadata.shun.install.verbs]]
key = "open-data"
display = "Open data folder"
target = "data-folder"
[[package.metadata.shun.install.verbs]]
key = "safe-mode"
display = "Safe mode"
target = "app"
arguments = "--safe"
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
let TargetConfig::Install(install) = &config.targets[0] else {
panic!("expected an install target");
};
assert_eq!(install.desktop_shortcut, DesktopShortcutPolicy::Always);
assert_eq!(install.start_menu_shortcut, DesktopShortcutPolicy::Never);
assert_eq!(install.aumid.as_deref(), Some("celestia-island.ShunDemo"));
assert_eq!(install.icon.as_deref(), Some(Path::new("assets/icon.png")));
assert_eq!(install.deep_links, vec!["shundemo".to_string()]);
assert_eq!(install.verbs.len(), 2);
assert_eq!(
install.verbs[0],
VerbConfig::DataFolder {
key: "open-data".into(),
display: "Open data folder".into(),
},
"data-folder verbs take no arguments"
);
assert_eq!(
install.verbs[1],
VerbConfig::App {
key: "safe-mode".into(),
display: "Safe mode".into(),
arguments: "--safe".into(),
}
);
}
#[test]
fn manifest_without_shun_metadata_defaults_to_install() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"plain-app\"\nversion = \"1.2.3\"\n",
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.product.name, "plain-app");
assert_eq!(config.product.version, "1.2.3");
assert_eq!(config.targets.len(), 1);
assert!(
matches!(&config.targets[0], TargetConfig::Install(install) if install.local && install.portable)
);
assert!(
matches!(&config.targets[0], TargetConfig::Install(install) if install.start_menu_shortcut == DesktopShortcutPolicy::Always)
);
}
#[test]
fn workspace_version_is_inherited() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n[workspace.package]\nversion = \"2.5.0\"\n",
)
.unwrap();
let app_dir = dir.path().join("app");
std::fs::create_dir(&app_dir).unwrap();
let manifest = app_dir.join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"app\"\nversion.workspace = true\n",
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.product.name, "app");
assert_eq!(config.product.version, "2.5.0");
}
#[test]
fn default_pipeline_is_mode_license_install() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]
name = \"app\"
version = \"1.0.0\"
",
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
let steps = config.resolve_steps(dir.path(), None).unwrap();
assert_eq!(
steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
vec![StepKind::Mode, StepKind::Install]
);
std::fs::write(
dir.path().join("LICENSE.md"),
"# terms
",
)
.unwrap();
let mut config = config;
config.license = Some("LICENSE.md".into());
let steps = config.resolve_steps(dir.path(), None).unwrap();
assert_eq!(
steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
vec![StepKind::Mode, StepKind::License, StepKind::Install]
);
assert_eq!(steps[1].body.as_deref(), Some("# terms\n"));
}
#[test]
fn declared_pipeline_orders_and_inlines_freely() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("why.md"),
"# why
",
)
.unwrap();
let config_path = dir.path().join("shun.toml");
std::fs::write(
&config_path,
r#"
product = "App"
[[steps]]
kind = "content"
title = "Why"
markdown = "why.md"
[[steps]]
kind = "scope"
[[steps]]
kind = "license"
[[steps]]
kind = "mode"
[[steps]]
kind = "install"
"#,
)
.unwrap();
let config = ShunConfig::from_path(&config_path).unwrap();
let steps = config.resolve_steps(dir.path(), None).unwrap();
assert_eq!(
steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
vec![
StepKind::Content,
StepKind::Scope,
StepKind::License,
StepKind::Mode,
StepKind::Install,
]
);
assert_eq!(steps[0].title, "Why");
assert_eq!(steps[0].body.as_deref(), Some("# why\n"));
assert_eq!(steps[2].body, None);
}
#[test]
fn pipeline_validation_rejects_bad_declarations() {
let dir = tempfile::tempdir().unwrap();
let mut config = sample();
let bare = |kind| StepConfig {
kind,
align: None,
title: None,
markdown: None,
columns: None,
};
config.steps = Some(vec![bare(StepKind::Mode)]);
assert!(config.resolve_steps(dir.path(), None).is_err());
config.steps = Some(vec![
bare(StepKind::Mode),
bare(StepKind::Install),
bare(StepKind::Install),
]);
assert!(config.resolve_steps(dir.path(), None).is_err());
config.steps = Some(vec![
bare(StepKind::Mode),
StepConfig {
kind: StepKind::Content,
align: None,
title: None,
markdown: None,
columns: None,
},
bare(StepKind::Install),
]);
assert!(config.resolve_steps(dir.path(), None).is_err());
config.steps = Some(vec![bare(StepKind::Mode), bare(StepKind::Install)]);
config.custom_steps = vec![CustomStepConfig {
key: "extra".into(),
after: "mode".into(),
title: "Extra".into(),
markdown: "extra.md".into(),
}];
let error = config.resolve_steps(dir.path(), None).unwrap_err();
assert!(error.to_string().contains("not both"));
config.steps = None;
config.license = None;
config.license_locales.clear();
config.custom_steps = vec![
CustomStepConfig {
key: "a".into(),
after: "mode".into(),
title: "A".into(),
markdown: "a.md".into(),
},
CustomStepConfig {
key: "b".into(),
after: "install".into(),
title: "B".into(),
markdown: "b.md".into(),
},
];
std::fs::write(dir.path().join("a.md"), "a").unwrap();
std::fs::write(dir.path().join("b.md"), "b").unwrap();
let steps = config.resolve_steps(dir.path(), None).unwrap();
assert_eq!(
steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
vec![
StepKind::Mode,
StepKind::Content,
StepKind::Install,
StepKind::Content
]
);
}
#[test]
fn step_alignment_overrides_and_log_level_parse() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("why.md"),
"# why
",
)
.unwrap();
let config_path = dir.path().join("shun.toml");
std::fs::write(
&config_path,
concat!(
"product = \"App\"
",
"[shell]
",
"log-level = \"scripts\"
",
"[[steps]]
",
"kind = \"license\"
",
"align = \"center\"
",
"[[steps]]
",
"kind = \"mode\"
",
"[[steps]]
",
"kind = \"content\"
",
"title = \"Notes\"
",
"markdown = \"why.md\"
",
"[[steps]]
",
"kind = \"install\"
",
),
)
.unwrap();
let config = ShunConfig::from_path(&config_path).unwrap();
assert_eq!(
config.shell.as_ref().unwrap().log_level,
Some(LogVerbosity::Scripts)
);
let steps = config.resolve_steps(dir.path(), None).unwrap();
assert_eq!(steps[0].align, StepAlign::Center);
assert_eq!(steps[1].align, StepAlign::Center);
assert_eq!(steps[2].align, StepAlign::Start);
}
#[test]
fn install_scope_policy_parses() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
concat!(
"[package]\n",
"name = \"app\"\n",
"version = \"1.0.0\"\n",
"\n",
"[package.metadata.shun.install]\n",
"scope = \"machine\"\n",
),
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
let TargetConfig::Install(install) = &config.targets[0] else {
panic!("install target expected")
};
assert_eq!(install.scope, ScopePolicy::Machine);
}
#[test]
fn loads_shell_ui_and_source_tables() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("shun.toml");
std::fs::write(
&config_path,
r#"
product = "ShunDemo"
payload = "payload"
license-locales = { zh-Hans = "LICENSE.zh.md" }
[shell]
timeline = "left"
language = "zh-Hans"
[shell.theme]
mode = "dark"
accent = [34, 211, 238]
[source]
type = "online"
url = "https://example.test/ShunDemo.shun"
"#,
)
.unwrap();
let config = ShunConfig::from_path(&config_path).unwrap();
let shell = config.shell.expect("shell table parsed");
assert_eq!(shell.timeline, Some(TimelineOrientation::Left));
assert_eq!(shell.language.as_deref(), Some("zh-Hans"));
let theme = shell.theme.expect("theme parsed");
assert_eq!(theme.mode, Some(ThemeMode::Dark));
assert_eq!(theme.accent, Some([34, 211, 238]));
assert!(matches!(
config.source,
Some(SourceConfig::Online { ref url }) if url == "https://example.test/ShunDemo.shun"
));
assert!(config.license_locales.contains_key("zh-Hans"));
}
}