use std::path::Path;
use super::cli::Request as UpgradeRequest;
use super::plan::{self, UpgradeError};
use super::report::{Report as UpgradeReport, UpgradeEntry};
use crate::command::Command;
use crate::config::Config;
use crate::domain::action::spec::Spec as ActionSpec;
use crate::domain::action::upgrade::{Action, Candidate as UpgradeCandidate};
use crate::domain::lock::Lock;
use crate::infra::github::Registry;
use crate::infra::lock::Error as LockFileError;
use crate::infra::manifest::Error as ManifestError;
use crate::infra::workflow_update::WorkflowWriter;
use thiserror::Error;
#[must_use]
pub fn empty_json_report() -> String {
UpgradeReport {
up_to_date: true,
..Default::default()
}
.to_json()
}
fn build_entry(candidate: &UpgradeCandidate, lock_before: &Lock) -> UpgradeEntry {
let to = candidate.candidate();
let in_range = matches!(candidate.action, Action::InRange { .. });
let key = ActionSpec::new(candidate.id.clone(), candidate.current.clone());
let from_tag = lock_before
.get(&key)
.and_then(|entry| entry.reference.tag().cloned());
let compare = from_tag
.as_ref()
.map(|from| candidate.id.compare_url(from, to));
let from = from_tag.map_or_else(|| candidate.current.to_string(), |v| v.to_string());
UpgradeEntry {
action: candidate.id.to_string(),
from,
to: to.to_string(),
in_range,
compare,
}
}
#[derive(Debug, Error)]
pub enum RunError {
#[error(transparent)]
Github(#[from] crate::infra::github::Error),
#[error(transparent)]
Manifest(#[from] ManifestError),
#[error(transparent)]
Lock(#[from] LockFileError),
#[error(transparent)]
Upgrade(#[from] UpgradeError),
}
pub struct Upgrade {
pub request: UpgradeRequest,
}
impl Command for Upgrade {
type Report = UpgradeReport;
type Error = RunError;
fn run(
&self,
repo_root: &Path,
config: Config,
on_progress: &mut dyn FnMut(&str),
) -> Result<UpgradeReport, RunError> {
let has_manifest = config.manifest_path.exists();
let registry = Registry::new(config.settings.github_token)?;
let updater = WorkflowWriter::new(repo_root);
let upgrade_plan = plan::plan(
&config.manifest,
&config.lock,
®istry,
&self.request,
&mut *on_progress,
)?;
if upgrade_plan.is_empty() {
return Ok(UpgradeReport {
up_to_date: true,
..Default::default()
});
}
if has_manifest {
crate::infra::manifest::patch::apply_manifest_diff(
&config.manifest_path,
&upgrade_plan.manifest,
)?;
let lock_store = crate::infra::lock::Store::new(&config.lock_path);
lock_store.save(&upgrade_plan.lock)?;
}
let workflows_updated = plan::apply_upgrade_workflows(
&updater,
&upgrade_plan.lock_changes,
&upgrade_plan.upgrades,
)?;
if config.manifest_migrated {
on_progress("migrated gx.toml → semver specifiers");
}
let upgrades: Vec<UpgradeEntry> = upgrade_plan
.upgrades
.iter()
.map(|u| build_entry(u, &config.lock))
.collect();
for entry in &upgrades {
if let Some(url) = &entry.compare {
on_progress(&format!(
"{} {} → {} ({url})",
entry.action, entry.from, entry.to
));
}
}
let report = UpgradeReport {
upgrades,
workflows_updated,
up_to_date: false,
..Default::default()
};
Ok(report)
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{Lock, build_entry, empty_json_report};
use crate::domain::action::identity::{ActionId, CommitDate, CommitSha, Repository, Version};
use crate::domain::action::resolved::{Commit, ResolvedRef};
use crate::domain::action::spec::Spec as ActionSpec;
use crate::domain::action::specifier::Specifier;
use crate::domain::action::upgrade::{Action, Candidate as UpgradeCandidate};
use crate::domain::action::uses_ref::RefType;
fn lock_with_tag(id: &str, specifier: &str, tag: &str) -> Lock {
let mut lock = Lock::default();
let spec = ActionSpec::new(ActionId::from(id), Specifier::parse(specifier));
lock.set(
&spec,
ResolvedRef::Tag(Version::from(tag)),
Commit {
sha: CommitSha::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
repository: Repository::from(id),
ref_type: Some(RefType::Tag),
date: CommitDate::from("2026-01-01T00:00:00Z"),
},
);
lock
}
#[test]
fn from_is_old_resolved_version_not_specifier() {
let lock = lock_with_tag("actions/checkout", "^6", "v6.0.1");
let candidate = UpgradeCandidate {
id: ActionId::from("actions/checkout"),
current: Specifier::parse("^6"),
action: Action::InRange {
candidate: Version::from("v6.0.3"),
},
};
let entry = build_entry(&candidate, &lock);
assert_eq!(entry.from, "v6.0.1");
assert_eq!(entry.to, "v6.0.3");
assert!(entry.in_range);
assert_eq!(
entry.compare.as_deref(),
Some("https://github.com/actions/checkout/compare/v6.0.1...v6.0.3")
);
}
#[test]
fn cross_range_reports_resolved_versions_and_is_not_in_range() {
let lock = lock_with_tag("actions/checkout", "^6", "v6.0.1");
let candidate = UpgradeCandidate {
id: ActionId::from("actions/checkout"),
current: Specifier::parse("^6"),
action: Action::CrossRange {
candidate: Version::from("v7.0.0"),
new_specifier: Specifier::parse("^7"),
},
};
let entry = build_entry(&candidate, &lock);
assert_eq!(entry.from, "v6.0.1");
assert_eq!(entry.to, "v7.0.0");
assert!(!entry.in_range);
assert_eq!(
entry.compare.as_deref(),
Some("https://github.com/actions/checkout/compare/v6.0.1...v7.0.0")
);
}
#[test]
fn no_lock_entry_falls_back_to_specifier_and_omits_compare() {
let candidate = UpgradeCandidate {
id: ActionId::from("actions/setup-node"),
current: Specifier::parse("^4"),
action: Action::InRange {
candidate: Version::from("v4.1.0"),
},
};
let entry = build_entry(&candidate, &Lock::default());
assert_eq!(entry.from, "^4");
assert_eq!(entry.to, "v4.1.0");
assert!(entry.compare.is_none());
}
#[test]
fn branch_pin_has_no_compare_link() {
let mut lock = Lock::default();
let spec = ActionSpec::new(ActionId::from("actions/checkout"), Specifier::parse("^6"));
lock.set(
&spec,
ResolvedRef::Branch(Version::from("main")),
Commit {
sha: CommitSha::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
repository: Repository::from("actions/checkout"),
ref_type: Some(RefType::Branch),
date: CommitDate::from("2026-01-01T00:00:00Z"),
},
);
let candidate = UpgradeCandidate {
id: ActionId::from("actions/checkout"),
current: Specifier::parse("^6"),
action: Action::InRange {
candidate: Version::from("v6.0.3"),
},
};
let entry = build_entry(&candidate, &lock);
assert!(entry.compare.is_none());
}
#[test]
fn empty_json_report_is_up_to_date() {
let value: serde_json::Value = serde_json::from_str(&empty_json_report()).unwrap();
assert_eq!(value["up_to_date"], true);
assert_eq!(value["upgrades"].as_array().unwrap().len(), 0);
}
}