use serde::Serialize;
use crate::output::CliReport;
use crate::output::human::Human;
#[derive(Debug, Serialize)]
pub struct UpdateReport {
pub status: &'static str,
pub current_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub install_method: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upgrade_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_instance_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_listen: Option<String>,
}
impl UpdateReport {
pub fn delegated(
current_version: String,
install_method: &'static str,
upgrade_command: String,
) -> Self {
Self {
status: "delegated",
current_version,
update_available: None,
target_version: None,
install_method: Some(install_method),
upgrade_command: Some(upgrade_command),
daemon: None,
daemon_reason: None,
daemon_pid: None,
daemon_version: None,
daemon_instance_id: None,
daemon_listen: None,
}
}
pub fn checked(
current_version: String,
available: bool,
target_version: Option<String>,
) -> Self {
Self {
status: "checked",
current_version,
update_available: Some(available),
target_version,
install_method: None,
upgrade_command: None,
daemon: None,
daemon_reason: None,
daemon_pid: None,
daemon_version: None,
daemon_instance_id: None,
daemon_listen: None,
}
}
pub fn aborted(current_version: String) -> Self {
Self {
status: "aborted",
current_version,
update_available: None,
target_version: None,
install_method: None,
upgrade_command: None,
daemon: None,
daemon_reason: None,
daemon_pid: None,
daemon_version: None,
daemon_instance_id: None,
daemon_listen: None,
}
}
pub fn unchanged(current_version: String) -> Self {
Self {
status: "unchanged",
current_version,
update_available: None,
target_version: None,
install_method: None,
upgrade_command: None,
daemon: None,
daemon_reason: None,
daemon_pid: None,
daemon_version: None,
daemon_instance_id: None,
daemon_listen: None,
}
}
pub fn updated(
current_version: String,
new_version: String,
daemon: Option<&'static str>,
) -> Self {
Self {
status: "updated",
current_version,
update_available: None,
target_version: Some(new_version),
install_method: None,
upgrade_command: None,
daemon,
daemon_reason: None,
daemon_pid: None,
daemon_version: None,
daemon_instance_id: None,
daemon_listen: None,
}
}
}
impl CliReport for UpdateReport {
fn exit_code(&self) -> i32 {
i32::from(self.daemon == Some("deferred"))
}
fn render(&self, h: &mut Human<'_>) -> std::io::Result<()> {
match self.status {
"delegated" => {
let how = match self.install_method {
Some("homebrew") => "Homebrew",
Some("cargo") => "Cargo",
_ => "your package manager",
};
h.line(&format!(
"bitrouter looks installed via {how}. Update with:"
))?;
if let Some(cmd) = &self.upgrade_command {
h.line(&format!(" {cmd}"))?;
}
Ok(())
}
"checked" => {
if self.update_available == Some(true) {
match &self.target_version {
Some(v) => h.line(&format!(
"update available: {} -> {v}",
self.current_version
)),
None => h.line("update available (target version unknown)"),
}
} else {
h.line(&format!("up to date ({})", self.current_version))
}
}
"aborted" => h.line("aborted"),
"unchanged" => h.line(&format!("already up to date ({})", self.current_version)),
"updated" => {
let new = self.target_version.as_deref().unwrap_or("(unknown)");
h.line(&format!("✓ updated {} -> {new}", self.current_version))?;
match self.daemon {
Some("restarted") => h.note(&format!("Restarted the running daemon on {new}.")),
Some("compatible") => h.note(&format!("The running daemon is serving {new}.")),
Some("restart_needed") => h.note(&format!(
"A daemon is running the old binary. Run `{} restart` to serve {new}.",
bitrouter_sdk::invocation::name()
)),
Some("deferred") => h.note(&format!(
"Daemon handoff deferred: {}",
self.daemon_reason
.as_deref()
.unwrap_or("reason unavailable")
)),
_ => Ok(()),
}?;
if let Some(pid) = self.daemon_pid {
h.field("daemon pid", pid)?;
}
if let Some(version) = &self.daemon_version {
h.field("daemon version", version)?;
}
if let Some(instance) = &self.daemon_instance_id {
h.field("daemon instance", instance)?;
}
if let Some(listen) = &self.daemon_listen {
h.field("daemon listen", listen)?;
}
Ok(())
}
other => h.line(other),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{Format, Output};
fn json(r: &dyn CliReport) -> serde_json::Value {
serde_json::from_slice(&Output::new(Format::Json).render_to_vec(r)).unwrap()
}
fn human(r: &dyn CliReport) -> String {
String::from_utf8(Output::new(Format::Human).render_to_vec(r)).unwrap()
}
#[test]
fn delegated_carries_method_and_command() {
let r = UpdateReport::delegated(
"1.0.0-alpha.19".into(),
"homebrew",
"brew upgrade bitrouter".into(),
);
assert_eq!(
json(&r),
serde_json::json!({
"status": "delegated",
"current_version": "1.0.0-alpha.19",
"install_method": "homebrew",
"upgrade_command": "brew upgrade bitrouter"
})
);
let h = human(&r);
assert!(h.contains("looks installed via Homebrew"), "{h:?}");
assert!(h.contains(" brew upgrade bitrouter"), "{h:?}");
}
#[test]
fn checked_available_names_target_version() {
let r = UpdateReport::checked("1.0.0-alpha.19".into(), true, Some("1.0.0-alpha.20".into()));
assert_eq!(
json(&r),
serde_json::json!({
"status": "checked",
"current_version": "1.0.0-alpha.19",
"update_available": true,
"target_version": "1.0.0-alpha.20"
})
);
assert_eq!(
human(&r),
"update available: 1.0.0-alpha.19 -> 1.0.0-alpha.20\n"
);
}
#[test]
fn checked_up_to_date_omits_target() {
let r = UpdateReport::checked("1.0.0-alpha.19".into(), false, None);
assert_eq!(
json(&r),
serde_json::json!({
"status": "checked",
"current_version": "1.0.0-alpha.19",
"update_available": false
})
);
assert_eq!(human(&r), "up to date (1.0.0-alpha.19)\n");
}
#[test]
fn checked_available_but_target_unknown() {
let r = UpdateReport::checked("1.0.0-alpha.19".into(), true, None);
assert_eq!(
json(&r),
serde_json::json!({
"status": "checked",
"current_version": "1.0.0-alpha.19",
"update_available": true
})
);
assert_eq!(human(&r), "update available (target version unknown)\n");
}
#[test]
fn updated_without_running_daemon_omits_daemon_key() {
let r = UpdateReport::updated("1.0.0-alpha.19".into(), "1.0.0-alpha.20".into(), None);
assert_eq!(
json(&r),
serde_json::json!({
"status": "updated",
"current_version": "1.0.0-alpha.19",
"target_version": "1.0.0-alpha.20"
})
);
assert_eq!(human(&r), "✓ updated 1.0.0-alpha.19 -> 1.0.0-alpha.20\n");
}
#[test]
fn updated_with_restart_hint_renders_note() {
let r = UpdateReport::updated(
"1.0.0-alpha.19".into(),
"1.0.0-alpha.20".into(),
Some("restart_needed"),
);
assert_eq!(
json(&r),
serde_json::json!({
"status": "updated",
"current_version": "1.0.0-alpha.19",
"target_version": "1.0.0-alpha.20",
"daemon": "restart_needed"
})
);
let h = human(&r);
assert!(
h.contains("✓ updated 1.0.0-alpha.19 -> 1.0.0-alpha.20"),
"{h:?}"
);
assert!(h.contains("Run `bro restart`"), "{h:?}");
}
#[test]
fn updated_restarted_omits_hint() {
let r = UpdateReport::updated(
"1.0.0-alpha.19".into(),
"1.0.0-alpha.20".into(),
Some("restarted"),
);
let h = human(&r);
assert!(
h.contains("Restarted the running daemon on 1.0.0-alpha.20"),
"{h:?}"
);
}
#[test]
fn already_compatible_daemon_does_not_claim_restart() {
let report = UpdateReport::updated(
"1.0.0-alpha.31".into(),
"1.0.0-alpha.32".into(),
Some("compatible"),
);
assert_eq!(report.exit_code(), 0);
assert!(human(&report).contains("The running daemon is serving 1.0.0-alpha.32"));
}
#[test]
fn updated_binary_with_deferred_daemon_is_not_a_success() {
let mut report = UpdateReport::updated(
"1.0.0-alpha.31".into(),
"1.0.0-alpha.32".into(),
Some("deferred"),
);
report.daemon_reason = Some("one supervised run is still active".into());
assert_eq!(report.exit_code(), 1);
assert_eq!(json(&report)["daemon"], "deferred");
assert!(human(&report).contains("one supervised run is still active"));
}
#[test]
fn unchanged_and_aborted_are_one_liners() {
assert_eq!(
human(&UpdateReport::unchanged("1.0.0-alpha.19".into())),
"already up to date (1.0.0-alpha.19)\n"
);
assert_eq!(
human(&UpdateReport::aborted("1.0.0-alpha.19".into())),
"aborted\n"
);
assert_eq!(
json(&UpdateReport::aborted("1.0.0-alpha.19".into())),
serde_json::json!({"status": "aborted", "current_version": "1.0.0-alpha.19"})
);
}
}