#![forbid(unsafe_code)]
#![allow(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::format_push_string,
clippy::many_single_char_names,
clippy::map_unwrap_or,
clippy::unnecessary_wraps
)]
use serde::Serialize;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, thiserror::Error)]
enum StampError {
#[error("argument: {0}")]
Args(String),
#[error("invalid tag `{0}`; expected semver like v1.2.3 or 1.2.3")]
InvalidTag(String),
#[error("io at {path}: {source}")]
Io {
path: PathBuf,
source: io::Error,
},
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
let _ = writeln!(io::stderr(), "shipwright-version-stamp: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), StampError> {
let args: Vec<String> = std::env::args().skip(1).collect();
let opts = parse_args(&args)?;
let version = normalize_tag(&opts.tag)?;
let root = opts.root.canonicalize().map_err(|source| StampError::Io {
path: opts.root.clone(),
source,
})?;
let summary = Summary::default();
let summary = stamp_cargo(&root, &version, opts.dry_run, summary)?;
let summary = stamp_all_package_json(&root, &version, opts.dry_run, summary)?;
let summary = stamp_all_csproj(&root, &version, opts.dry_run, summary)?;
let summary = stamp_pubspec(&root, &version, opts.dry_run, summary)?;
let summary = emit_build_info(&root, &version, opts.dry_run, summary)?;
println!("stamped version={version} (dry-run={})", opts.dry_run);
println!(" Cargo.toml : {}", summary.cargo);
println!(" package.json : {}", summary.npm);
println!(" *.csproj : {}", summary.csproj);
println!(" pubspec.yaml : {}", summary.pubspec);
println!(" build-info.json : {}", summary.build_info);
Ok(())
}
#[derive(Debug)]
struct Opts {
tag: String,
root: PathBuf,
dry_run: bool,
}
fn parse_args(args: &[String]) -> Result<Opts, StampError> {
let mut tag: Option<String> = None;
let mut root: Option<PathBuf> = None;
let mut dry_run = false;
let mut i = 0;
while i < args.len() {
let a = &args[i];
match a.as_str() {
"--tag" => {
let v = args
.get(i + 1)
.ok_or_else(|| StampError::Args("--tag requires a value".into()))?;
tag = Some(v.clone());
i += 2;
}
"--root" => {
let v = args
.get(i + 1)
.ok_or_else(|| StampError::Args("--root requires a value".into()))?;
root = Some(PathBuf::from(v));
i += 2;
}
"--dry-run" => {
dry_run = true;
i += 1;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => return Err(StampError::Args(format!("unknown argument `{other}`"))),
}
}
Ok(Opts {
tag: tag.ok_or_else(|| StampError::Args("--tag is required".into()))?,
root: root.unwrap_or_else(|| PathBuf::from(".")),
dry_run,
})
}
fn print_help() {
println!(
"shipwright-version-stamp — stamp a release tag into every language's version field\n\n\
USAGE:\n shipwright-version-stamp --tag <TAG> [--root <DIR>] [--dry-run]\n\n\
OPTIONS:\n --tag <TAG> semver or v<semver>, e.g. v1.2.3\n \
--root <DIR> repo root (default: .)\n \
--dry-run print actions without writing\n"
);
}
fn normalize_tag(tag: &str) -> Result<String, StampError> {
let candidate = tag.strip_prefix('v').unwrap_or(tag);
if is_semver(candidate) {
Ok(candidate.to_string())
} else {
Err(StampError::InvalidTag(tag.to_string()))
}
}
fn is_semver(s: &str) -> bool {
let mut parts = s.splitn(3, '.');
let (Some(maj), Some(min), Some(rest)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if !is_dec_digits(maj) || !is_dec_digits(min) {
return false;
}
let patch = rest.split(['-', '+']).next().unwrap_or(rest);
is_dec_digits(patch)
}
fn is_dec_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
#[derive(Default, Debug, Clone, Copy)]
struct Summary {
cargo: usize,
npm: usize,
csproj: usize,
pubspec: usize,
build_info: usize,
}
fn stamp_cargo(
root: &Path,
version: &str,
dry_run: bool,
mut s: Summary,
) -> Result<Summary, StampError> {
let path = root.join("Cargo.toml");
if !path.is_file() {
return Ok(s);
}
let original = read_to_string(&path)?;
let replaced = replace_cargo_version_lines(&original, version);
if replaced != original {
if !dry_run {
write_string(&path, &replaced)?;
}
s.cargo = s.cargo.saturating_add(1);
}
Ok(s)
}
fn replace_cargo_version_lines(src: &str, version: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut in_target_table = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
in_target_table =
trimmed.starts_with("[workspace.package]") || trimmed.starts_with("[package]");
out.push_str(line);
out.push('\n');
continue;
}
let should_replace = in_target_table
&& trimmed
.strip_prefix("version")
.map(|rest| rest.trim_start().starts_with('='))
.unwrap_or(false);
if should_replace {
let indent = &line[..line.len() - trimmed.len()];
out.push_str(indent);
out.push_str(&format!("version = \"{version}\""));
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
}
if !src.ends_with('\n') && out.ends_with('\n') {
let _ = out.pop();
}
out
}
fn stamp_all_package_json(
root: &Path,
version: &str,
dry_run: bool,
mut s: Summary,
) -> Result<Summary, StampError> {
for p in walk_for("package.json", root)? {
let original = read_to_string(&p)?;
let Some(replaced) = replace_package_json_version(&original, version)? else {
continue;
};
if replaced != original {
if !dry_run {
write_string(&p, &replaced)?;
}
s.npm = s.npm.saturating_add(1);
}
}
Ok(s)
}
fn replace_package_json_version(src: &str, version: &str) -> Result<Option<String>, StampError> {
let mut value: serde_json::Value = serde_json::from_str(src)?;
let Some(obj) = value.as_object_mut() else {
return Ok(None);
};
if !obj.contains_key("version") {
return Ok(None);
}
let _ = obj.insert(
"version".to_string(),
serde_json::Value::String(version.to_string()),
);
let mut out = serde_json::to_string_pretty(&value)?;
if src.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
Ok(Some(out))
}
fn stamp_all_csproj(
root: &Path,
version: &str,
dry_run: bool,
mut s: Summary,
) -> Result<Summary, StampError> {
for p in walk_for_ext("csproj", root)? {
let original = read_to_string(&p)?;
let replaced = replace_csproj_version(&original, version);
if replaced != original {
if !dry_run {
write_string(&p, &replaced)?;
}
s.csproj = s.csproj.saturating_add(1);
}
}
Ok(s)
}
fn replace_csproj_version(src: &str, version: &str) -> String {
if let Some(start) = src.find("<Version>") {
if let Some(end_rel) = src[start..].find("</Version>") {
let end = start + end_rel;
let mut out = String::with_capacity(src.len());
out.push_str(&src[..start]);
out.push_str("<Version>");
out.push_str(version);
out.push_str(&src[end..]);
return out;
}
}
if let Some(pg) = src.find("<PropertyGroup>") {
let insert_at = pg.saturating_add("<PropertyGroup>".len());
let mut out = String::with_capacity(src.len() + 64);
out.push_str(&src[..insert_at]);
out.push_str(&format!("\n <Version>{version}</Version>"));
out.push_str(&src[insert_at..]);
return out;
}
src.to_string()
}
fn stamp_pubspec(
root: &Path,
version: &str,
dry_run: bool,
mut s: Summary,
) -> Result<Summary, StampError> {
for p in walk_for("pubspec.yaml", root)? {
let original = read_to_string(&p)?;
let replaced = replace_pubspec_version(&original, version);
if replaced != original {
if !dry_run {
write_string(&p, &replaced)?;
}
s.pubspec = s.pubspec.saturating_add(1);
}
}
Ok(s)
}
fn replace_pubspec_version(src: &str, version: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut replaced = false;
for line in src.lines() {
if !replaced {
if let Some(rest) = line.strip_prefix("version:") {
let _ = rest; out.push_str(&format!("version: {version}\n"));
replaced = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if !src.ends_with('\n') && out.ends_with('\n') {
let _ = out.pop();
}
out
}
fn emit_build_info(
root: &Path,
version: &str,
dry_run: bool,
mut s: Summary,
) -> Result<Summary, StampError> {
let path = root.join("build-info.json");
let info = BuildInfoJson {
manifest_version: 1,
version: version.to_string(),
build_time: rfc3339_now(),
};
if !dry_run {
let body = serde_json::to_string_pretty(&info)?;
write_string(&path, &format!("{body}\n"))?;
}
s.build_info = s.build_info.saturating_add(1);
Ok(s)
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BuildInfoJson {
manifest_version: u32,
version: String,
build_time: String,
}
fn is_ignored(p: &Path) -> bool {
let s = p.to_string_lossy();
["/target/", "/node_modules/", "/.git/", "/dist/", "/build/"]
.iter()
.any(|needle| s.contains(*needle))
}
fn walk_for(name: &str, root: &Path) -> Result<Vec<PathBuf>, StampError> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if is_ignored(&path) {
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.file_name().and_then(|f| f.to_str()) == Some(name) {
found.push(path);
}
}
}
Ok(found)
}
fn walk_for_ext(ext: &str, root: &Path) -> Result<Vec<PathBuf>, StampError> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if is_ignored(&path) {
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
found.push(path);
}
}
}
Ok(found)
}
fn read_to_string(path: &Path) -> Result<String, StampError> {
fs::read_to_string(path).map_err(|source| StampError::Io {
path: path.to_path_buf(),
source,
})
}
fn write_string(path: &Path, body: &str) -> Result<(), StampError> {
fs::write(path, body).map_err(|source| StampError::Io {
path: path.to_path_buf(),
source,
})
}
fn rfc3339_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
rfc3339_from_secs(secs)
}
fn rfc3339_from_secs(secs: u64) -> String {
let days = secs / 86_400;
let secs_of_day = secs % 86_400;
let h = secs_of_day / 3600;
let m = (secs_of_day % 3600) / 60;
let s = secs_of_day % 60;
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let (y, mo, d) = epoch_days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::arithmetic_side_effects
)]
fn epoch_days_to_ymd(days: u64) -> (i32, u32, u32) {
let days = i64::try_from(days)
.unwrap_or(i64::MAX)
.saturating_add(719_468);
let era = days / 146_097;
let doe = (days - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + i64::from(m <= 2);
(y as i32, m as u32, d as u32)
}