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 script: Option<ScriptHooksConfig>,
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 variants: Option<Vec<VariantConfig>>,
#[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 read_license = |path: &str| -> Result<String, crate::error::ShunError> {
read_markdown(path, "license").map(|text| {
let trimmed = text
.lines()
.map(|line| line.trim_start())
.collect::<Vec<_>>()
.join(
"
",
);
if text.ends_with(
"
",
) && !trimmed.ends_with(
"
",
) {
trimmed
+ "
"
} else {
trimmed
}
})
};
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`",
));
}
}
let install_at = steps
.iter()
.position(|s| s.kind == StepKind::Install)
.expect("guarded above");
let pairings = steps.iter().filter(|s| s.kind == StepKind::Pairing).count();
if pairings > 1 {
return Err(config_error(&format!(
"the pipeline contains {pairings} `pairing` steps; \
at most one is allowed"
)));
}
for (at, step) in steps.iter().enumerate() {
let pairing = match (&step.kind, &step.pairing) {
(StepKind::Pairing, None) => {
return Err(config_error(
"pairing steps need a `pairing` section",
));
}
(StepKind::Pairing, Some(pairing)) => pairing,
(_, Some(_)) => {
return Err(config_error(
"`pairing` is only valid on `pairing` steps",
));
}
_ => continue,
};
if at > install_at {
return Err(config_error(
"the pairing step must precede the install step",
));
}
if let PairingSource::Gateway { official, .. } = &pairing.source {
let identity = pairing.identity.as_ref();
if !identity.is_some_and(|i| i.node_id) {
return Err(config_error(
"the gateway pairing lane requires \
`pairing.identity.node-id = true`",
));
}
if !official.starts_with("http://")
&& !official.starts_with("https://")
{
return Err(config_error(
"the gateway `official` URL must start \
with http:// or https://",
));
}
if pairing.env_file.is_none() {
return Err(config_error(
"the gateway pairing lane requires \
`pairing.env-file` — the claimed \
credential needs a declared landing \
inside the install",
));
}
}
if let PairingSource::Scripts {
request,
r#await: await_script,
record,
} = &pairing.source
{
let payload_root = self
.payload
.as_deref()
.unwrap_or_else(|| std::path::Path::new(""));
for (path, what) in [
(request, "request"),
(await_script, "await"),
(record, "record"),
] {
if !contained_relative(&path.replace('\\', "/")) {
return Err(config_error(&format!(
"pairing {what} script `{path}` must be a \
relative path inside the payload"
)));
}
if !base.join(payload_root).join(path).is_file() {
return Err(config_error(&format!(
"pairing {what} script `{path}` not found in \
the payload"
)));
}
}
}
if let Some(env_file) = &pairing.env_file {
if env_file.trim().is_empty() {
return Err(config_error(
"pairing `env-file` must not be empty",
));
}
if !contained_relative(env_file) {
return Err(config_error(
"pairing `env-file` must be a relative \
path inside the install directory",
));
}
}
}
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_license(&path.display().to_string())?,
});
}
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_license(&path.display().to_string())?,
});
}
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,
pairing: step.pairing.clone(),
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 contained_relative(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.is_empty() {
return false;
}
let path = std::path::Path::new(trimmed);
!path.is_absolute()
&& !path.has_root()
&& !trimmed.contains('\\')
&& !trimmed.contains(':')
&& path
.components()
.all(|c| !matches!(c, std::path::Component::ParentDir))
}
fn bare_step(kind: StepKind) -> StepConfig {
StepConfig {
kind,
align: None,
title: None,
markdown: None,
columns: None,
pairing: 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,
pairing: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub faces: Option<Vec<UiFace>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VariantConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub faces: Option<Vec<UiFace>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::BTreeMap<String, String>>,
}
impl ShunConfig {
pub fn apply_variant(&mut self, name: &str) -> Result<(), String> {
let variant = self
.variants
.as_ref()
.and_then(|vs| vs.iter().find(|v| v.name == name))
.cloned()
.ok_or_else(|| format!("unknown variant `{name}`"))?;
if let Some(payload) = variant.payload {
self.payload = Some(payload);
}
if let Some(faces) = variant.faces {
let shell = self.shell.get_or_insert_with(Default::default);
shell.faces = Some(faces);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum UiFace {
Webview,
Egui,
Tui,
}
#[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]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<BackgroundSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rail_background: Option<BackgroundSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_background: Option<BackgroundSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_adjustable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wallpaper: Option<WallpaperSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WallpaperSpec {
pub sources: Vec<WallpaperSourceSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WallpaperSourceSpec {
Video { video: String },
Image { image: String },
Pipeline { pipeline: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BackgroundSpec {
Color(String),
Gradient {
from: String,
to: String,
#[serde(default = "default_gradient_angle")]
angle: u16,
},
Image { image: String },
#[serde(skip_deserializing)]
DataUrl(String),
}
fn default_gradient_angle() -> u16 {
135
}
impl BackgroundSpec {
pub fn css(&self) -> String {
match self {
BackgroundSpec::Color(color) => color.clone(),
BackgroundSpec::Gradient { from, to, angle } => {
format!("linear-gradient({angle}deg, {from}, {to})")
}
BackgroundSpec::Image { image } => format!("url({image})"),
BackgroundSpec::DataUrl(url) => format!("url({url})"),
}
}
}
#[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 ScriptHooksConfig {
pub runner: String,
#[serde(default)]
pub hooks: Vec<ScriptHookConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ScriptHookConfig {
pub phase: String,
pub script: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing: Option<PairingStepConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct PairingStepConfig {
pub source: PairingSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<PairingIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_file: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PairingSource {
#[serde(rename_all = "kebab-case")]
Gateway {
official: String,
#[serde(
default = "default_true",
alias = "allow_custom",
rename = "allow-custom"
)]
allow_custom: bool,
},
Scripts {
request: String,
#[serde(rename = "await")]
r#await: String,
record: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct PairingIdentity {
#[serde(default)]
pub node_id: bool,
#[serde(default)]
pub name: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: 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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing: Option<PairingStepConfig>,
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,
Pairing,
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)]
script: Option<ScriptHooksConfig>,
#[serde(default, rename = "variant")]
variants: Option<Vec<VariantConfig>>,
#[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 {
script: self.script,
variants: self.variants,
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 theme_variant_tests {
use super::*;
#[test]
fn faces_variants_and_backgrounds_parse() {
let raw = r##"
product = "app"
payload = "payload"
[shell.theme]
background = { from = "#000", to = "#111" }
rail-background = "#0A0A0A"
user-adjustable = true
[shell.theme.wallpaper]
sources = [
{ video = "https://cdn.example/a.mp4" },
{ image = "res/fallback.png" },
]
[shell]
faces = ["egui", "tui"]
[[variant]]
name = "lite"
payload = "payload-lite"
env = { SHUN_FLAVOR = "lite" }
[install]
local = true
"##;
let draft: ShunMetadataDraft = toml::from_str(raw).expect("draft parses");
let mut config = draft.into_config("app".into(), "1.0".into(), Path::new(""));
let faces = config.shell.as_ref().unwrap().faces.clone().unwrap();
assert_eq!(faces, vec![UiFace::Egui, UiFace::Tui]);
let theme = config.shell.as_ref().unwrap().theme.clone().unwrap();
assert_eq!(
theme.background.unwrap().css(),
"linear-gradient(135deg, #000, #111)"
);
assert_eq!(theme.rail_background.unwrap().css(), "#0A0A0A");
assert_eq!(theme.user_adjustable, Some(true));
let wallpaper = theme.wallpaper.unwrap();
assert_eq!(wallpaper.sources.len(), 2);
assert!(matches!(
wallpaper.sources[0],
WallpaperSourceSpec::Video { .. }
));
config.apply_variant("lite").expect("variant applies");
assert_eq!(
config.payload.clone().unwrap(),
PathBuf::from("payload-lite")
);
assert!(config.apply_variant("nope").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ShunConfig {
ShunConfig {
script: None,
variants: None,
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,
}
}
fn steps_config(steps: Vec<StepConfig>) -> ShunConfig {
let mut config = sample();
config.custom_steps = Vec::new();
config.steps = Some(steps);
config
}
fn pairing_step(pairing: Option<PairingStepConfig>) -> StepConfig {
StepConfig {
kind: StepKind::Pairing,
align: None,
title: None,
markdown: None,
columns: None,
pairing,
}
}
fn gateway_pairing() -> PairingStepConfig {
PairingStepConfig {
source: PairingSource::Gateway {
official: "https://gateway.example/server".into(),
allow_custom: true,
},
identity: Some(PairingIdentity {
node_id: true,
name: true,
tier: Some(2),
}),
env_file: Some("product.env".into()),
}
}
#[test]
fn pairing_step_parses_from_toml_with_both_lanes() {
let gateway = r#"
[[steps]]
kind = "pairing"
[steps.pairing.source]
kind = "gateway"
official = "https://gateway.example/server"
allow_custom = false
[steps.pairing.identity]
node-id = true
name = true
tier = 2
[steps.pairing]
env-file = "product.env"
[[steps]]
kind = "install"
"#;
#[derive(serde::Deserialize)]
struct StepsDoc {
steps: Vec<StepConfig>,
}
let parsed: StepsDoc = toml::from_str(gateway).expect("gateway lane parses");
let parsed = parsed.steps;
let PairingSource::Gateway {
official,
allow_custom,
} = &parsed[0].pairing.as_ref().unwrap().source
else {
panic!("gateway lane");
};
assert_eq!(official, "https://gateway.example/server");
assert!(!*allow_custom, "allow_custom round-trips");
assert_eq!(
parsed[0].pairing.as_ref().unwrap().env_file.as_deref(),
Some("product.env")
);
let scripts = r#"
[[steps]]
kind = "pairing"
[steps.pairing.source]
kind = "scripts"
request = "scripts/request.py"
await = "scripts/await.py"
record = "scripts/record.py"
[[steps]]
kind = "install"
"#;
let parsed: StepsDoc = toml::from_str(scripts).expect("scripts lane parses");
let parsed = parsed.steps;
let PairingSource::Scripts {
request,
r#await: await_script,
record,
} = &parsed[0].pairing.as_ref().unwrap().source
else {
panic!("scripts lane");
};
assert_eq!(request, "scripts/request.py");
assert_eq!(await_script, "scripts/await.py");
assert_eq!(record, "scripts/record.py");
}
#[test]
fn pairing_gateway_lane_resolves_before_install() {
let config = steps_config(vec![
pairing_step(Some(gateway_pairing())),
bare_step(StepKind::Install),
]);
let steps = config
.resolve_steps(Path::new("."), None)
.expect("resolves");
let resolved = steps
.iter()
.find(|s| s.kind == StepKind::Pairing)
.expect("the pairing step resolves");
assert_eq!(resolved.pairing.as_ref(), Some(&gateway_pairing()));
assert!(
steps
.iter()
.find(|s| s.kind == StepKind::Install)
.unwrap()
.pairing
.is_none()
);
}
#[test]
fn pairing_after_install_is_rejected() {
let config = steps_config(vec![
bare_step(StepKind::Install),
pairing_step(Some(gateway_pairing())),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(
err.to_string().contains("precede the install step"),
"{err}"
);
}
#[test]
fn pairing_gateway_lane_requires_a_node_id() {
let mut pairing = gateway_pairing();
pairing.identity = None;
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("node-id"), "{err}");
}
#[test]
fn pairing_scripts_must_stay_inside_the_payload() {
let base = std::env::temp_dir().join("shun-pairing-scripts-escape");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("payload/installer/pairing")).unwrap();
std::fs::write(base.join("payload/installer/pairing/real.py"), "# ok").unwrap();
let mut config = steps_config(vec![
pairing_step(Some(PairingStepConfig {
source: PairingSource::Scripts {
request: "installer/pairing/real.py".into(),
r#await: "installer/pairing/real.py".into(),
record: "installer/pairing/real.py".into(),
},
identity: None,
env_file: None,
})),
bare_step(StepKind::Install),
]);
config.payload = Some("payload".into());
config
.resolve_steps(&base, None)
.expect("the payload-relative script resolves");
let outside = base.parent().unwrap().join("shun-pairing-outside.py");
std::fs::write(&outside, "# ok").unwrap();
for escape in [
outside.to_str().unwrap().to_string(),
"../../shun-pairing-outside.py".into(),
"C:\\creds.py".into(),
] {
let mut config = steps_config(vec![
pairing_step(Some(PairingStepConfig {
source: PairingSource::Scripts {
request: escape.clone(),
r#await: escape.clone(),
record: escape.clone(),
},
identity: None,
env_file: None,
})),
bare_step(StepKind::Install),
]);
config.payload = Some("payload".into());
let err = config.resolve_steps(&base, None).unwrap_err();
assert!(
err.to_string().contains("inside the payload"),
"{escape}: {err}"
);
}
let _ = std::fs::remove_file(&outside);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pairing_scripts_must_exist_in_the_payload() {
let base = std::env::temp_dir().join("shun-pairing-scripts-test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("payload/scripts")).unwrap();
std::fs::write(base.join("payload/scripts/request.py"), "# ok").unwrap();
let mut config = steps_config(vec![
pairing_step(Some(PairingStepConfig {
source: PairingSource::Scripts {
request: "scripts/request.py".into(),
r#await: "scripts/await.py".into(),
record: "scripts/record.py".into(),
},
identity: None,
env_file: None,
})),
bare_step(StepKind::Install),
]);
config.payload = Some("payload".into());
let err = config.resolve_steps(&base, None).unwrap_err();
assert!(
err.to_string().contains("await script") && err.to_string().contains("in the payload"),
"{err}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pairing_section_only_belongs_to_pairing_steps() {
let mut stray = bare_step(StepKind::Mode);
stray.pairing = Some(gateway_pairing());
let config = steps_config(vec![stray, bare_step(StepKind::Install)]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("only valid on"), "{err}");
}
#[test]
fn duplicate_pairing_steps_are_rejected() {
let config = steps_config(vec![
pairing_step(Some(gateway_pairing())),
pairing_step(Some(gateway_pairing())),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("at most one"), "{err}");
}
#[test]
fn gateway_official_url_must_carry_a_scheme() {
let mut pairing = gateway_pairing();
pairing.source = PairingSource::Gateway {
official: "gateway.example/server".into(),
allow_custom: true,
};
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("http"), "{err}");
}
#[test]
fn gateway_lane_requires_an_env_file() {
let mut pairing = gateway_pairing();
pairing.env_file = None;
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("env-file"), "{err}");
}
#[test]
fn env_file_must_stay_inside_the_install_dir() {
for traversal in ["../creds.env", "/abs/creds.env", "a/../../creds.env"] {
let mut pairing = gateway_pairing();
pairing.env_file = Some(traversal.into());
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("relative"), "{traversal}: {err}");
}
for fine in ["product.env", "config/product.env"] {
let mut pairing = gateway_pairing();
pairing.env_file = Some(fine.into());
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
assert!(config.resolve_steps(Path::new("."), None).is_ok(), "{fine}");
}
}
#[test]
fn gateway_allow_custom_defaults_to_true_when_omitted() {
let doc: serde_json::Value =
serde_json::from_str(r#"{"kind":"gateway","official":"https://g"}"#).unwrap();
let source: PairingSource = serde_json::from_value(doc).unwrap();
assert!(
matches!(
source,
PairingSource::Gateway {
allow_custom: true,
..
}
),
"an omitted allow-custom must keep the custom gateway input ON"
);
}
#[test]
fn env_file_must_not_be_blank() {
let mut pairing = gateway_pairing();
pairing.env_file = Some(" ".into());
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("must not be empty"), "{err}");
}
#[test]
fn windows_only_path_escapes_are_rejected_on_every_host() {
for escape in [
"C:creds.env",
"creds\\..\\creds.env",
"sub\\creds.env",
"/rooted-without-drive.env",
] {
let mut pairing = gateway_pairing();
pairing.env_file = Some(escape.into());
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("relative"), "{escape}: {err}");
}
}
#[test]
fn an_explicit_node_id_false_is_rejected_like_a_missing_identity() {
let mut pairing = gateway_pairing();
pairing.identity = Some(PairingIdentity {
node_id: false,
name: true,
tier: None,
});
let config = steps_config(vec![
pairing_step(Some(pairing)),
bare_step(StepKind::Install),
]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("node-id"), "{err}");
}
#[test]
fn pairing_toml_accepts_the_kebab_spelling_of_allow_custom() {
#[derive(serde::Deserialize)]
struct StepsDoc {
steps: Vec<StepConfig>,
}
let doc = r#"
[[steps]]
kind = "pairing"
[steps.pairing.source]
kind = "gateway"
official = "https://gateway.example/server"
allow-custom = false
[steps.pairing.identity]
node-id = true
[steps.pairing]
env-file = "product.env"
[[steps]]
kind = "install"
"#;
let parsed: StepsDoc = toml::from_str(doc).expect("kebab spelling parses in TOML");
assert!(
!matches!(
parsed.steps[0].pairing.as_ref().unwrap().source,
PairingSource::Gateway {
allow_custom: true,
..
}
),
"the kebab spelling must not be silently ignored"
);
}
#[test]
fn gateway_source_serializes_with_kebab_tags_and_accepts_both_spellings() {
let source = PairingSource::Gateway {
official: "https://gateway.example/server".into(),
allow_custom: false,
};
let json = serde_json::to_value(&source).unwrap();
assert_eq!(json["kind"], "gateway", "the lane tag round-trips");
assert!(
json.get("allow-custom").is_some() && json.get("allow_custom").is_none(),
"the field serializes kebab-case like every other pairing key"
);
let parse = |body: &str| {
let value: serde_json::Value = serde_json::from_str(body).unwrap();
serde_json::from_value::<PairingSource>(value).unwrap()
};
let kebab = parse(r#"{"kind":"gateway","official":"https://g","allow-custom":false}"#);
let snake = parse(r#"{"kind":"gateway","official":"https://g","allow_custom":false}"#);
assert_eq!(kebab, snake, "both spellings parse to the same config");
assert!(!matches!(
kebab,
PairingSource::Gateway {
allow_custom: true,
..
}
));
}
#[test]
fn pairing_step_without_a_section_is_rejected() {
let config = steps_config(vec![pairing_step(None), bare_step(StepKind::Install)]);
let err = config.resolve_steps(Path::new("."), None).unwrap_err();
assert!(err.to_string().contains("need a"), "{err}");
}
#[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,
pairing: None,
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,
pairing: 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,
pairing: 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"));
}
}