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 licenses: Vec<LicenseDocConfig>,
#[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()
|| !self.licenses.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_docs = || -> Result<Vec<ResolvedLicenseDoc>, crate::error::ShunError> {
let mut docs = Vec::new();
let sugar = locale
.and_then(|l| self.license_locales.get(l))
.or(self.license.as_ref());
if let Some(path) = sugar {
docs.push(ResolvedLicenseDoc {
title: None,
body: read_markdown(&path.display().to_string(), "license")?,
});
}
for doc in &self.licenses {
let path = locale
.and_then(|l| doc.locale_paths.get(l))
.unwrap_or(&doc.path);
docs.push(ResolvedLicenseDoc {
title: doc.title.clone(),
body: read_markdown(&path.display().to_string(), "license")?,
});
}
Ok(docs)
};
pipeline
.into_iter()
.map(|step| {
let licenses = if step.kind == StepKind::License {
license_docs()?
} else {
Vec::new()
};
let markdown = match (step.kind, step.markdown.as_deref()) {
(StepKind::License, _) => joined_license_body(&licenses),
(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,
licenses,
columns,
})
})
.collect()
}
}
const LICENSE_BODY_DIVIDER: &str =
"\n\n--------------------------------------------------------------------\n\n";
fn joined_license_body(docs: &[ResolvedLicenseDoc]) -> Option<String> {
match docs.len() {
0 => None,
1 => Some(docs[0].body.clone()),
_ => Some(
docs.iter()
.map(|doc| doc.body.as_str())
.collect::<Vec<_>>()
.join(LICENSE_BODY_DIVIDER),
),
}
}
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 launch_after_install: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_dir_folder: Option<String>,
}
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,
launch_after_install: ShortcutPolicy::default(),
scope: ScopePolicy::User,
verbs: Vec::new(),
deep_links: Vec::new(),
aumid: None,
icon: None,
root_dir_folder: 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 LicenseDocConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub path: PathBuf,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub locale_paths: BTreeMap<String, PathBuf>,
}
#[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, Eq, Serialize, Deserialize)]
pub struct ResolvedLicenseDoc {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResolvedStep {
pub kind: StepKind,
pub align: StepAlign,
pub title: String,
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub licenses: Vec<ResolvedLicenseDoc>,
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)]
licenses: Option<Vec<LicenseDocConfig>>,
#[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(),
licenses: self.licenses.unwrap_or_default(),
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(),
licenses: Vec::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"
launch-after-install = "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.launch_after_install, 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)
);
assert!(
matches!(&config.targets[0], TargetConfig::Install(install) if install.launch_after_install == DesktopShortcutPolicy::Ask)
);
}
#[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 licenses_parse_kebab_case_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "multi-license-demo"
version = "0.1.0"
[package.metadata.shun]
product = "MultiLicense"
[[package.metadata.shun.licenses]]
title = "Copyright notice"
path = "NOTICE.md"
[package.metadata.shun.licenses.locale-paths]
zh-Hans = "NOTICE.zh-Hans.md"
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
assert_eq!(config.licenses.len(), 1);
let doc = &config.licenses[0];
assert_eq!(doc.title.as_deref(), Some("Copyright notice"));
assert_eq!(doc.path, Path::new("NOTICE.md"));
assert_eq!(
doc.locale_paths.get("zh-Hans"),
Some(&PathBuf::from("NOTICE.zh-Hans.md"))
);
}
#[test]
fn license_documents_resolve_locale_aware_and_sugar_first() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("LICENSE.md"), "sugar body\n").unwrap();
std::fs::write(dir.path().join("LICENSE.zh.md"), "sugar body zh\n").unwrap();
std::fs::write(dir.path().join("NOTICE.md"), "notice body\n").unwrap();
std::fs::write(dir.path().join("NOTICE.zh-Hans.md"), "notice body zh\n").unwrap();
let mut config = sample();
config.license = Some("LICENSE.md".into());
config
.license_locales
.insert("zh-Hans".into(), "LICENSE.zh.md".into());
config.licenses = vec![LicenseDocConfig {
title: Some("Demo notice".into()),
path: "NOTICE.md".into(),
locale_paths: BTreeMap::from([("zh-Hans".into(), "NOTICE.zh-Hans.md".into())]),
}];
let steps = config.resolve_steps(dir.path(), Some("zh-Hans")).unwrap();
let license = steps.iter().find(|s| s.kind == StepKind::License).unwrap();
assert_eq!(license.licenses.len(), 2);
assert_eq!(license.licenses[0].title, None);
assert_eq!(license.licenses[0].body, "sugar body zh\n");
assert_eq!(license.licenses[1].title.as_deref(), Some("Demo notice"));
assert_eq!(license.licenses[1].body, "notice body zh\n");
let steps = config.resolve_steps(dir.path(), None).unwrap();
let license = steps.iter().find(|s| s.kind == StepKind::License).unwrap();
assert_eq!(license.licenses[0].body, "sugar body\n");
assert_eq!(license.licenses[1].body, "notice body\n");
}
#[test]
fn licenses_alone_trigger_the_license_step() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("NOTICE.md"), "notice\n").unwrap();
let mut config = sample();
config.licenses = vec![LicenseDocConfig {
title: None,
path: "NOTICE.md".into(),
locale_paths: BTreeMap::new(),
}];
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].licenses.len(), 1);
assert_eq!(steps[1].licenses[0].body, "notice\n");
}
#[test]
fn license_body_concatenation_semantics() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.md"), "a").unwrap();
std::fs::write(dir.path().join("b.md"), "b").unwrap();
let pipeline = |config: &ShunConfig| {
let mut cfg = config.clone();
cfg.steps = Some(vec![
bare_step(StepKind::License),
bare_step(StepKind::Install),
]);
cfg.resolve_steps(dir.path(), None).unwrap()
};
let steps = pipeline(&sample());
assert_eq!(steps[0].body, None);
assert!(steps[0].licenses.is_empty());
let mut config = sample();
config.license = Some("a.md".into());
let steps = pipeline(&config);
assert_eq!(steps[0].body.as_deref(), Some("a"));
assert_eq!(steps[0].licenses.len(), 1);
config.licenses = vec![LicenseDocConfig {
title: Some("B".into()),
path: "b.md".into(),
locale_paths: BTreeMap::new(),
}];
let steps = pipeline(&config);
assert_eq!(
steps[0].body.as_deref(),
Some(&format!("a{LICENSE_BODY_DIVIDER}b")[..]),
);
assert_eq!(steps[0].licenses.len(), 2);
assert_eq!(steps[0].licenses[1].title.as_deref(), Some("B"));
}
#[test]
fn resolved_license_step_serializes_back_compat() {
let bare = ResolvedStep {
kind: StepKind::License,
align: StepAlign::Start,
title: String::new(),
body: None,
licenses: Vec::new(),
columns: None,
};
let json = serde_json::to_value(&bare).unwrap();
assert!(json.get("licenses").is_none());
assert_eq!(json["body"], serde_json::Value::Null);
let single = ResolvedStep {
body: Some("solo".into()),
licenses: vec![ResolvedLicenseDoc {
title: None,
body: "solo".into(),
}],
..bare.clone()
};
let json = serde_json::to_value(&single).unwrap();
let mut keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["align", "body", "columns", "kind", "licenses", "title"]
);
assert_eq!(json["body"], "solo");
let multi = ResolvedStep {
body: Some(format!("a{LICENSE_BODY_DIVIDER}b")),
licenses: vec![
ResolvedLicenseDoc {
title: Some("A".into()),
body: "a".into(),
},
ResolvedLicenseDoc {
title: None,
body: "b".into(),
},
],
..bare
};
let json = serde_json::to_value(&multi).unwrap();
assert_eq!(json["licenses"].as_array().unwrap().len(), 2);
assert_eq!(json["licenses"][0]["title"], "A");
assert!(json["licenses"][1].get("title").is_none());
assert_eq!(json["body"], format!("a{LICENSE_BODY_DIVIDER}b"));
}
#[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 root_dir_folder_parses_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
r#"
[package]
name = "root-guard-demo"
version = "0.1.0"
[package.metadata.shun.install]
root-dir-folder = "Wowsp CE"
"#,
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&manifest).unwrap();
let TargetConfig::Install(install) = &config.targets[0] else {
panic!("install target expected")
};
assert_eq!(install.root_dir_folder.as_deref(), Some("Wowsp CE"));
let dir2 = tempfile::tempdir().unwrap();
let plain = dir2.path().join("Cargo.toml");
std::fs::write(
&plain,
"[package]\nname = \"plain-app\"\nversion = \"1.2.3\"\n",
)
.unwrap();
let config = ShunConfig::from_cargo_manifest(&plain).unwrap();
assert!(
matches!(&config.targets[0], TargetConfig::Install(i) if i.root_dir_folder.is_none()),
"the knob stays optional"
);
}
#[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"));
}
}