#![feature(coverage_attribute)]
use std::process::ExitCode;
use std::sync::Arc;
use anyhow::Context as _;
use clap::Parser as _;
use cursus::command::{DryRunCommandRunner, RealCommandRunner, VerboseCommandRunner};
struct CliLogger {
stderr_is_terminal: bool,
}
#[coverage(off)]
#[mutants::skip]
impl log::Log for CliLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
use std::io::Write as _;
let target = record.target();
let args = record.args();
match record.level() {
log::Level::Info => {
let _ = writeln!(std::io::stdout().lock(), "{args}");
}
log::Level::Warn => {
let stderr = std::io::stderr();
if self.stderr_is_terminal {
let _ = writeln!(stderr.lock(), "\x1b[33m[warning] {args}\x1b[0m");
} else {
let _ = writeln!(stderr.lock(), "[warning] {args}");
}
}
log::Level::Error => {
let stderr = std::io::stderr();
if self.stderr_is_terminal {
let _ = writeln!(stderr.lock(), "\x1b[91m[error] {args}\x1b[0m");
} else {
let _ = writeln!(stderr.lock(), "[error] {args}");
}
}
log::Level::Debug => {
let _ = writeln!(std::io::stdout().lock(), "debug: {target}: {args}");
}
log::Level::Trace => {
let _ = writeln!(std::io::stdout().lock(), "trace: {target}: {args}");
}
}
}
fn flush(&self) {
use std::io::Write as _;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
}
}
static LOGGER: std::sync::OnceLock<CliLogger> = std::sync::OnceLock::new();
#[coverage(off)]
#[mutants::skip]
fn init_logging(level: log::LevelFilter) {
use std::io::IsTerminal as _;
let logger = LOGGER.get_or_init(|| CliLogger {
stderr_is_terminal: std::io::stderr().is_terminal(),
});
if let Err(e) = log::set_logger(logger) {
eprintln!("warning: failed to initialize logging: {e}");
}
log::set_max_level(level);
}
#[coverage(off)]
#[mutants::skip]
fn determine_log_level(global: &cursus::cli::GlobalArgs) -> log::LevelFilter {
if global.silent {
log::LevelFilter::Error
} else {
match global.verbose {
0 => log::LevelFilter::Info,
1 => log::LevelFilter::Debug,
_ => log::LevelFilter::Trace,
}
}
}
#[coverage(off)]
#[mutants::skip]
fn env_first(vars: &[&str]) -> Option<String> {
vars.iter()
.find_map(|name| std::env::var(name).ok().filter(|s| !s.is_empty()))
}
#[coverage(off)]
#[mutants::skip]
fn detect_locale() -> String {
env_first(&["CURSUS_LOCALE"])
.or_else(sys_locale::get_locale)
.unwrap_or_else(|| cursus::locale::DEFAULT_LOCALE.to_string())
}
#[coverage(off)]
#[mutants::skip]
#[tokio::main]
async fn main() -> ExitCode {
let cli = match cursus::cli::Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
init_logging(log::LevelFilter::Info);
if let Err(print_err) = e.print() {
log::error!("failed to print help: {print_err:#}");
}
return if e.use_stderr() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
};
}
};
init_logging(determine_log_level(&cli.global));
match try_main(cli).await {
Ok(code) => code,
Err(e) => {
log::error!("{e:#}");
ExitCode::FAILURE
}
}
}
fn build_octocrab(token: &str) -> Result<octocrab::Octocrab, octocrab::Error> {
octocrab::Octocrab::builder()
.personal_token(token.to_string())
.set_connect_timeout(Some(std::time::Duration::from_secs(10)))
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.set_write_timeout(Some(std::time::Duration::from_secs(30)))
.build()
}
async fn resolve_forge_client(
env: &cursus::Env,
config: &Option<cursus::model::config::Config>,
octocrab: Option<Arc<octocrab::Octocrab>>,
) -> Result<Arc<dyn cursus::github::client::CodeForgeClient>, String> {
let octocrab = match octocrab {
Some(o) => (*o).clone(),
None => {
let token = env_first(&["GH_TOKEN", "GITHUB_TOKEN"])
.ok_or_else(|| "No GitHub token found (GH_TOKEN / GITHUB_TOKEN)".to_string())?;
build_octocrab(&token).map_err(|e| format!("Failed to build GitHub client: {e}"))?
}
};
let cfg = config
.as_ref()
.ok_or_else(|| "No configuration file found".to_string())?;
let repo = cursus::github::remote::GitHubRepo::resolve(&cfg.github, env.git())
.await
.map_err(|e| format!("{e:#}"))?;
Ok(
Arc::new(cursus::github::OctocrabGitHubClient::new(octocrab, repo))
as Arc<dyn cursus::github::client::CodeForgeClient>,
)
}
async fn try_main(cli: cursus::cli::Cli) -> anyhow::Result<ExitCode> {
let cwd = std::env::current_dir()?;
let cwd_abs = cursus::path::AbsolutePath::new(&cwd)?;
let runner: Arc<dyn cursus::command::CommandRunner> =
Arc::new(VerboseCommandRunner::new(RealCommandRunner));
let filesystem: Arc<dyn cursus::filesystem::Filesystem> =
Arc::new(cursus::filesystem::LocalFilesystem);
let runner = if cli.global.dry_run {
Arc::new(DryRunCommandRunner::new(runner)) as Arc<dyn cursus::command::CommandRunner>
} else {
runner
};
let git_workdir = cursus::git::find_workdir(&cwd_abs, &*filesystem)
.await
.context("No git repository found")?;
let config = cursus::model::config::load(&*filesystem, &git_workdir).await?;
let git_inner = Arc::new(cursus::git::GitWorkdir::new(
Arc::clone(&runner),
git_workdir,
));
let octocrab: Option<Arc<octocrab::Octocrab>> = env_first(&["GH_TOKEN", "GITHUB_TOKEN"])
.and_then(|t| build_octocrab(&t).ok())
.map(Arc::new);
let git = build_git(
git_inner,
Arc::clone(&filesystem),
Arc::clone(&runner),
&config,
cli.global.dry_run,
octocrab.clone(),
)
.await?;
let editor = env_first(&["VISUAL", "EDITOR"]);
let oidc_environment = env_first(&["ACTIONS_ID_TOKEN_REQUEST_URL", "CI_JOB_JWT_V2"]).is_some();
let node_auth_token_present = env_first(&["NODE_AUTH_TOKEN"]).is_some();
let cargo_registry_token_present = env_first(&["CARGO_REGISTRY_TOKEN"]).is_some();
let locale = detect_locale();
let env = cursus::Env::new(runner, filesystem, git)
.with_editor_opt(editor)
.with_oidc_environment(oidc_environment)
.with_node_auth_token_present(node_auth_token_present)
.with_cargo_registry_token_present(cargo_registry_token_present)
.with_locale(locale);
let forge_client = resolve_forge_client(&env, &config, octocrab).await;
let env = env.with_code_forge_client_result(forge_client);
cursus::run(cli, env, config).await
}
#[coverage(off)]
#[mutants::skip]
async fn build_git(
inner: Arc<cursus::git::GitWorkdir>,
filesystem: Arc<dyn cursus::filesystem::Filesystem>,
runner: Arc<dyn cursus::command::CommandRunner>,
config: &Option<cursus::model::config::Config>,
dry_run: bool,
octocrab: Option<Arc<octocrab::Octocrab>>,
) -> anyhow::Result<Arc<dyn cursus::git::Git>> {
let mode = config
.as_ref()
.map(|c| c.git.signed_commits)
.unwrap_or_default();
let on_gha = std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true");
let use_api = resolve_signed_commits_mode(mode, octocrab.is_some(), on_gha);
if use_api {
let octocrab = octocrab.context(
"GitHub token required for signed commits but none found (GH_TOKEN / GITHUB_TOKEN)",
)?;
let github_config = config
.as_ref()
.map(|c| c.github.clone())
.unwrap_or_default();
let repo = cursus::github::remote::GitHubRepo::resolve(&github_config, &*inner)
.await
.context("cannot enable signed commits: failed to determine GitHub repository")?;
log::info!(
"Routing git commit and push operations through the GitHub API for verified commits."
);
let g: Arc<dyn cursus::git::Git> = Arc::new(cursus::git::SignedCommitGit::new(
inner, filesystem, octocrab, runner, repo.owner, repo.repo, dry_run,
));
Ok(g)
} else {
let g: Arc<dyn cursus::git::Git> = inner;
Ok(g)
}
}
pub(crate) fn resolve_signed_commits_mode(
mode: cursus::model::config::SignedCommitsMode,
token_present: bool,
on_gha: bool,
) -> bool {
use cursus::model::config::SignedCommitsMode;
match mode {
SignedCommitsMode::Off => false,
SignedCommitsMode::Force => token_present,
SignedCommitsMode::Auto => on_gha && token_present,
}
}
#[cfg(test)]
mod tests {
use cursus::model::config::SignedCommitsMode;
use super::resolve_signed_commits_mode;
#[test]
fn off_always_false() {
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Off,
true,
true
));
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Off,
false,
false
));
}
#[test]
fn force_requires_only_token() {
assert!(resolve_signed_commits_mode(
SignedCommitsMode::Force,
true,
false
));
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Force,
false,
true
));
}
#[test]
fn auto_requires_gha_and_token() {
assert!(resolve_signed_commits_mode(
SignedCommitsMode::Auto,
true,
true
));
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Auto,
true,
false
));
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Auto,
false,
true
));
assert!(!resolve_signed_commits_mode(
SignedCommitsMode::Auto,
false,
false
));
}
}