use crate::command::CommandReport;
use crate::output::lines::Line as OutputLine;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct UpgradeEntry {
pub action: String,
pub from: String,
pub to: String,
pub in_range: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub compare: Option<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub upgrades: Vec<UpgradeEntry>,
#[serde(serialize_with = "serialize_skipped")]
pub skipped: Vec<(String, String)>,
pub warnings: Vec<String>,
pub workflows_updated: usize,
pub up_to_date: bool,
}
fn serialize_skipped<S: serde::Serializer>(
skipped: &[(String, String)],
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq as _;
let mut seq = serializer.serialize_seq(Some(skipped.len()))?;
for (action, reason) in skipped {
seq.serialize_element(&SkippedEntry { action, reason })?;
}
seq.end()
}
#[derive(Serialize)]
struct SkippedEntry<'entry> {
action: &'entry str,
reason: &'entry str,
}
impl Report {
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_owned())
}
}
impl CommandReport for Report {
fn render(&self) -> Vec<OutputLine> {
if self.up_to_date
|| (self.upgrades.is_empty() && self.skipped.is_empty() && self.warnings.is_empty())
{
return vec![OutputLine::Summary {
text: "All actions up to date".to_owned(),
}];
}
let mut lines = Vec::new();
for entry in &self.upgrades {
lines.push(OutputLine::Upgraded {
action: entry.action.clone(),
from: entry.from.clone(),
to: entry.to.clone(),
});
}
for (action, reason) in &self.skipped {
lines.push(OutputLine::Skipped {
action: action.clone(),
reason: reason.clone(),
});
}
for message in &self.warnings {
lines.push(OutputLine::Warning {
message: message.clone(),
});
}
lines.push(OutputLine::Blank);
let upgrade_count = self.upgrades.len();
let wf = self.workflows_updated;
let summary = format!(
"{} upgraded · {} file{}",
upgrade_count,
wf,
if wf == 1 { "" } else { "s" }
);
lines.push(OutputLine::Summary { text: summary });
lines
}
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
clippy::unwrap_used,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{CommandReport as _, OutputLine, Report, UpgradeEntry};
fn entry(action: &str, from: &str, to: &str) -> UpgradeEntry {
UpgradeEntry {
action: action.to_owned(),
from: from.to_owned(),
to: to.to_owned(),
in_range: true,
compare: None,
}
}
#[test]
fn render_upgrade_up_to_date() {
let report = Report {
up_to_date: true,
..Default::default()
};
let lines = report.render();
assert_eq!(lines.len(), 1);
assert!(
matches!(&lines[0], OutputLine::Summary { text } if text == "All actions up to date")
);
}
#[test]
fn render_upgrade_with_upgrades() {
let report = Report {
upgrades: vec![
entry("actions/checkout", "v6", "v6.0.2"),
entry("jdx/mise-action", "v3", "v3.6.2"),
],
workflows_updated: 1,
..Default::default()
};
let lines = report.render();
assert!(lines.contains(&OutputLine::Upgraded {
action: "actions/checkout".to_owned(),
from: "v6".to_owned(),
to: "v6.0.2".to_owned(),
}));
assert!(lines.contains(&OutputLine::Upgraded {
action: "jdx/mise-action".to_owned(),
from: "v3".to_owned(),
to: "v3.6.2".to_owned(),
}));
assert!(lines.contains(&OutputLine::Summary {
text: "2 upgraded · 1 file".to_owned(),
}));
}
#[test]
fn to_json_uses_resolved_versions_and_compare() {
let report = Report {
upgrades: vec![UpgradeEntry {
compare: Some(
"https://github.com/actions/checkout/compare/v6.0.1...v6.0.3".to_owned(),
),
..entry("actions/checkout", "v6.0.1", "v6.0.3")
}],
workflows_updated: 1,
..Default::default()
};
let value: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
let upgrade = &value["upgrades"][0];
assert_eq!(upgrade["from"], "v6.0.1");
assert_eq!(upgrade["to"], "v6.0.3");
assert_eq!(upgrade["in_range"], true);
assert_eq!(
upgrade["compare"],
"https://github.com/actions/checkout/compare/v6.0.1...v6.0.3"
);
assert_eq!(value["workflows_updated"], 1);
assert_eq!(value["up_to_date"], false);
}
#[test]
fn to_json_up_to_date_has_empty_upgrades() {
let report = Report {
up_to_date: true,
..Default::default()
};
let value: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
assert_eq!(value["up_to_date"], true);
assert_eq!(value["upgrades"].as_array().unwrap().len(), 0);
}
#[test]
fn to_json_omits_compare_when_absent() {
let report = Report {
upgrades: vec![entry("actions/setup-node", "v4.0.0", "v4.1.0")],
..Default::default()
};
let value: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
assert!(value["upgrades"][0].get("compare").is_none());
}
}