use crate::command::Command;
use crate::config::Config;
use crate::domain::action::identity::CommitSha;
use crate::domain::action::spec::Spec;
use crate::domain::action::uses_ref::ParsedRef;
use crate::domain::diff::{LockDiff, ManifestDiff, WorkflowPatch};
use crate::domain::file::actions::ActionSet as WorkflowActionSet;
use crate::domain::file::scan::{Error as WorkflowError, Scanner as WorkflowScanner};
use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::domain::resolution::{ActionResolver, VersionRegistry};
use crate::infra::github::{Error as GithubError, Registry as GithubRegistry};
use crate::infra::lock::{Error as LockFileError, Store as LockStore};
use crate::infra::manifest::Error as ManifestError;
use crate::infra::manifest::patch::apply_manifest_diff;
use crate::infra::registry::caching_retrying;
use crate::infra::workflow_scan::FileScanner as FileWorkflowScanner;
use crate::infra::workflow_update::WorkflowWriter;
use report::Report;
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;
use super::lock_sync;
use super::manifest_sync;
use super::patches;
use super::report;
#[derive(Debug, Default)]
pub struct Plan {
pub manifest: ManifestDiff,
pub lock: Lock,
pub lock_changes: LockDiff,
pub workflows: Vec<WorkflowPatch>,
}
impl Plan {
#[must_use]
pub fn is_empty(&self) -> bool {
self.manifest.is_empty() && self.lock_changes.is_empty() && self.workflows.is_empty()
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error("failed to resolve {count} action(s):\n {specs}")]
ResolutionFailed { count: usize, specs: String },
#[error(transparent)]
Workflow(#[from] WorkflowError),
}
pub fn plan<R, P, F>(
manifest: &Manifest,
lock: &Lock,
registry: &R,
scanner: &P,
mut on_progress: F,
) -> Result<Plan, Error>
where
F: FnMut(&str),
R: VersionRegistry,
P: WorkflowScanner,
{
let mut located = Vec::new();
let mut action_set = WorkflowActionSet::new();
for result in scanner.scan() {
let action = result?;
action_set.add(&action.action);
located.push(action);
}
if located.is_empty() {
return Ok(Plan::default());
}
let mut planned_manifest = manifest.clone();
let mut planned_lock = lock.clone();
let resolver = ActionResolver::new(registry);
let sync_events = manifest_sync::sync_manifest_actions(&mut planned_manifest, &action_set);
for event in &sync_events {
on_progress(&event.to_string());
}
let upgrade_events =
manifest_sync::upgrade_sha_versions_to_tags(&mut planned_manifest, &resolver);
for event in &upgrade_events {
on_progress(&event.to_string());
}
planned_manifest.sync_overrides(&located, &action_set);
planned_manifest.prune_stale_overrides(&located);
let workflow_shas: HashMap<Spec, CommitSha> = located
.iter()
.filter_map(|loc| {
let ParsedRef::Pinned { sha, .. } = &loc.action.reference else {
return None;
};
let manifest_version = planned_manifest.get(&loc.action.id)?;
let key = Spec::new(loc.action.id.clone(), manifest_version.clone());
Some((key, sha.clone()))
})
.collect();
let lock_events = lock_sync::update_lock(
&mut planned_lock,
&mut planned_manifest,
&resolver,
&workflow_shas,
)?;
for event in &lock_events {
on_progress(&event.to_string());
}
let keys_to_retain = planned_manifest.lock_keys();
planned_lock.retain(&keys_to_retain);
let workflow_patches =
patches::compute_workflow_patches(&located, &planned_manifest, &planned_lock, scanner)?;
let manifest_diff = manifest.diff(&planned_manifest);
let lock_diff = lock.diff(&planned_lock);
Ok(Plan {
manifest: manifest_diff,
lock: planned_lock,
lock_changes: lock_diff,
workflows: workflow_patches,
})
}
pub fn apply_workflow_patches(
writer: &WorkflowWriter,
patches: &[WorkflowPatch],
) -> Result<usize, Error> {
let results = writer.apply_patches(patches)?;
Ok(results.len())
}
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error(transparent)]
Github(#[from] GithubError),
#[error(transparent)]
Manifest(#[from] ManifestError),
#[error(transparent)]
Lock(#[from] LockFileError),
#[error(transparent)]
Tidy(#[from] Error),
}
pub struct Tidy;
impl Command for Tidy {
type Report = Report;
type Error = RunError;
fn run(
&self,
repo_root: &Path,
config: Config,
on_progress: &mut dyn FnMut(&str),
) -> Result<Report, RunError> {
let has_manifest = config.manifest_path.exists();
if config.manifest_migrated {
on_progress("migrated gx.toml → semver specifiers");
}
if config.settings.github_token.is_none() {
on_progress(
"Warning: No GITHUB_TOKEN set — using unauthenticated GitHub API (60 requests/hour limit).",
);
}
let github = GithubRegistry::new(config.settings.github_token)?;
let (registry, progress) = caching_retrying(github, on_progress);
let scanner = FileWorkflowScanner::new(repo_root);
let updater = WorkflowWriter::new(repo_root);
let original_manifest = config.manifest.clone();
let tidy_plan = plan(
&config.manifest,
&config.lock,
®istry,
&scanner,
progress,
)?;
if tidy_plan.is_empty() {
return Ok(Report::default());
}
if has_manifest {
apply_manifest_diff(&config.manifest_path, &tidy_plan.manifest)?;
let lock_store = LockStore::new(&config.lock_path);
lock_store.save(&tidy_plan.lock)?;
}
let workflows_updated = apply_workflow_patches(&updater, &tidy_plan.workflows)?;
let report = Report {
removed: tidy_plan.manifest.removed,
added: tidy_plan.manifest.added,
upgraded: tidy_plan
.manifest
.updated
.into_iter()
.map(|(id, new_v)| {
let old_v = original_manifest
.get(&id)
.map_or_else(|| "?".to_owned(), std::string::ToString::to_string);
(id, old_v, new_v)
})
.collect(),
workflows_updated,
};
Ok(report)
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
#[path = "command_tests.rs"]
mod tests;