Skip to main content

anodizer_core/config/
source.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use super::StringOrU32;
5
6// ---------------------------------------------------------------------------
7// SourceConfig
8// ---------------------------------------------------------------------------
9
10/// An individual file entry for the source archive, supporting src/dst mapping
11/// and file metadata overrides.
12#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
13#[serde(default, deny_unknown_fields)]
14pub struct SourceFileEntry {
15    /// Source file path or glob pattern.
16    pub src: String,
17    /// Destination path within the archive prefix directory.
18    pub dst: Option<String>,
19    /// Strip the parent directory from the source path.
20    pub strip_parent: Option<bool>,
21    /// File metadata overrides.
22    pub info: Option<SourceFileInfo>,
23}
24
25/// File metadata overrides for source archive entries.
26#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
27#[serde(default, deny_unknown_fields)]
28pub struct SourceFileInfo {
29    /// File owner.
30    pub owner: Option<String>,
31    /// File group.
32    pub group: Option<String>,
33    /// File permissions mode. Accepts a YAML int (decimal) or an
34    /// octal-prefixed string (`"0o755"`, `"0755"`). Stored as a `u32` after
35    /// parsing — see [`StringOrU32`].
36    pub mode: Option<StringOrU32>,
37    /// Modification time in RFC3339 format (supports templates).
38    pub mtime: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
42#[serde(default, deny_unknown_fields)]
43pub struct SourceConfig {
44    /// When true, generate a source code archive for the release.
45    pub enabled: Option<bool>,
46    /// Archive format for the source tarball: tar.gz, tgz, tar, or zip (default: tar.gz).
47    pub format: Option<String>,
48    /// Filename template for the source archive (supports templates).
49    pub name_template: Option<String>,
50    /// Prefix prepended to all paths inside the archive (supports templates).
51    /// Defaults to name_template value. Use this to set a different prefix than the archive name.
52    pub prefix_template: Option<String>,
53    /// Extra files to include in the source archive. Accepts strings (glob patterns) or objects with src/dst/info.
54    #[serde(default, deserialize_with = "deserialize_source_files")]
55    #[schemars(schema_with = "source_files_schema")]
56    pub files: Vec<SourceFileEntry>,
57}
58
59impl SourceConfig {
60    /// Whether source archive generation is enabled (default: false).
61    pub fn is_enabled(&self) -> bool {
62        self.enabled.unwrap_or(false)
63    }
64
65    /// Archive format to use (default: "tar.gz").
66    pub fn archive_format(&self) -> &str {
67        self.format.as_deref().unwrap_or("tar.gz")
68    }
69
70    /// Apply defaults to `prefix_template`: when unset and `name_template` is
71    /// set, default `prefix_template` to the same string as `name_template`.
72    ///
73    /// Q-src3: the doc has long claimed "Defaults to `name_template` value",
74    /// but downstream consumers were reading the raw `Option<String>` and
75    /// substituting empty string. Filling the field at defaults-resolution
76    /// time honors the documented contract — stage code that already
77    /// renders the (now-Some) field needs no behavioral change.
78    ///
79    /// Defaults to
80    /// empty; this is anodize-additive (more ergonomic default), aligning
81    /// behavior with the long-standing doc.
82    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
91/// Helper schema function for the source files field (accepts strings, objects, or mixed arrays).
92fn 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
104/// Custom deserializer for the source `files` field.
105/// Accepts:
106///   - null/missing → empty vec (via serde default)
107///   - a single string → vec of one SourceFileEntry with that src
108///   - a single object → vec of one SourceFileEntry
109///   - an array of mixed strings/objects → vec of SourceFileEntry
110fn 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        // archive_format only overrides the default when format is Some; each
280        // explicit value must survive verbatim (no normalization).
281        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        // The unset-name guard must not clobber a prefix the user set alone.
289        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        // unset on a plain string entry, not defaulted to a value
299        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        // A bare YAML int is decimal: 493 == 0o755. Distinct path from the
306        // octal-prefixed-string case already covered above.
307        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        // visit_seq with zero elements is a distinct branch from visit_none.
340        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}