1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use super::StringOrU32;
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
13#[serde(default, deny_unknown_fields)]
14pub struct SourceFileEntry {
15 pub src: String,
17 pub dst: Option<String>,
19 pub strip_parent: Option<bool>,
21 pub info: Option<SourceFileInfo>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
27#[serde(default, deny_unknown_fields)]
28pub struct SourceFileInfo {
29 pub owner: Option<String>,
31 pub group: Option<String>,
33 pub mode: Option<StringOrU32>,
37 pub mtime: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
42#[serde(default, deny_unknown_fields)]
43pub struct SourceConfig {
44 pub enabled: Option<bool>,
46 pub format: Option<String>,
48 pub name_template: Option<String>,
50 pub prefix_template: Option<String>,
53 #[serde(default, deserialize_with = "deserialize_source_files")]
55 #[schemars(schema_with = "source_files_schema")]
56 pub files: Vec<SourceFileEntry>,
57}
58
59impl SourceConfig {
60 pub fn is_enabled(&self) -> bool {
62 self.enabled.unwrap_or(false)
63 }
64
65 pub fn archive_format(&self) -> &str {
67 self.format.as_deref().unwrap_or("tar.gz")
68 }
69
70 pub fn apply_prefix_template_default(&mut self) {
83 if self.prefix_template.is_none()
84 && let Some(ref name_tpl) = self.name_template
85 {
86 self.prefix_template = Some(name_tpl.clone());
87 }
88 }
89}
90
91fn source_files_schema(
93 generator: &mut schemars::r#gen::SchemaGenerator,
94) -> schemars::schema::Schema {
95 let mut schema = generator.subschema_for::<Vec<SourceFileEntry>>();
96 if let schemars::schema::Schema::Object(ref mut obj) = schema {
97 obj.metadata().description = Some(
98 "Extra files for the source archive. Accepts strings (glob patterns), objects with src/dst/info, or a mixed array.".to_owned(),
99 );
100 }
101 schema
102}
103
104fn deserialize_source_files<'de, D>(deserializer: D) -> Result<Vec<SourceFileEntry>, D::Error>
111where
112 D: Deserializer<'de>,
113{
114 use serde::de::{self, SeqAccess, Visitor};
115
116 struct SourceFilesVisitor;
117
118 impl<'de> Visitor<'de> for SourceFilesVisitor {
119 type Value = Vec<SourceFileEntry>;
120
121 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.write_str("a string, a source file entry object, or an array of strings/objects")
123 }
124
125 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
126 Ok(vec![SourceFileEntry {
127 src: v.to_string(),
128 ..Default::default()
129 }])
130 }
131
132 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
133 let mut entries = Vec::new();
134 while let Some(value) = seq.next_element::<serde_yaml_ng::Value>()? {
135 match value {
136 serde_yaml_ng::Value::String(s) => {
137 entries.push(SourceFileEntry {
138 src: s,
139 ..Default::default()
140 });
141 }
142 other => {
143 let entry =
144 SourceFileEntry::deserialize(other).map_err(de::Error::custom)?;
145 entries.push(entry);
146 }
147 }
148 }
149 Ok(entries)
150 }
151
152 fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
153 let entry = SourceFileEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
154 Ok(vec![entry])
155 }
156
157 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
158 Ok(Vec::new())
159 }
160
161 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
162 Ok(Vec::new())
163 }
164 }
165
166 deserializer.deserialize_any(SourceFilesVisitor)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn parse(yaml: &str) -> SourceConfig {
174 serde_yaml_ng::from_str(yaml).expect("valid SourceConfig YAML")
175 }
176
177 #[test]
178 fn is_enabled_defaults_false_when_unset() {
179 let cfg = SourceConfig::default();
180 assert!(!cfg.is_enabled());
181 }
182
183 #[test]
184 fn is_enabled_honors_explicit_values() {
185 assert!(parse("enabled: true").is_enabled());
186 assert!(!parse("enabled: false").is_enabled());
187 }
188
189 #[test]
190 fn archive_format_defaults_to_tar_gz() {
191 assert_eq!(SourceConfig::default().archive_format(), "tar.gz");
192 }
193
194 #[test]
195 fn archive_format_uses_explicit_format() {
196 assert_eq!(parse("format: zip").archive_format(), "zip");
197 }
198
199 #[test]
200 fn prefix_default_copies_name_template_when_prefix_unset() {
201 let mut cfg = parse("name_template: \"{{ .ProjectName }}-{{ .Version }}\"");
202 assert!(cfg.prefix_template.is_none());
203 cfg.apply_prefix_template_default();
204 assert_eq!(
205 cfg.prefix_template.as_deref(),
206 Some("{{ .ProjectName }}-{{ .Version }}")
207 );
208 }
209
210 #[test]
211 fn prefix_default_preserves_explicit_prefix() {
212 let mut cfg = parse("name_template: name-tpl\nprefix_template: prefix-tpl");
213 cfg.apply_prefix_template_default();
214 assert_eq!(cfg.prefix_template.as_deref(), Some("prefix-tpl"));
215 }
216
217 #[test]
218 fn prefix_default_noop_when_name_template_also_unset() {
219 let mut cfg = SourceConfig::default();
220 cfg.apply_prefix_template_default();
221 assert!(cfg.prefix_template.is_none());
222 }
223
224 #[test]
225 fn files_missing_is_empty_vec() {
226 assert!(parse("enabled: true").files.is_empty());
227 }
228
229 #[test]
230 fn files_single_string_becomes_one_entry() {
231 let cfg = parse("files: LICENSE");
232 assert_eq!(cfg.files.len(), 1);
233 assert_eq!(cfg.files[0].src, "LICENSE");
234 assert!(cfg.files[0].dst.is_none());
235 }
236
237 #[test]
238 fn files_single_object_becomes_one_entry() {
239 let cfg = parse("files:\n src: README.md\n dst: docs/README.md");
240 assert_eq!(cfg.files.len(), 1);
241 assert_eq!(cfg.files[0].src, "README.md");
242 assert_eq!(cfg.files[0].dst.as_deref(), Some("docs/README.md"));
243 }
244
245 #[test]
246 fn files_mixed_array_parses_strings_and_objects() {
247 let cfg = parse("files:\n - LICENSE\n - src: README.md\n dst: docs/README.md\n");
248 assert_eq!(cfg.files.len(), 2);
249 assert_eq!(cfg.files[0].src, "LICENSE");
250 assert!(cfg.files[0].dst.is_none());
251 assert_eq!(cfg.files[1].src, "README.md");
252 assert_eq!(cfg.files[1].dst.as_deref(), Some("docs/README.md"));
253 }
254
255 #[test]
256 fn files_null_is_empty_vec() {
257 let cfg = parse("files: null");
258 assert!(cfg.files.is_empty());
259 }
260
261 #[test]
262 fn file_entry_info_mode_parses_octal_string() {
263 let cfg = parse(
264 "files:\n - src: bin/app\n info:\n owner: root\n mode: \"0o755\"\n",
265 );
266 let info = cfg.files[0].info.as_ref().expect("info present");
267 assert_eq!(info.owner.as_deref(), Some("root"));
268 assert_eq!(info.mode.map(|m| m.value()), Some(0o755));
269 }
270
271 #[test]
272 fn deny_unknown_fields_rejects_typos() {
273 let err = serde_yaml_ng::from_str::<SourceConfig>("enabledd: true");
274 assert!(err.is_err(), "unknown field must be rejected");
275 }
276
277 #[test]
278 fn archive_format_passes_through_tgz_tar_and_zip() {
279 assert_eq!(parse("format: tgz").archive_format(), "tgz");
282 assert_eq!(parse("format: tar").archive_format(), "tar");
283 assert_eq!(parse("format: zip").archive_format(), "zip");
284 }
285
286 #[test]
287 fn prefix_default_keeps_explicit_prefix_when_name_template_absent() {
288 let mut cfg = parse("prefix_template: only-prefix");
290 cfg.apply_prefix_template_default();
291 assert_eq!(cfg.prefix_template.as_deref(), Some("only-prefix"));
292 }
293
294 #[test]
295 fn file_entry_strip_parent_round_trips() {
296 let cfg = parse("files:\n - src: a/b/c.txt\n strip_parent: true\n");
297 assert_eq!(cfg.files[0].strip_parent, Some(true));
298 let bare = parse("files: a/b/c.txt");
300 assert_eq!(bare.files[0].strip_parent, None);
301 }
302
303 #[test]
304 fn file_info_mode_accepts_bare_decimal_int() {
305 let cfg = parse("files:\n - src: bin/app\n info:\n mode: 493\n");
308 let info = cfg.files[0].info.as_ref().expect("info present");
309 assert_eq!(info.mode.map(|m| m.value()), Some(0o755));
310 }
311
312 #[test]
313 fn file_info_captures_group_and_mtime() {
314 let cfg = parse(
315 "files:\n - src: f\n info:\n group: staff\n mtime: \"2024-01-01T00:00:00Z\"\n",
316 );
317 let info = cfg.files[0].info.as_ref().unwrap();
318 assert_eq!(info.group.as_deref(), Some("staff"));
319 assert_eq!(info.mtime.as_deref(), Some("2024-01-01T00:00:00Z"));
320 assert!(info.owner.is_none());
321 }
322
323 #[test]
324 fn nested_file_entry_rejects_unknown_field() {
325 let err = serde_yaml_ng::from_str::<SourceConfig>("files:\n - src: f\n bogus: 1\n");
326 assert!(err.is_err(), "SourceFileEntry must deny unknown fields");
327 }
328
329 #[test]
330 fn nested_file_info_rejects_unknown_field() {
331 let err = serde_yaml_ng::from_str::<SourceConfig>(
332 "files:\n - src: f\n info:\n moed: 7\n",
333 );
334 assert!(err.is_err(), "SourceFileInfo must deny unknown fields");
335 }
336
337 #[test]
338 fn empty_array_files_is_empty_vec() {
339 let cfg = parse("files: []");
341 assert!(cfg.files.is_empty());
342 }
343
344 #[test]
345 fn files_array_of_objects_only() {
346 let cfg = parse("files:\n - src: a\n dst: x/a\n - src: b\n");
347 assert_eq!(cfg.files.len(), 2);
348 assert_eq!(cfg.files[0].dst.as_deref(), Some("x/a"));
349 assert_eq!(cfg.files[1].src, "b");
350 assert!(cfg.files[1].dst.is_none());
351 }
352
353 #[test]
354 fn config_round_trips_through_serde() {
355 let cfg = parse(
356 "enabled: true\nformat: zip\nname_template: src-{{ .Version }}\nfiles:\n - LICENSE\n",
357 );
358 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
359 let back: SourceConfig = serde_yaml_ng::from_str(&yaml).unwrap();
360 assert!(back.is_enabled());
361 assert_eq!(back.archive_format(), "zip");
362 assert_eq!(back.files.len(), 1);
363 assert_eq!(back.files[0].src, "LICENSE");
364 }
365}