use std::collections::HashSet;
use anyhow::{Context as _, Result};
use crate::config::CrateConfig;
use crate::context::Context;
const PER_CRATE_SCOPED_VARS: &[&str] = &["Version", "RawVersion", "Tag", "ProjectName", "Name"];
pub fn crate_template_overrides(name: &str, tag: &str) -> Result<Vec<(&'static str, String)>> {
let semver = crate::git::parse_semver_tag(tag).with_context(|| {
format!("crate '{name}': release tag '{tag}' is not a parseable semver version")
})?;
Ok(vec![
("RawVersion", semver.raw_version_string()),
("Version", semver.version_string()),
("Tag", tag.to_string()),
("ProjectName", name.to_string()),
("Name", name.to_string()),
])
}
pub fn resolve_crate_tag(ctx: &Context, crate_cfg: &CrateConfig) -> Option<String> {
let monorepo_prefix = ctx.config.monorepo_tag_prefix();
let repo = ctx
.options
.project_root
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("."));
if let Some(tag) = tag_for_current_version(ctx, crate_cfg, &repo, monorepo_prefix) {
return Some(tag);
}
let tag = crate::git::find_latest_tag_matching_with_prefix_in(
&repo,
crate_cfg.resolved_tag_template(),
ctx.config.git.as_ref(),
Some(ctx.template_vars()),
monorepo_prefix,
)
.ok()
.flatten()?;
let stripped = match monorepo_prefix {
Some(prefix) => crate::git::strip_monorepo_prefix(&tag, prefix).to_string(),
None => tag,
};
Some(stripped)
}
fn tag_for_current_version(
ctx: &Context,
crate_cfg: &CrateConfig,
repo: &std::path::Path,
monorepo_prefix: Option<&str>,
) -> Option<String> {
let template = crate_cfg.resolved_tag_template();
let rendered = ctx.render_template(template).ok()?;
if rendered == template {
return None;
}
let candidate = match monorepo_prefix {
Some(prefix) => format!("{prefix}{rendered}"),
None => rendered.clone(),
};
let exists = crate::git::list_tags_with_prefix(repo, &candidate)
.ok()?
.iter()
.any(|t| t == &candidate);
exists.then_some(rendered)
}
pub fn no_matching_tag_error(ctx: &Context, crate_cfg: &CrateConfig, selected_for: &str) -> String {
let repo = ctx
.options
.project_root
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let existing = crate::git::list_tags_with_prefix(&repo, "").unwrap_or_default();
if existing.is_empty() {
format!(
"crate '{}' is selected for {selected_for} but has no release tag matching its \
tag_template '{}': the repository has no git tags at all (likely a fresh clone \
with no tags fetched, a shallow checkout, or tags deleted by a rollback/re-cut); \
run `git fetch --tags`, create a release tag, or use --snapshot (local build) \
or --nightly (synthesized version) which need no tag",
crate_cfg.name,
crate_cfg.resolved_tag_template()
)
} else {
let sample = existing
.iter()
.take(5)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
format!(
"crate '{}' is selected for {selected_for} but has no release tag matching its \
tag_template '{}'; cannot derive its version (nearest existing tags: {sample})",
crate_cfg.name,
crate_cfg.resolved_tag_template()
)
}
}
pub fn apply_var_overrides(
ctx: &mut Context,
overrides: &[(&'static str, String)],
) -> Vec<(&'static str, Option<String>)> {
let mut saved: Vec<(&'static str, Option<String>)> = Vec::new();
let mut seen: HashSet<&'static str> = HashSet::new();
for key in PER_CRATE_SCOPED_VARS {
if seen.insert(*key) {
saved.push((*key, ctx.template_vars().get(key).cloned()));
}
}
for (key, value) in overrides {
ctx.template_vars_mut().set(key, value);
}
saved
}
pub fn restore_var_overrides(ctx: &mut Context, saved: Vec<(&'static str, Option<String>)>) {
for (key, prior) in saved {
match prior {
Some(value) => ctx.template_vars_mut().set(key, &value),
None => {
ctx.template_vars_mut().unset(key);
}
}
}
}
pub fn with_crate_scope<T>(
ctx: &mut Context,
crate_cfg: &CrateConfig,
resolve_tag: &dyn Fn(&Context, &CrateConfig) -> Option<String>,
body: impl FnOnce(&mut Context) -> Result<T>,
) -> Result<T> {
let tag = resolve_tag(ctx, crate_cfg)
.with_context(|| no_matching_tag_error(ctx, crate_cfg, "a per-crate emission"))?;
let overrides = crate_template_overrides(&crate_cfg.name, &tag)?;
let saved = apply_var_overrides(ctx, &overrides);
let result = body(ctx);
restore_var_overrides(ctx, saved);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextOptions;
use std::process::Command;
fn ctx_at(root: &std::path::Path) -> Context {
let options = ContextOptions {
project_root: Some(root.to_path_buf()),
..Default::default()
};
Context::new(crate::config::Config::default(), options)
}
fn crate_cfg() -> CrateConfig {
CrateConfig {
name: "orphan".to_string(),
path: ".".to_string(),
tag_template: Some("orphan-v{{ .Version }}".to_string()),
..Default::default()
}
}
fn git(root: &std::path::Path, args: &[&str]) {
let out = crate::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(root)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?}: {out:?}");
}
#[test]
fn no_matching_tag_error_names_tagless_repo_and_remedies() {
let tmp = tempfile::tempdir().unwrap();
git(tmp.path(), &["init", "-q"]);
let msg = no_matching_tag_error(&ctx_at(tmp.path()), &crate_cfg(), "version-sync/binstall");
assert!(
msg.contains("no release tag matching its tag_template")
&& msg.contains("no git tags at all")
&& msg.contains("git fetch --tags")
&& msg.contains("--snapshot")
&& msg.contains("orphan-v{{ .Version }}"),
"got: {msg}"
);
}
#[test]
fn no_matching_tag_error_samples_existing_tags() {
let tmp = tempfile::tempdir().unwrap();
git(tmp.path(), &["init", "-q"]);
git(
tmp.path(),
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"--allow-empty",
"-q",
"-m",
"x",
],
);
git(tmp.path(), &["tag", "widget-v1.2.3"]);
git(tmp.path(), &["tag", "widget-v1.2.4"]);
let msg = no_matching_tag_error(&ctx_at(tmp.path()), &crate_cfg(), "a per-crate emission");
assert!(
msg.contains("cannot derive its version")
&& msg.contains("orphan-v{{ .Version }}")
&& msg.contains("nearest existing tags:")
&& msg.contains("widget-v1.2.4"),
"got: {msg}"
);
}
}