anodizer_stage_source/
run.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context as _, Result};
7
8use anodizer_core::artifact::{Artifact, ArtifactKind};
9use anodizer_core::config::SourceFileEntry;
10use anodizer_core::context::Context;
11use anodizer_core::stage::Stage;
12
13use crate::archive::{SourceArchiveInputs, create_source_archive, get_repo_root};
14
15pub struct SourceStage;
16
17impl Stage for SourceStage {
18 fn name(&self) -> &str {
19 "source"
20 }
21
22 fn run(&self, ctx: &mut Context) -> Result<()> {
23 let log = ctx.logger("source");
24 let source_enabled = ctx
25 .config
26 .source
27 .as_ref()
28 .map(|s| s.is_enabled())
29 .unwrap_or(false);
30
31 if !source_enabled {
32 log.status("skipped source archive — not enabled");
33 return Ok(());
34 }
35
36 let dist = ctx.config.dist.clone();
37 if !ctx.is_dry_run() {
38 std::fs::create_dir_all(&dist).with_context(|| {
39 format!("source: failed to create dist dir: {}", dist.display())
40 })?;
41 }
42
43 self.run_source_archive(ctx, &dist)?;
44
45 Ok(())
46 }
47}
48
49impl SourceStage {
50 fn run_source_archive(&self, ctx: &mut Context, dist: &Path) -> Result<()> {
51 let source_cfg = ctx
52 .config
53 .source
54 .as_ref()
55 .context("source stage invoked without source config (programmer bug)")?;
56 let format = source_cfg.archive_format().to_string();
57
58 let project_name = ctx.config.project_name.clone();
62 let version = ctx
63 .template_vars()
64 .get("Version")
65 .cloned()
66 .unwrap_or_else(|| "unknown".to_string());
67
68 let name = if let Some(ref tpl) = source_cfg.name_template {
69 ctx.render_template(tpl)
70 .with_context(|| format!("source: failed to render name_template '{}'", tpl))?
71 } else {
72 format!("{}-{}", project_name, version)
73 };
74 if name.is_empty() {
81 anyhow::bail!(
82 "source: rendered source archive name is empty. The configured \
83 `source.name_template` rendered to '' (or both `project_name` \
84 and Version were empty when the template fell back to the \
85 `<project>-<version>` default). An empty name produces a \
86 hidden output path (`dist/.{}`) that downstream stages \
87 (checksum, sign, release) cannot resolve. Set \
88 `source.name_template:` explicitly or verify `project_name` is \
89 populated in the config.",
90 format,
91 );
92 }
93
94 let prefix = if let Some(ref tpl) = source_cfg.prefix_template {
97 ctx.render_template(tpl)
98 .with_context(|| format!("source: failed to render prefix_template '{}'", tpl))?
99 } else {
100 String::new()
101 };
102
103 let source_prefix = prefix
114 .strip_suffix('/')
115 .map(str::to_string)
116 .unwrap_or_default();
117
118 let log = ctx.logger("source");
119
120 let cwd = ctx
121 .options
122 .project_root
123 .clone()
124 .or_else(|| std::env::current_dir().ok())
125 .unwrap_or_else(|| PathBuf::from("."));
126 let repo_root = get_repo_root(&cwd, &log)?;
127
128 let mut extra_files: Vec<SourceFileEntry> = Vec::new();
132 for entry in &source_cfg.files {
133 let rendered_src = ctx.render_template(&entry.src).with_context(|| {
134 format!("source: render extra files src template '{}'", entry.src)
135 })?;
136
137 let pattern = if Path::new(&rendered_src).is_absolute() {
138 rendered_src.clone()
139 } else {
140 repo_root.join(&rendered_src).to_string_lossy().into_owned()
141 };
142
143 let expanded_for_entry = match glob::glob(&pattern) {
144 Ok(paths) => {
145 let expanded: Vec<_> = paths
146 .filter_map(|p| p.ok())
147 .filter(|p| p.is_file())
148 .map(|p| SourceFileEntry {
149 src: p.to_string_lossy().into_owned(),
150 dst: entry.dst.clone(),
151 strip_parent: entry.strip_parent,
152 info: entry.info.clone(),
153 })
154 .collect();
155 if expanded.is_empty() {
156 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
157 log.warn(&format!("extra file pattern {pattern:?} matched no files"));
158 }
159 vec![SourceFileEntry {
160 src: rendered_src,
161 dst: entry.dst.clone(),
162 strip_parent: entry.strip_parent,
163 info: entry.info.clone(),
164 }]
165 } else {
166 expanded
167 }
168 }
169 Err(e) => {
170 log.warn(&format!(
171 "extra file pattern {pattern:?} is not a valid glob ({e}); \
172 treating as literal path"
173 ));
174 vec![SourceFileEntry {
175 src: rendered_src,
176 dst: entry.dst.clone(),
177 strip_parent: entry.strip_parent,
178 info: entry.info.clone(),
179 }]
180 }
181 };
182 extra_files.extend(expanded_for_entry);
183 }
184
185 ctx.template_vars_mut().set("SourcePrefix", &source_prefix);
192
193 if ctx.is_dry_run() {
194 log.status(&format!(
195 "(dry-run) would create {}.{} archive",
196 name, format
197 ));
198 return Ok(());
199 }
200
201 log.status(&format!("creating {}.{} archive...", name, format));
202 let resolved_commit: String;
209 let commit: &str = match ctx.git_info.as_ref() {
210 Some(info) if !info.commit.is_empty() => info.commit.as_str(),
211 _ => {
212 resolved_commit = anodizer_core::git::get_head_commit()
213 .with_context(|| "source: failed to resolve HEAD via `git rev-parse HEAD`")?;
214 resolved_commit.as_str()
215 }
216 };
217 let sde_mtime = ctx
218 .env_var("SOURCE_DATE_EPOCH")
219 .and_then(|s| s.parse::<u64>().ok());
220 let output_path = create_source_archive(&SourceArchiveInputs {
221 dist,
222 format: &format,
223 name: &name,
224 prefix: &prefix,
225 extra_files: &extra_files,
226 repo_root: &repo_root,
227 commit,
228 log: &log,
229 strict: ctx.is_strict(),
230 sde_mtime,
231 })?;
232
233 let artifact_name = output_path
235 .file_name()
236 .map(|n| n.to_string_lossy().to_string())
237 .unwrap_or_default();
238
239 let mut metadata = HashMap::new();
240 metadata.insert("format".to_string(), format);
241
242 ctx.artifacts.add(Artifact {
243 kind: ArtifactKind::SourceArchive,
244 name: artifact_name,
245 path: output_path,
246 target: None,
247 crate_name: project_name.clone(),
248 metadata,
249 size: None,
250 });
251
252 Ok(())
253 }
254}