diff --git a/build.rs b/build.rs index 80bb880..86a2b6d 100644 --- a/build.rs +++ b/build.rs @@ -12,5 +12,61 @@ fn main() -> Result<(), Box> { .build_client(false) .compile_protos(&["proto/echo.proto"], &["proto"])?; println!("cargo:rerun-if-changed=proto/echo.proto"); + emit_build_info(); Ok(()) } + +// Capture the git revision and build metadata at compile time and expose them as +// `env!()`-readable vars, so the binary can print a real version banner (its own +// commit hash, build date, toolchain). Degrades gracefully when git is absent +// (a tarball build) — the fields just read "unknown"/empty. +fn emit_build_info() { + use std::process::Command; + let git = |args: &[&str]| { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + }; + let hash = git(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".into()); + let dirty = git(&["status", "--porcelain"]).map(|s| !s.is_empty()).unwrap_or(false); + let commit_date = git(&["log", "-1", "--format=%cd", "--date=short"]).unwrap_or_default(); + + // Build time (UTC seconds), honouring SOURCE_DATE_EPOCH for reproducible builds. + let epoch = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + }); + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let rustc_ver = Command::new(rustc) + .arg("--version") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".into()); + + println!("cargo:rustc-env=ECHO_GIT_HASH={hash}"); + println!("cargo:rustc-env=ECHO_GIT_DIRTY={}", if dirty { "-dirty" } else { "" }); + println!("cargo:rustc-env=ECHO_COMMIT_DATE={commit_date}"); + println!("cargo:rustc-env=ECHO_BUILD_EPOCH={epoch}"); + println!("cargo:rustc-env=ECHO_RUSTC={rustc_ver}"); + println!("cargo:rustc-env=ECHO_TARGET={}", std::env::var("TARGET").unwrap_or_default()); + + // Re-run (refresh the hash) when the checked-out commit moves. + println!("cargo:rerun-if-changed=.git/HEAD"); + if let Ok(head) = std::fs::read_to_string(".git/HEAD") { + if let Some(reference) = head.strip_prefix("ref: ").map(str::trim) { + println!("cargo:rerun-if-changed=.git/{reference}"); + } + } + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); +} diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 2434eee..f0af9dc 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -11,7 +11,7 @@ impl Engine { let cmd = parts.next().unwrap_or("").to_ascii_uppercase(); let arg = parts.next().unwrap_or(""); let response = match cmd.as_str() { - "VERSION" => format!("VERSION echo services {}", env!("CARGO_PKG_VERSION")), + "VERSION" => format!("VERSION {}", crate::version::short()), "PING" => format!("PING {arg}"), "TIME" => format!("TIME {}", echo_api::human_time(self.now_secs())), "CLIENTINFO" => "CLIENTINFO ACTION CLIENTINFO PING TIME VERSION".to_string(), diff --git a/src/main.rs b/src/main.rs index 3d33765..2fc0dab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod keycard; mod link; mod migrate; mod proto; +mod version; mod wiktionary; #[cfg(test)] mod i18n_check; @@ -57,6 +58,12 @@ async fn main() -> Result<()> { // One-off migration: `echo import [node-name]` builds a // fresh event log from an Anope db_json and exits, touching nothing else. let argv: Vec = std::env::args().collect(); + // `echo --version` / `-V` prints the build banner (version, git revision, build + // date, toolchain, target) and exits. + if matches!(argv.get(1).map(String::as_str), Some("--version") | Some("-V") | Some("version")) { + println!("{}", version::banner()); + return Ok(()); + } if argv.get(1).map(String::as_str) == Some("import") { let (Some(src), Some(out)) = (argv.get(2), argv.get(3)) else { eprintln!("usage: echo import [node-name]"); @@ -88,6 +95,7 @@ async fn main() -> Result<()> { } let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); + tracing::info!(revision = %version::revision(), built = %version::built(), rustc = version::RUSTC, "starting {}", version::short()); let cfg = config::Config::load(&path)?; let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..871a271 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,54 @@ +//! Build/version metadata, captured at compile time by `build.rs` and formatted +//! for `echo --version`, the startup log, and the CTCP VERSION reply. + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const GIT_HASH: &str = env!("ECHO_GIT_HASH"); +pub const GIT_DIRTY: &str = env!("ECHO_GIT_DIRTY"); // "-dirty" or "" +pub const COMMIT_DATE: &str = env!("ECHO_COMMIT_DATE"); +pub const RUSTC: &str = env!("ECHO_RUSTC"); +pub const TARGET: &str = env!("ECHO_TARGET"); +const BUILD_EPOCH: &str = env!("ECHO_BUILD_EPOCH"); + +fn profile() -> &'static str { + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } +} + +/// The build time as "YYYY-MM-DD HH:MM:SS UTC". +pub fn built() -> String { + echo_api::human_time(BUILD_EPOCH.parse().unwrap_or(0)) +} + +/// `git a1b2c3d4e5f6` (or `…-dirty`), the revision alone. +pub fn revision() -> String { + format!("{GIT_HASH}{GIT_DIRTY}") +} + +/// One compact line — for the CTCP VERSION reply and the startup log. +/// `echo 0.0.1 (a1b2c3d4e5f6)`. +pub fn short() -> String { + format!("echo {VERSION} ({})", revision()) +} + +/// The full multi-line banner for `echo --version`. +pub fn banner() -> String { + let commit = if COMMIT_DATE.is_empty() { + String::new() + } else { + format!(" ({COMMIT_DATE})") + }; + format!( + "echo \u{2014} federated IRC services\n \ + version: {VERSION}\n \ + revision: {rev}{commit}\n \ + built: {built} ({profile})\n \ + rustc: {RUSTC}\n \ + target: {TARGET}", + rev = revision(), + built = built(), + profile = profile(), + ) +}