Skip to main content

anodizer_core/
extrafiles.rs

1//! Shared `extra_files` glob resolution.
2//!
3//! Canonical implementation of the GoReleaser `extrafiles.Find()` function —
4//! a single resolver used by every pipe that accepts an `extra_files:` config
5//! (checksum, blob upload, custom publisher, artifactory/fury/cloudsmith,
6//! release body uploads).
7//!
8//! Semantics match `goreleaser/internal/extrafiles/extra_files.go`:
9//! - An empty glob (after template rendering) emits a warning and is skipped.
10//! - Glob expansion errors bubble up as `Err`.
11//! - If a `name_template` is set on a spec, the glob must match **exactly one**
12//!   file — multi-match with a name template is an error (you can't give many
13//!   files the same overridden name).
14//! - Directory entries are filtered out.
15//! - Duplicate paths across multiple specs are deduplicated (first wins).
16//! - The returned list is sorted by path for deterministic output.
17
18use std::collections::HashSet;
19use std::path::PathBuf;
20
21use anyhow::{Context, Result, bail};
22
23use crate::config::ExtraFileSpec;
24use crate::log::StageLogger;
25
26/// One resolved `extra_files` entry.
27///
28/// `name_template` is the raw, unrendered template string from the user's
29/// config (e.g. `"{{ .ProjectName }}.txt"`). Callers render it with their own
30/// `TemplateVars` so they can inject per-site variables (e.g. stage-blob sets
31/// a `Filename` var so users can write `"renamed-{{ .Filename }}"`).
32#[derive(Debug, Clone)]
33pub struct ResolvedExtraFile {
34    pub path: PathBuf,
35    pub name_template: Option<String>,
36}
37
38/// Resolve a list of `ExtraFileSpec`s into deduplicated, path-sorted resolved
39/// entries. See module docs for the full semantic.
40pub fn resolve(specs: &[ExtraFileSpec], log: &StageLogger) -> Result<Vec<ResolvedExtraFile>> {
41    let mut seen: HashSet<PathBuf> = HashSet::new();
42    let mut out: Vec<ResolvedExtraFile> = Vec::new();
43
44    for spec in specs {
45        let pattern = spec.glob();
46        let name_tmpl = spec.name_template().map(str::to_owned);
47
48        if pattern.is_empty() {
49            log.warn("extra_files: ignoring empty glob");
50            continue;
51        }
52
53        let matches: Vec<PathBuf> = glob::glob(pattern)
54            .with_context(|| format!("extra_files: invalid glob '{pattern}'"))?
55            .collect::<std::result::Result<Vec<_>, _>>()
56            .with_context(|| format!("extra_files: error expanding glob '{pattern}'"))?;
57
58        if matches.is_empty() {
59            log.warn(&format!(
60                "extra_files: glob '{pattern}' matched no files, skipping"
61            ));
62            continue;
63        }
64
65        if name_tmpl.is_some() && matches.len() > 1 {
66            bail!(
67                "extra_files: glob '{}' with name_template matched {} files (must match exactly one)",
68                pattern,
69                matches.len()
70            );
71        }
72
73        for path in matches.into_iter().filter(|p| p.is_file()) {
74            if seen.insert(path.clone()) {
75                out.push(ResolvedExtraFile {
76                    path,
77                    name_template: name_tmpl.clone(),
78                });
79            }
80        }
81    }
82
83    out.sort_by(|a, b| a.path.cmp(&b.path));
84    Ok(out)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use tempfile::TempDir;
91
92    fn log() -> StageLogger {
93        StageLogger::new("test", crate::log::Verbosity::Quiet)
94    }
95
96    #[test]
97    fn empty_specs_returns_empty() {
98        let result = resolve(&[], &log()).unwrap();
99        assert!(result.is_empty());
100    }
101
102    #[test]
103    fn empty_glob_is_skipped() {
104        let specs = vec![ExtraFileSpec::Glob(String::new())];
105        let result = resolve(&specs, &log()).unwrap();
106        assert!(result.is_empty());
107    }
108
109    #[test]
110    fn no_match_is_skipped_not_error() {
111        let specs = vec![ExtraFileSpec::Glob(
112            "/tmp/nonexistent-prefix-xyz-*.bin".to_string(),
113        )];
114        let result = resolve(&specs, &log()).unwrap();
115        assert!(result.is_empty());
116    }
117
118    #[test]
119    fn multi_match_with_name_template_errors() {
120        let tmp = TempDir::new().unwrap();
121        std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
122        std::fs::write(tmp.path().join("b.bin"), b"b").unwrap();
123
124        let glob_pattern = format!("{}/*.bin", tmp.path().display());
125        let specs = vec![ExtraFileSpec::Detailed {
126            glob: glob_pattern,
127            name_template: Some("collapsed.bin".to_string()),
128            allow_empty: false,
129        }];
130
131        let err = resolve(&specs, &log()).unwrap_err();
132        assert!(err.to_string().contains("must match exactly one"));
133    }
134
135    #[test]
136    fn dedupes_across_specs() {
137        let tmp = TempDir::new().unwrap();
138        std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
139
140        let glob1 = format!("{}/*.bin", tmp.path().display());
141        let glob2 = format!("{}/a.bin", tmp.path().display());
142        let specs = vec![ExtraFileSpec::Glob(glob1), ExtraFileSpec::Glob(glob2)];
143
144        let result = resolve(&specs, &log()).unwrap();
145        assert_eq!(result.len(), 1);
146    }
147
148    #[test]
149    fn results_sorted_by_path() {
150        let tmp = TempDir::new().unwrap();
151        std::fs::write(tmp.path().join("c.bin"), b"c").unwrap();
152        std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
153        std::fs::write(tmp.path().join("b.bin"), b"b").unwrap();
154
155        let specs = vec![ExtraFileSpec::Glob(format!(
156            "{}/*.bin",
157            tmp.path().display()
158        ))];
159        let result = resolve(&specs, &log()).unwrap();
160        assert_eq!(result.len(), 3);
161        assert!(result[0].path.to_string_lossy().ends_with("a.bin"));
162        assert!(result[1].path.to_string_lossy().ends_with("b.bin"));
163        assert!(result[2].path.to_string_lossy().ends_with("c.bin"));
164    }
165
166    #[test]
167    fn directories_filtered_out() {
168        let tmp = TempDir::new().unwrap();
169        std::fs::create_dir(tmp.path().join("subdir")).unwrap();
170        std::fs::write(tmp.path().join("real.bin"), b"x").unwrap();
171
172        let specs = vec![ExtraFileSpec::Glob(format!("{}/*", tmp.path().display()))];
173        let result = resolve(&specs, &log()).unwrap();
174        assert_eq!(result.len(), 1);
175        assert!(result[0].path.to_string_lossy().ends_with("real.bin"));
176    }
177}