#![expect(
unused_crate_dependencies,
reason = "dev-dependencies are only used in integration tests"
)]
use clap::{Parser, Subcommand};
use gx::command::{Command as _, CommandReport as _};
use gx::config::{Config, Error as ConfigError};
use gx::infra::{repo, repo::Error as RepoError};
use gx::init::Error as InitError;
use gx::lint::Error as LintError;
use gx::output::lines::Line as OutputLine;
use gx::output::log_file::LogFile;
use gx::output::printer::Printer;
use gx::tidy::RunError as TidyRunError;
use gx::upgrade::command::RunError as UpgradeRunError;
use gx::{init, lint, tidy, upgrade};
use indicatif::ProgressBar;
use thiserror::Error;
#[derive(Debug, Error)]
enum GxError {
#[error(transparent)]
Resolve(#[from] upgrade::cli::Error),
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Init(#[from] InitError),
#[error(transparent)]
Tidy(#[from] TidyRunError),
#[error(transparent)]
Upgrade(#[from] UpgradeRunError),
#[error(transparent)]
Lint(#[from] LintError),
#[error(transparent)]
Repo(#[from] RepoError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Parser)]
#[command(name = "gx")]
#[command(about = "CLI to manage Github Actions dependencies", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Tidy,
Init,
Upgrade {
#[arg(value_name = "ACTION")]
action: Option<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
json: bool,
},
Lint,
}
fn make_cb<'cb>(
spinner: Option<&'cb ProgressBar>,
log_file: &'cb mut Option<LogFile>,
is_ci: bool,
) -> impl FnMut(&str) + 'cb {
move |msg: &str| {
if let Some(pb) = spinner {
pb.set_message(msg.to_owned());
}
if let Some(lf) = log_file.as_mut() {
lf.write(msg);
}
if is_ci {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
#[expect(
clippy::print_stdout,
reason = "CI verbose mode outputs directly to stdout"
)]
{
println!(" [{h:02}:{m:02}:{s:02}] {msg}");
}
}
}
}
fn finish_spinner(spinner: Option<ProgressBar>) {
if let Some(pb) = spinner {
pb.finish_and_clear();
}
}
fn append_log_path(log_file: Option<&LogFile>, lines: &mut Vec<OutputLine>) {
if let Some(lf) = log_file {
lines.push(OutputLine::LogPath {
path: lf.path().clone(),
});
}
}
fn run_reported<C>(
printer: &Printer,
repo_root: &std::path::Path,
config: Config,
is_ci: bool,
log_file: Option<LogFile>,
spinner_text: &str,
command: &C,
) -> Result<Option<LogFile>, GxError>
where
C: gx::command::Command,
GxError: From<C::Error>,
{
let spinner = printer.spinner(spinner_text);
let mut lf = log_file;
let report = {
let mut cb = make_cb(spinner.as_ref(), &mut lf, is_ci);
command.run(repo_root, config, &mut cb)?
};
finish_spinner(spinner);
let mut lines = report.render();
append_log_path(lf.as_ref(), &mut lines);
printer.print_lines(&lines);
if report.exit_code() != 0 {
std::process::exit(report.exit_code());
}
Ok(lf)
}
fn run_upgrade(
printer: &Printer,
repo_root: &std::path::Path,
config: Config,
log_file: Option<LogFile>,
action: Option<&str>,
latest: bool,
json: bool,
) -> Result<Option<LogFile>, GxError> {
let request = upgrade::cli::resolve_upgrade_mode(action, latest)?;
let spinner = if json {
None
} else {
printer.spinner("Checking actions...")
};
let mut lf = if json { None } else { log_file };
let report = {
let mut cb = make_cb(spinner.as_ref(), &mut lf, printer.is_ci && !json);
upgrade::command::Upgrade { request }.run(repo_root, config, &mut cb)?
};
finish_spinner(spinner);
if json {
#[expect(clippy::print_stdout, reason = "JSON contract is written to stdout")]
{
println!("{}", report.to_json());
}
} else {
let mut lines = report.render();
append_log_path(lf.as_ref(), &mut lines);
printer.print_lines(&lines);
}
if report.exit_code() != 0 {
std::process::exit(report.exit_code());
}
Ok(lf)
}
fn main() -> Result<(), GxError> {
let cli = Cli::parse();
let printer = Printer::new();
let is_ci = printer.is_ci;
let cmd_name = match &cli.command {
Commands::Tidy => "tidy",
Commands::Init => "init",
Commands::Upgrade { .. } => "upgrade",
Commands::Lint => "lint",
};
let json_mode = matches!(cli.command, Commands::Upgrade { json: true, .. });
let mut log_file: Option<LogFile> = if is_ci || json_mode {
None
} else {
LogFile::new(cmd_name).ok()
};
if is_ci && !json_mode {
printer.print_lines(&[OutputLine::CiNotice {
message: "CI detected, running in verbose mode".to_owned(),
}]);
}
let cwd = std::env::current_dir()?;
let repo_root = match repo::find_root(&cwd) {
Ok(root) => root,
Err(RepoError::GithubFolder) => {
if json_mode {
#[expect(clippy::print_stdout, reason = "JSON contract is written to stdout")]
{
println!("{}", upgrade::command::empty_json_report());
}
} else {
printer.print_lines(&[OutputLine::Summary {
text: ".github folder not found. gx didn't modify any file.".to_owned(),
}]);
}
return Ok(());
}
Err(e) => return Err(e.into()),
};
let config = Config::load(&repo_root)?;
let lf = log_file.take();
log_file = match cli.command {
Commands::Tidy => run_reported(
&printer,
&repo_root,
config,
is_ci,
lf,
"Running tidy...",
&tidy::Tidy,
)?,
Commands::Init => run_reported(
&printer,
&repo_root,
config,
is_ci,
lf,
"Initializing...",
&init::Init,
)?,
Commands::Lint => run_reported(
&printer,
&repo_root,
config,
is_ci,
lf,
"Linting...",
&lint::Lint,
)?,
Commands::Upgrade {
action,
latest,
json,
} => run_upgrade(
&printer,
&repo_root,
config,
lf,
action.as_deref(),
latest,
json,
)?,
};
drop(log_file);
Ok(())
}