use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VersionOutputMode {
Concise,
Verbose,
}
const SHORT_COMMIT_LEN: usize = 9;
#[derive(Debug, Clone)]
pub(crate) struct VersionInfo {
cabin_version: String,
commit: Option<String>,
commit_date: Option<String>,
host: Option<String>,
os: Option<String>,
}
impl VersionInfo {
pub(crate) fn current() -> Self {
Self {
cabin_version: env!("CARGO_PKG_VERSION").to_owned(),
commit: option_env!("CABIN_BUILD_COMMIT").map(str::to_owned),
commit_date: option_env!("CABIN_BUILD_COMMIT_DATE").map(str::to_owned),
host: option_env!("CABIN_BUILD_HOST").map(str::to_owned),
os: detect_os_string(),
}
}
#[cfg(test)]
fn for_tests(
cabin_version: &str,
commit: Option<&str>,
commit_date: Option<&str>,
host: Option<&str>,
os: Option<&str>,
) -> Self {
Self {
cabin_version: cabin_version.to_owned(),
commit: commit.map(str::to_owned),
commit_date: commit_date.map(str::to_owned),
host: host.map(str::to_owned),
os: os.map(str::to_owned),
}
}
pub(crate) fn format(&self, mode: VersionOutputMode) -> String {
match mode {
VersionOutputMode::Concise => format!("cabin {}\n", self.cabin_version),
VersionOutputMode::Verbose => self.format_verbose(),
}
}
fn short_commit(&self) -> Option<&str> {
self.commit
.as_deref()
.map(|hash| &hash[..hash.len().min(SHORT_COMMIT_LEN)])
}
fn format_verbose(&self) -> String {
let mut out = String::with_capacity(256);
match (self.short_commit(), self.commit_date.as_deref()) {
(Some(short), Some(date)) => {
let _ = writeln!(out, "cabin {} ({} {})", self.cabin_version, short, date);
}
_ => {
let _ = writeln!(out, "cabin {}", self.cabin_version);
}
}
let _ = writeln!(out, "release: {}", self.cabin_version);
if let Some(hash) = self.commit.as_deref() {
let _ = writeln!(out, "commit-hash: {hash}");
}
if let Some(date) = self.commit_date.as_deref() {
let _ = writeln!(out, "commit-date: {date}");
}
if let Some(host) = self.host.as_deref() {
let _ = writeln!(out, "host: {host}");
}
if let Some(os) = self.os.as_deref() {
let _ = writeln!(out, "os: {os}");
}
out
}
}
fn detect_os_string() -> Option<String> {
let info = os_info::get();
let os_type = info.os_type();
let version = info.version();
let bitness = info.bitness();
let mut buf = String::new();
let _ = write!(buf, "{os_type}");
if !matches!(version, os_info::Version::Unknown) {
let _ = write!(buf, " {version}");
}
if !matches!(bitness, os_info::Bitness::Unknown) {
let _ = write!(buf, " [{bitness}]");
}
let buf = buf.trim().to_owned();
if buf.is_empty() { None } else { Some(buf) }
}
#[cfg(test)]
mod tests {
use super::*;
fn full() -> VersionInfo {
VersionInfo::for_tests(
"x.y.z",
Some("abc1234def56789a"),
Some("2026-05-11"),
Some("x86_64-unknown-linux-gnu"),
Some("Ubuntu 24.04 [64-bit]"),
)
}
fn minimal() -> VersionInfo {
VersionInfo::for_tests("x.y.z", None, None, None, None)
}
#[test]
fn concise_format_is_single_line_with_release_name() {
let info = full();
assert_eq!(info.format(VersionOutputMode::Concise), "cabin x.y.z\n");
}
#[test]
fn concise_format_works_with_minimal_metadata() {
let info = minimal();
assert_eq!(info.format(VersionOutputMode::Concise), "cabin x.y.z\n");
}
#[test]
fn verbose_format_header_includes_short_hash_and_commit_date() {
let info = full();
let out = info.format(VersionOutputMode::Verbose);
let header = out.lines().next().expect("at least one line");
assert_eq!(header, "cabin x.y.z (abc1234de 2026-05-11)");
}
#[test]
fn verbose_format_drops_parenthetical_when_git_metadata_missing() {
let info = VersionInfo::for_tests(
"x.y.z",
None,
None,
Some("x86_64-unknown-linux-gnu"),
Some("Ubuntu 24.04 [64-bit]"),
);
let out = info.format(VersionOutputMode::Verbose);
let header = out.lines().next().expect("at least one line");
assert_eq!(header, "cabin x.y.z");
}
#[test]
fn verbose_format_emits_fields_in_cargo_order() {
let info = full();
let out = info.format(VersionOutputMode::Verbose);
let expected = "\
cabin x.y.z (abc1234de 2026-05-11)
release: x.y.z
commit-hash: abc1234def56789a
commit-date: 2026-05-11
host: x86_64-unknown-linux-gnu
os: Ubuntu 24.04 [64-bit]
";
assert_eq!(out, expected);
}
#[test]
fn verbose_format_omits_missing_optional_rows() {
let info = minimal();
let out = info.format(VersionOutputMode::Verbose);
let expected = "\
cabin x.y.z
release: x.y.z
";
assert_eq!(out, expected);
}
#[test]
fn verbose_format_uses_short_labels_no_uppercase() {
let info = full();
for line in info.format(VersionOutputMode::Verbose).lines() {
if !line.contains(':') {
continue;
}
let label = line.split(':').next().expect("label before colon");
assert!(
label.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"labels must be lowercase / dashed: got `{label}`"
);
}
}
#[test]
fn current_uses_package_version_as_cabin_version() {
let info = VersionInfo::current();
assert_eq!(info.cabin_version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn verbose_format_never_leaks_local_paths() {
let info = full();
let out = info.format(VersionOutputMode::Verbose);
for needle in ["/Users/", "/home/", "/private/", "/opt/", "/tmp/"] {
assert!(
!out.contains(needle),
"verbose output must not leak `{needle}`: {out}"
);
}
}
}