use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use super::{
ArchiveHooksConfig, SignConfig, StringOrBool, StringOrU32, deserialize_string_or_bool_opt,
};
#[derive(Debug, Clone, JsonSchema)]
pub enum ArchivesConfig {
Disabled,
Configs(Vec<ArchiveConfig>),
}
impl Serialize for ArchivesConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
ArchivesConfig::Disabled => serializer.serialize_bool(false),
ArchivesConfig::Configs(configs) => configs.serialize(serializer),
}
}
}
impl Default for ArchivesConfig {
fn default() -> Self {
ArchivesConfig::Configs(vec![])
}
}
pub(super) fn deserialize_archives_config<'de, D>(
deserializer: D,
) -> Result<ArchivesConfig, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct ArchivesVisitor;
impl<'de> Visitor<'de> for ArchivesVisitor {
type Value = ArchivesConfig;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("false or a list of archive configs")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
if !v {
Ok(ArchivesConfig::Disabled)
} else {
Err(E::custom(
"archives: true is not valid; use false or a list",
))
}
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut configs = Vec::new();
while let Some(item) = seq.next_element::<ArchiveConfig>()? {
configs.push(item);
}
Ok(ArchivesConfig::Configs(configs))
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(ArchivesConfig::Configs(vec![]))
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(ArchivesConfig::Configs(vec![]))
}
}
deserializer.deserialize_any(ArchivesVisitor)
}
pub(super) fn deserialize_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct SignsVisitor;
impl<'de> Visitor<'de> for SignsVisitor {
type Value = Vec<SignConfig>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a sign config object or an array of sign config objects")
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut configs = Vec::new();
while let Some(item) = seq.next_element::<SignConfig>()? {
configs.push(item);
}
Ok(configs)
}
fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
let config = SignConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(vec![config])
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(Vec::new())
}
}
deserializer.deserialize_any(SignsVisitor)
}
pub(super) fn deserialize_binary_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
where
D: Deserializer<'de>,
{
let configs = deserialize_signs(deserializer)?;
for (idx, cfg) in configs.iter().enumerate() {
if let Some(art) = cfg.artifacts.as_deref()
&& art != "binary"
&& art != "none"
{
return Err(serde::de::Error::custom(format!(
"binary_signs[{idx}].artifacts: '{art}' is not allowed; \
binary_signs accepts only 'binary' or 'none' (use top-level \
`signs:` for broader artifact filters)"
)));
}
}
Ok(configs)
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum WrapInDirectory {
Bool(bool),
Name(String),
}
impl<'de> serde::Deserialize<'de> for WrapInDirectory {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_yaml_ng::Value::deserialize(deserializer)?;
match value {
serde_yaml_ng::Value::Bool(b) => Ok(WrapInDirectory::Bool(b)),
serde_yaml_ng::Value::String(s) => Ok(WrapInDirectory::Name(s)),
_ => Err(serde::de::Error::custom("expected bool or string")),
}
}
}
impl WrapInDirectory {
pub fn directory_name(&self, default_name: &str) -> Option<String> {
match self {
WrapInDirectory::Bool(true) => Some(default_name.to_string()),
WrapInDirectory::Bool(false) => None,
WrapInDirectory::Name(s) if s.is_empty() => None,
WrapInDirectory::Name(s) => Some(s.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ArchiveConfig {
pub id: Option<String>,
pub name_template: Option<String>,
pub formats: Option<Vec<String>>,
pub format_overrides: Option<Vec<FormatOverride>>,
pub files: Option<Vec<ArchiveFileSpec>>,
pub binaries: Option<Vec<String>>,
pub wrap_in_directory: Option<WrapInDirectory>,
pub ids: Option<Vec<String>>,
pub meta: Option<bool>,
pub builds_info: Option<ArchiveFileInfo>,
pub strip_binary_directory: Option<bool>,
pub allow_different_binary_count: Option<bool>,
pub hooks: Option<ArchiveHooksConfig>,
pub templated_files: Option<Vec<super::TemplateFileConfig>>,
#[serde(rename = "if")]
pub if_condition: Option<String>,
pub completions: Option<super::CompletionsConfig>,
pub manpages: Option<super::ManpagesConfig>,
}
fn fold_format_into_formats(
context_label: &str,
context_kind: &str,
formats: Option<Vec<String>>,
legacy: Option<String>,
) -> Option<Vec<String>> {
let mut formats = formats;
if let Some(legacy) = legacy {
tracing::warn!(
"DEPRECATION: {}[{}]: 'format: {}' is deprecated; \
use 'formats: [{}]' instead.",
context_kind,
context_label,
legacy,
legacy
);
formats.get_or_insert_with(Vec::new).push(legacy);
}
formats
}
impl<'de> Deserialize<'de> for ArchiveConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct Raw {
id: Option<String>,
name_template: Option<String>,
formats: Option<Vec<String>>,
format: Option<String>,
format_overrides: Option<Vec<FormatOverride>>,
files: Option<Vec<ArchiveFileSpec>>,
binaries: Option<Vec<String>>,
wrap_in_directory: Option<WrapInDirectory>,
ids: Option<Vec<String>>,
builds: Option<Vec<String>>,
meta: Option<bool>,
builds_info: Option<ArchiveFileInfo>,
strip_binary_directory: Option<bool>,
allow_different_binary_count: Option<bool>,
hooks: Option<ArchiveHooksConfig>,
templated_files: Option<Vec<super::TemplateFileConfig>>,
#[serde(rename = "if")]
if_condition: Option<String>,
completions: Option<super::CompletionsConfig>,
manpages: Option<super::ManpagesConfig>,
}
let raw = Raw::deserialize(deserializer)?;
let id_label = raw.id.clone().unwrap_or_else(|| "default".to_string());
let formats = fold_format_into_formats(
&format!("id={}", id_label),
"archives",
raw.formats,
raw.format,
);
let mut ids = raw.ids;
if let Some(legacy) = raw.builds {
tracing::warn!(
"DEPRECATION: archives[id={}]: 'builds: {:?}' is deprecated; \
use 'ids: [...]' instead.",
id_label,
legacy
);
let target = ids.get_or_insert_with(Vec::new);
target.extend(legacy);
}
Ok(ArchiveConfig {
id: raw.id.or_else(|| Some("default".to_string())),
name_template: raw.name_template,
formats,
format_overrides: raw.format_overrides,
files: raw.files,
binaries: raw.binaries,
wrap_in_directory: raw.wrap_in_directory,
ids,
meta: raw.meta,
builds_info: raw.builds_info,
strip_binary_directory: raw.strip_binary_directory,
allow_different_binary_count: raw.allow_different_binary_count,
hooks: raw.hooks,
templated_files: raw.templated_files,
if_condition: raw.if_condition,
completions: raw.completions,
manpages: raw.manpages,
})
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FormatOverride {
pub os: String,
pub formats: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for FormatOverride {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct Raw {
os: String,
formats: Option<Vec<String>>,
format: Option<String>,
}
let raw = Raw::deserialize(deserializer)?;
let formats = fold_format_into_formats(
&format!("os={}", raw.os),
"archives.format_overrides",
raw.formats,
raw.format,
);
Ok(FormatOverride {
os: raw.os,
formats,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ArchiveFileSpec {
Glob(String),
Detailed {
src: String,
dst: Option<String>,
info: Option<ArchiveFileInfo>,
strip_parent: Option<bool>,
},
}
impl PartialEq<&str> for ArchiveFileSpec {
fn eq(&self, other: &&str) -> bool {
match self {
ArchiveFileSpec::Glob(s) => s.as_str() == *other,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct FileInfo {
pub owner: Option<String>,
pub group: Option<String>,
pub mode: Option<StringOrU32>,
pub mtime: Option<String>,
}
pub type ArchiveFileInfo = FileInfo;
pub fn parse_octal_mode(s: &str) -> Option<u32> {
let cleaned = s
.strip_prefix("0o")
.or_else(|| s.strip_prefix("0O"))
.unwrap_or(s);
let cleaned = if cleaned.is_empty() { "0" } else { cleaned };
u32::from_str_radix(cleaned, 8).ok()
}
pub const VALID_ARCHIVE_FORMATS: &[&str] = &[
"tar.gz", "tgz", "tar.xz", "txz", "tar.zst", "tzst", "tar", "zip", "gz", "xz", "binary", "none",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ExtraFileSpec {
Glob(String),
Detailed {
glob: String,
#[serde(default)]
name_template: Option<String>,
#[serde(default)]
allow_empty: bool,
},
}
impl ExtraFileSpec {
pub fn glob(&self) -> &str {
match self {
ExtraFileSpec::Glob(s) => s,
ExtraFileSpec::Detailed { glob, .. } => glob,
}
}
pub fn name_template(&self) -> Option<&str> {
match self {
ExtraFileSpec::Glob(_) => None,
ExtraFileSpec::Detailed { name_template, .. } => name_template.as_deref(),
}
}
pub fn allow_empty(&self) -> bool {
match self {
ExtraFileSpec::Glob(_) => false,
ExtraFileSpec::Detailed { allow_empty, .. } => *allow_empty,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct TemplatedExtraFile {
pub src: String,
pub dst: Option<String>,
pub mode: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ChecksumSplitFormat {
#[default]
Bare,
Coreutils,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ChecksumConfig {
pub name_template: Option<String>,
pub algorithm: Option<String>,
#[serde(
alias = "disable",
deserialize_with = "deserialize_string_or_bool_opt",
default
)]
pub skip: Option<StringOrBool>,
pub extra_files: Option<Vec<ExtraFileSpec>>,
pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
pub ids: Option<Vec<String>>,
pub split: Option<bool>,
pub split_format: Option<ChecksumSplitFormat>,
}
impl ChecksumConfig {
pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ ProjectName }}_{{ Version }}_checksums.txt";
pub const DEFAULT_ALGORITHM: &'static str = "sha256";
pub const SUPPORTED_ALGORITHMS: &'static [&'static str] = &[
"sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384",
"sha3-512", "blake2b", "blake2s", "blake3", "crc32", "md5",
];
pub fn resolved_algorithm(&self) -> &str {
self.algorithm.as_deref().unwrap_or(Self::DEFAULT_ALGORITHM)
}
pub fn resolved_split(&self) -> bool {
self.split.unwrap_or(false)
}
pub fn resolved_combined_name_template(&self) -> &str {
self.name_template
.as_deref()
.unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
}
pub fn resolve_combined_name_template<'a>(
crate_checksum: Option<&'a ChecksumConfig>,
global_checksum: Option<&'a ChecksumConfig>,
) -> &'a str {
crate_checksum
.and_then(|c| c.name_template.as_deref())
.or_else(|| global_checksum.and_then(|c| c.name_template.as_deref()))
.unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ContentSource {
Inline(String),
FromFile {
from_file: String,
},
FromUrl {
from_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
},
}
impl PartialEq for ContentSource {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Inline(a), Self::Inline(b)) => a == b,
(Self::FromFile { from_file: a }, Self::FromFile { from_file: b }) => a == b,
(
Self::FromUrl {
from_url: a,
headers: ha,
},
Self::FromUrl {
from_url: b,
headers: hb,
},
) => a == b && ha == hb,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct ArchivesWrapper {
#[serde(default, deserialize_with = "deserialize_archives_config")]
archives: ArchivesConfig,
}
#[test]
fn archives_false_is_disabled() {
let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: false").unwrap();
assert!(matches!(w.archives, ArchivesConfig::Disabled));
}
#[test]
fn archives_true_is_rejected() {
let r: Result<ArchivesWrapper, _> = serde_yaml_ng::from_str("archives: true");
assert!(r.is_err(), "archives: true must be rejected");
}
#[test]
fn archives_list_becomes_configs() {
let w: ArchivesWrapper =
serde_yaml_ng::from_str("archives:\n - id: a\n - id: b\n").unwrap();
match w.archives {
ArchivesConfig::Configs(c) => assert_eq!(c.len(), 2),
other => panic!("expected Configs, got {other:?}"),
}
}
#[test]
fn archives_null_defaults_to_empty_configs() {
let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: null").unwrap();
match w.archives {
ArchivesConfig::Configs(c) => assert!(c.is_empty()),
other => panic!("expected empty Configs, got {other:?}"),
}
}
#[derive(Deserialize)]
struct SignsWrapper {
#[serde(default, deserialize_with = "deserialize_signs")]
signs: Vec<SignConfig>,
}
#[test]
fn signs_single_object_becomes_one_element_vec() {
let w: SignsWrapper = serde_yaml_ng::from_str("signs:\n artifacts: all\n").unwrap();
assert_eq!(w.signs.len(), 1);
assert_eq!(w.signs[0].artifacts.as_deref(), Some("all"));
}
#[test]
fn signs_sequence_collects_all() {
let w: SignsWrapper =
serde_yaml_ng::from_str("signs:\n - artifacts: all\n - artifacts: checksum\n")
.unwrap();
assert_eq!(w.signs.len(), 2);
}
#[test]
fn signs_null_is_empty_vec() {
let w: SignsWrapper = serde_yaml_ng::from_str("signs: null").unwrap();
assert!(w.signs.is_empty());
}
#[derive(Deserialize)]
struct BinarySignsWrapper {
#[serde(default, deserialize_with = "deserialize_binary_signs")]
binary_signs: Vec<SignConfig>,
}
#[test]
fn binary_signs_accepts_binary_and_none() {
let w: BinarySignsWrapper =
serde_yaml_ng::from_str("binary_signs:\n - artifacts: binary\n").unwrap();
assert_eq!(w.binary_signs.len(), 1);
let w2: BinarySignsWrapper =
serde_yaml_ng::from_str("binary_signs:\n - artifacts: none\n").unwrap();
assert_eq!(w2.binary_signs.len(), 1);
}
#[test]
fn binary_signs_rejects_broad_artifact_filter() {
let r: Result<BinarySignsWrapper, _> =
serde_yaml_ng::from_str("binary_signs:\n - artifacts: all\n");
assert!(
r.is_err(),
"binary_signs must reject a non-binary artifact filter"
);
}
#[test]
fn wrap_in_directory_bool_true_uses_default_name() {
assert_eq!(
WrapInDirectory::Bool(true).directory_name("myapp_1.0"),
Some("myapp_1.0".to_string())
);
}
#[test]
fn wrap_in_directory_bool_false_disables_wrapping() {
assert_eq!(
WrapInDirectory::Bool(false).directory_name("myapp_1.0"),
None
);
}
#[test]
fn wrap_in_directory_empty_string_disables_wrapping() {
assert_eq!(
WrapInDirectory::Name(String::new()).directory_name("fallback"),
None
);
}
#[test]
fn wrap_in_directory_custom_name_overrides_default() {
assert_eq!(
WrapInDirectory::Name("custom".into()).directory_name("fallback"),
Some("custom".to_string())
);
}
#[derive(Deserialize)]
struct WrapWrapper {
wrap_in_directory: WrapInDirectory,
}
#[test]
fn wrap_in_directory_deserializes_bool_and_string() {
let b: WrapWrapper = serde_yaml_ng::from_str("wrap_in_directory: true").unwrap();
assert_eq!(b.wrap_in_directory, WrapInDirectory::Bool(true));
let s: WrapWrapper = serde_yaml_ng::from_str("wrap_in_directory: dist").unwrap();
assert_eq!(s.wrap_in_directory, WrapInDirectory::Name("dist".into()));
}
#[test]
fn wrap_in_directory_rejects_non_scalar() {
let r: Result<WrapWrapper, _> = serde_yaml_ng::from_str("wrap_in_directory:\n - a\n");
assert!(r.is_err());
}
#[test]
fn parse_octal_mode_accepts_common_forms() {
assert_eq!(parse_octal_mode("0755"), Some(0o755));
assert_eq!(parse_octal_mode("0o755"), Some(0o755));
assert_eq!(parse_octal_mode("0O755"), Some(0o755));
assert_eq!(parse_octal_mode("755"), Some(0o755));
assert_eq!(parse_octal_mode("0o"), Some(0));
assert_eq!(parse_octal_mode("0"), Some(0));
}
#[test]
fn parse_octal_mode_rejects_non_octal() {
assert_eq!(parse_octal_mode("0o899"), None);
assert_eq!(parse_octal_mode("garbage"), None);
}
#[test]
fn resolve_combined_name_template_prefers_crate_then_global_then_default() {
let crate_cfg = ChecksumConfig {
name_template: Some("crate.txt".into()),
..Default::default()
};
let global_cfg = ChecksumConfig {
name_template: Some("global.txt".into()),
..Default::default()
};
assert_eq!(
ChecksumConfig::resolve_combined_name_template(Some(&crate_cfg), Some(&global_cfg)),
"crate.txt"
);
let bare = ChecksumConfig::default();
assert_eq!(
ChecksumConfig::resolve_combined_name_template(Some(&bare), Some(&global_cfg)),
"global.txt"
);
assert_eq!(
ChecksumConfig::resolve_combined_name_template(None, None),
ChecksumConfig::DEFAULT_NAME_TEMPLATE
);
}
#[test]
fn checksum_split_format_defaults_to_bare() {
assert_eq!(ChecksumSplitFormat::default(), ChecksumSplitFormat::Bare);
}
#[test]
fn checksum_split_format_deserializes_lowercase() {
assert_eq!(
serde_yaml_ng::from_str::<ChecksumSplitFormat>("bare").unwrap(),
ChecksumSplitFormat::Bare
);
assert_eq!(
serde_yaml_ng::from_str::<ChecksumSplitFormat>("coreutils").unwrap(),
ChecksumSplitFormat::Coreutils
);
assert!(serde_yaml_ng::from_str::<ChecksumSplitFormat>("Coreutils").is_err());
}
#[test]
fn extra_file_spec_allow_empty_only_true_for_detailed_opt_in() {
let bare: ExtraFileSpec = serde_yaml_ng::from_str("dist/*.sig").unwrap();
assert!(!bare.allow_empty());
let opt_in: ExtraFileSpec =
serde_yaml_ng::from_str("glob: keys/*.pub\nallow_empty: true").unwrap();
assert!(opt_in.allow_empty());
assert_eq!(opt_in.glob(), "keys/*.pub");
let default_off: ExtraFileSpec = serde_yaml_ng::from_str("glob: docs/*.pdf").unwrap();
assert!(!default_off.allow_empty());
}
#[test]
fn archive_file_spec_str_eq_matches_glob_only() {
assert!(ArchiveFileSpec::Glob("README.md".into()) == "README.md");
assert!(ArchiveFileSpec::Glob("README.md".into()) != "other");
let detailed = ArchiveFileSpec::Detailed {
src: "README.md".into(),
dst: None,
info: None,
strip_parent: None,
};
assert!(detailed != "README.md");
}
#[test]
fn archive_config_folds_singular_format_into_formats() {
let c: ArchiveConfig = serde_yaml_ng::from_str("format: tar.gz").unwrap();
assert_eq!(c.formats.as_deref().unwrap(), ["tar.gz"]);
}
#[test]
fn archive_config_folds_deprecated_builds_into_ids() {
let c: ArchiveConfig = serde_yaml_ng::from_str("ids: [keep]\nbuilds: [legacy]").unwrap();
let ids = c.ids.unwrap();
assert!(ids.contains(&"keep".to_string()));
assert!(ids.contains(&"legacy".to_string()));
}
#[test]
fn archive_config_defaults_id_to_default() {
let c: ArchiveConfig = serde_yaml_ng::from_str("name_template: x").unwrap();
assert_eq!(c.id.as_deref(), Some("default"));
let named: ArchiveConfig = serde_yaml_ng::from_str("id: bins").unwrap();
assert_eq!(named.id.as_deref(), Some("bins"));
}
#[test]
fn format_override_folds_singular_format() {
let o: FormatOverride = serde_yaml_ng::from_str("os: windows\nformat: zip").unwrap();
assert_eq!(o.os, "windows");
assert_eq!(o.formats.as_deref().unwrap(), ["zip"]);
}
#[test]
fn content_source_partial_eq_by_variant_and_payload() {
assert_eq!(
ContentSource::Inline("a".into()),
ContentSource::Inline("a".into())
);
assert_ne!(
ContentSource::Inline("a".into()),
ContentSource::Inline("b".into())
);
assert_ne!(
ContentSource::Inline("a".into()),
ContentSource::FromFile {
from_file: "a".into()
}
);
let mut h = HashMap::new();
h.insert("Accept".to_string(), "text/plain".to_string());
let with_headers = ContentSource::FromUrl {
from_url: "u".into(),
headers: Some(h.clone()),
};
assert_eq!(
with_headers,
ContentSource::FromUrl {
from_url: "u".into(),
headers: Some(h),
}
);
assert_ne!(
with_headers,
ContentSource::FromUrl {
from_url: "u".into(),
headers: None,
}
);
}
#[test]
fn content_source_from_url_deserializes_headers() {
let cs: ContentSource = serde_yaml_ng::from_str(
"from_url: https://example.com/h.md\nheaders:\n X-Token: abc\n",
)
.unwrap();
match cs {
ContentSource::FromUrl { from_url, headers } => {
assert_eq!(from_url, "https://example.com/h.md");
assert_eq!(headers.unwrap().get("X-Token").unwrap(), "abc");
}
other => panic!("expected FromUrl, got {other:?}"),
}
}
#[test]
fn templated_extra_file_parses_and_defaults() {
let full: TemplatedExtraFile = serde_yaml_ng::from_str(
"src: NOTES.tera\ndst: \"{{ ProjectName }}-NOTES.txt\"\nmode: \"0644\"",
)
.unwrap();
assert_eq!(full.src, "NOTES.tera");
assert_eq!(full.dst.as_deref(), Some("{{ ProjectName }}-NOTES.txt"));
assert_eq!(full.mode.as_deref(), Some("0644"));
let minimal: TemplatedExtraFile = serde_yaml_ng::from_str("src: NOTES.tera").unwrap();
assert!(minimal.dst.is_none());
assert!(minimal.mode.is_none());
assert!(serde_yaml_ng::from_str::<TemplatedExtraFile>("src: x\nbogus: y").is_err());
}
}