use std::ffi::OsString;
use std::path::{Path, PathBuf};
use camino::Utf8PathBuf;
use zeroize::Zeroizing;
use super::secrets;
use crate::detect::{self, Forge};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const TRUNK_BRANCH: &str = "master";
const PASSTHROUGH: [&str; 11] = [
"PATH",
"HOME",
"XDG_CONFIG_HOME",
"GH_TOKEN",
"GITHUB_TOKEN",
"GH_HOST",
"GH_CONFIG_DIR",
"GLAB_TOKEN",
"GITLAB_TOKEN",
"GITLAB_HOST",
"GLAB_CONFIG_DIR",
];
pub use super::secrets::VALUE_VARS as SECRET_VARS;
#[derive(Debug)]
pub struct Ctx {
pub target: Utf8PathBuf,
pub repo: String,
pub forge: Forge,
pub host: Option<String>,
pub required_check: Option<String>,
pub cli: PathBuf,
pub tech: Option<&'static str>,
}
impl Ctx {
pub fn resolve(
target: &Utf8PathBuf,
repo_flag: Option<&str>,
forge_flag: Option<&str>,
required_check: Option<&str>,
) -> Result<Self, RkError> {
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory; nothing was run"),
)
.expected("an existing repository to set up"),
));
}
let forge_flag = forge_flag
.map(|name| {
detect::Forge::parse(name).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{name}'; the forges are: github, gitlab"
))
})
})
.transpose()?;
let detected = detect::detect(target.as_std_path());
let Some(forge) = forge_flag.or(detected.forge) else {
let diagnostic = detected.host.as_ref().map_or_else(
|| {
Diagnostic::new(
Reason::ForgeUndetected,
"no forge detected: the target has no origin remote",
)
},
|host| {
Diagnostic::new(
Reason::ForgeUndetected,
format!("no forge detected: the host {host} is not recognized"),
)
},
);
let diagnostic = diagnostic
.expected("a github.com or gitlab remote, or an override")
.action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
return Err(if detected.host.is_some() {
RkError::refusal(diagnostic)
} else {
RkError::missing(diagnostic)
});
};
let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
return Err(RkError::missing(
Diagnostic::new(
Reason::ForgeUndetected,
"no repository detected: the target has no origin remote",
)
.expected("an origin remote naming the project")
.action("pass --repo <owner/name>"),
));
};
let cli = resolve_cli(forge)?;
Ok(Self {
target: target.clone(),
repo,
forge,
host: detected.host,
required_check: required_check.map(str::to_owned),
cli,
tech: detect::tech_of(target.as_std_path()),
})
}
#[must_use]
pub fn self_hosted_gitlab(&self) -> bool {
self.forge == Forge::Gitlab
&& self
.host
.as_deref()
.is_some_and(|host| host != "gitlab.com")
}
#[must_use]
pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
let mut env: Vec<(OsString, OsString)> = vec![
("RK_FORGE".into(), self.forge.as_str().into()),
("RK_REPO".into(), self.repo.clone().into()),
("RK_TRUNK_BRANCH".into(), TRUNK_BRANCH.into()),
("GH_PAGER".into(), "".into()),
("GLAB_PAGER".into(), "".into()),
];
if let Some(check) = &self.required_check {
if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
{
env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
}
}
for name in PASSTHROUGH {
if let Some(value) = std::env::var_os(name) {
env.push((name.into(), value));
}
}
if let Some(dir) = self.cli_override_dir() {
let mut paths: Vec<PathBuf> = vec![dir];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
if let Ok(joined) = std::env::join_paths(paths) {
env.retain(|(name, _)| name != "PATH");
env.push(("PATH".into(), joined));
}
}
if step == "bot-secrets" {
for name in SECRET_VARS {
if let Some(value) = secrets::value_of(name) {
env.push((name.into(), value));
}
}
}
env
}
fn cli_override_dir(&self) -> Option<PathBuf> {
let overridden = std::env::var_os(match self.forge {
Forge::Github => "RK_GH_BIN",
Forge::Gitlab => "RK_GLAB_BIN",
})?;
Path::new(&overridden).parent().map(Path::to_path_buf)
}
#[must_use]
pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
SECRET_VARS
.iter()
.filter_map(|name| secrets::value_of(name))
.map(|value| Zeroizing::new(value.into_encoded_bytes()))
.collect()
}
}
fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
let override_var = match forge {
Forge::Github => "RK_GH_BIN",
Forge::Gitlab => "RK_GLAB_BIN",
};
if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
let path = PathBuf::from(&overridden);
if !path.is_file() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{override_var} names {}, which does not exist",
path.display()
),
)
.expected("the override to name the forge CLI binary"),
));
}
if path.file_name().is_none_or(|name| name != forge.cli()) {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{override_var} must name a binary called {}, and {} is not one",
forge.cli(),
path.display()
),
)
.expected(format!(
"an override whose file name is {}, so scripts and observations run one binary",
forge.cli()
)),
));
}
return Ok(path);
}
let name = forge.cli();
let found = std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
});
found.ok_or_else(|| {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{name} is not on PATH, and every {} step calls it",
forge.as_str()
),
)
.expected(format!("the {name} CLI installed and authenticated"))
.action(format!("install {name}, then run {name} auth login")),
)
})
}