use super::{SecretVariable, SecretsProvider};
use crate::commands::ssh_helpers::prompt_for_password;
use crate::config::{global_xbp_paths, resolve_github_oauth2_key, SecretProvider, SshConfig};
use crate::utils::normalize_env_value;
use crate::utils::{
command_exists, git_remote_url_from_metadata, parse_github_repo_from_remote_url,
};
use dialoguer::Confirm;
use futures::{stream, StreamExt};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Client;
use reqwest::StatusCode;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::{stdin, stdout, IsTerminal};
use std::path::{Path, PathBuf};
use tokio::process::Command;
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_API_VERSION: &str = "2022-11-28";
const GITHUB_AUTH_HELP: &str =
"Set `--token`, export `GITHUB_TOKEN`, run `xbp config github set-key`, or authenticate `gh` with `gh auth login`.";
const VARIABLE_WRITE_CONCURRENCY: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VariableWriteMode {
Create,
Update,
}
#[derive(Debug)]
pub struct GitHubProvider {
owner: String,
repo: String,
environment: String,
token: String,
client: Client,
}
impl GitHubProvider {
pub async fn new(
project_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<Self, String> {
let (owner, repo) = if let Some(value) = repo_override {
parse_repo_override(value)?
} else {
detect_repo_info(project_root).await?
};
let token = find_github_token(token_override).await?;
let client = Client::builder()
.user_agent("xbp-cli")
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
validate_repo_access(&client, &owner, &repo, &token).await?;
let provider = Self {
owner,
repo,
environment: environment.to_string(),
token,
client,
};
Ok(provider)
}
fn base_url(&self) -> String {
format!(
"{}/repos/{}/{}/environments/{}/variables",
GITHUB_API_BASE, self.owner, self.repo, self.environment
)
}
fn auth_headers(&self) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
let value = HeaderValue::from_str(&format!("Bearer {}", self.token))
.map_err(|e| format!("Invalid authorization header: {}", e))?;
headers.insert(AUTHORIZATION, value);
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
"X-GitHub-Api-Version",
HeaderValue::from_static(GITHUB_API_VERSION),
);
headers.insert(USER_AGENT, HeaderValue::from_static("xbp-cli"));
Ok(headers)
}
}
#[async_trait::async_trait]
impl SecretsProvider for GitHubProvider {
async fn push(&self, secrets: &HashMap<String, String>, _force: bool) -> Result<(), String> {
self.push_with_progress(secrets, &mut |_| {}).await
}
async fn pull(&self) -> Result<Vec<SecretVariable>, String> {
self.pull_with_progress(&mut |_| {}).await
}
async fn list(&self) -> Result<Vec<SecretVariable>, String> {
self.list_paginated().await
}
}
impl GitHubProvider {
pub async fn push_with_progress<F>(
&self,
secrets: &HashMap<String, String>,
progress: &mut F,
) -> Result<(), String>
where
F: FnMut(&str),
{
progress(&format!(
"[4/6] Ensuring GitHub environment `{}` exists",
self.environment
));
self.ensure_environment_exists().await?;
progress("[5/6] Fetching remote GitHub variable names");
let headers = self.auth_headers()?;
let existing_names = self.list_variable_names().await?;
let write_plan = plan_variable_writes(secrets, &existing_names);
let total = write_plan.len();
if total == 0 {
progress("[6/6] Nothing to write");
return Ok(());
}
let provider = self;
let mut completed = 0usize;
let mut writes = stream::iter(write_plan.into_iter().map(|(name, value, mode)| {
let headers = headers.clone();
async move {
provider
.write_variable(&name, &value, mode, headers)
.await
.map(|_| name)
}
}))
.buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
while let Some(result) = writes.next().await {
completed += 1;
progress(&format!(
"[6/6] Writing GitHub variables ({}/{})",
completed, total
));
result?;
}
Ok(())
}
pub async fn pull_with_progress<F>(
&self,
progress: &mut F,
) -> Result<Vec<SecretVariable>, String>
where
F: FnMut(&str),
{
self.list_paginated_with_progress(progress).await
}
async fn ensure_environment_exists(&self) -> Result<(), String> {
let url = format!(
"{}/repos/{}/{}/environments/{}",
GITHUB_API_BASE, self.owner, self.repo, self.environment
);
let response = self
.client
.put(&url)
.headers(self.auth_headers()?)
.json(&serde_json::json!({}))
.send()
.await
.map_err(|e| {
format!(
"GitHub environment create failed for {}: {}",
self.environment, e
)
})?;
response
.error_for_status()
.map_err(|e| format!("GitHub rejected environment {}: {}", self.environment, e))?;
Ok(())
}
async fn list_variable_names(&self) -> Result<HashSet<String>, String> {
Ok(self
.list_paginated()
.await?
.into_iter()
.map(|variable| variable.name)
.collect())
}
async fn list_paginated(&self) -> Result<Vec<SecretVariable>, String> {
self.list_paginated_with_progress(&mut |_| {}).await
}
async fn list_paginated_with_progress<F>(
&self,
progress: &mut F,
) -> Result<Vec<SecretVariable>, String>
where
F: FnMut(&str),
{
let mut page = 1;
let mut results: Vec<SecretVariable> = Vec::new();
loop {
progress(&format!("[3/4] Fetching GitHub variables page {}", page));
let url = format!("{}?per_page=100&page={}", self.base_url(), page);
let headers = self.auth_headers()?;
let response = self
.client
.get(&url)
.headers(headers)
.send()
.await
.map_err(|e| format!("GitHub list request failed: {}", e))?;
let status = response.status();
let body = response
.json::<ListVariablesResponse>()
.await
.map_err(|e| format!("GitHub response parsing failed: {}", e))?;
if !status.is_success() {
return Err(format!(
"GitHub API returned {} when listing variables",
status
));
}
let chunk: Vec<SecretVariable> = body
.variables
.into_iter()
.map(|v| SecretVariable {
name: v.name,
value: normalize_env_value(&v.value),
})
.collect();
let items = chunk.len();
results.extend(chunk);
if items < 100 {
break;
}
page += 1;
}
Ok(results)
}
async fn write_variable(
&self,
name: &str,
value: &str,
mode: VariableWriteMode,
headers: HeaderMap,
) -> Result<(), String> {
let response = self
.send_variable_write(name, value, mode, headers.clone())
.await?;
if response.status().is_success() {
return Ok(());
}
if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
let retry_response = self
.send_variable_write(name, value, retry_mode, headers)
.await?;
if retry_response.status().is_success() {
return Ok(());
}
let detail = describe_variable_write_failure(retry_mode, retry_response).await;
return Err(format!("GitHub rejected {}: {}", name, detail));
}
let detail = describe_variable_write_failure(mode, response).await;
Err(format!("GitHub rejected {}: {}", name, detail))
}
async fn send_variable_write(
&self,
name: &str,
value: &str,
mode: VariableWriteMode,
headers: HeaderMap,
) -> Result<reqwest::Response, String> {
let payload = serde_json::json!({
"name": name,
"value": value,
});
Ok(match mode {
VariableWriteMode::Create => self
.client
.post(self.base_url())
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|e| format!("GitHub create failed for {}: {}", name, e))?,
VariableWriteMode::Update => self
.client
.patch(format!("{}/{}", self.base_url(), name))
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|e| format!("GitHub update failed for {}: {}", name, e))?,
})
}
}
fn retry_variable_write_mode(
mode: VariableWriteMode,
status: StatusCode,
) -> Option<VariableWriteMode> {
match (mode, status) {
(VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
Some(VariableWriteMode::Update)
}
(VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
_ => None,
}
}
async fn describe_variable_write_failure(
mode: VariableWriteMode,
response: reqwest::Response,
) -> String {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let github_message = extract_github_error_message(&body);
match mode {
VariableWriteMode::Create if status == StatusCode::UNPROCESSABLE_ENTITY => {
github_message.unwrap_or_else(|| "variable may already exist".to_string())
}
VariableWriteMode::Update if status == StatusCode::NOT_FOUND => {
github_message.unwrap_or_else(|| "variable does not exist yet".to_string())
}
_ => github_message.unwrap_or_else(|| format!("HTTP {}", status)),
}
}
fn extract_github_error_message(body: &str) -> Option<String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return None;
}
let parsed = serde_json::from_str::<serde_json::Value>(trimmed).ok()?;
let message = parsed
.get("message")
.and_then(|value| value.as_str())?
.trim();
if message.is_empty() {
None
} else {
Some(message.to_string())
}
}
#[derive(Debug, Deserialize)]
struct ListVariablesResponse {
#[allow(dead_code)]
total_count: usize,
variables: Vec<VariableEntry>,
}
#[derive(Debug, Deserialize)]
struct VariableEntry {
name: String,
value: String,
}
#[derive(Debug, Clone)]
struct GhInstallArtifact {
path: PathBuf,
invocation: String,
script: String,
}
fn parse_repo_override(value: &str) -> Result<(String, String), String> {
let trimmed = value.trim();
let normalized = trimmed.trim_end_matches(".git");
let parts: Vec<&str> = normalized.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() != 2 {
return Err(format!(
"Invalid repository override '{}'; expected owner/repo",
value
));
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
async fn detect_repo_info(project_root: &Path) -> Result<(String, String), String> {
if let Some(url) = git_remote_url_from_metadata(project_root, "origin")? {
return parse_detected_repo_url(&url);
}
if !command_exists("git") {
return Err(
"Failed to detect GitHub repository. Is 'origin' remote configured?".to_string(),
);
}
let output = Command::new("git")
.arg("remote")
.arg("get-url")
.arg("origin")
.current_dir(project_root)
.output()
.await
.map_err(|e| format!("Failed to run git: {}", e))?;
if !output.status.success() {
return Err(
"Failed to detect GitHub repository. Is 'origin' remote configured?".to_string(),
);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_detected_repo_url(&url)
}
fn parse_detected_repo_url(url: &str) -> Result<(String, String), String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err("Git remote URL is empty.".to_string());
}
if let Some((owner, repo)) = parse_github_repo_from_remote_url(trimmed) {
return Ok((owner, repo));
}
if trimmed.contains("github.com/") || trimmed.contains("github.com:") {
return Err(format!("Unexpected GitHub remote format: {}", trimmed));
}
Err("Origin remote is not a GitHub repository URL.".to_string())
}
pub fn needs_repo_setup(error: &str) -> bool {
error.contains("origin' remote configured")
|| error.contains("Git remote URL is empty")
|| error.contains("Origin remote is not a GitHub repository URL")
|| error.contains("Unexpected GitHub remote format")
}
pub async fn assert_repo_scope(client: &Client, token: &str) -> Result<(), String> {
let url = format!("{}/user", GITHUB_API_BASE);
let response = client
.get(&url)
.header(USER_AGENT, "xbp-cli")
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("GitHub token scope check failed: {}", e))?;
let scopes = response
.headers()
.get("X-OAuth-Scopes")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !scopes
.split(',')
.any(|s| s.trim().eq_ignore_ascii_case("repo"))
{
return Err("GitHub token is missing 'repo' scope; required for private repo secrets. Set a PAT with repo scope or run `gh auth login --scopes repo`.".to_string());
}
Ok(())
}
async fn validate_repo_access(
client: &Client,
owner: &str,
repo: &str,
token: &str,
) -> Result<(), String> {
assert_repo_scope(client, token).await?;
let url = format!("{}/repos/{}/{}", GITHUB_API_BASE, owner, repo);
let resp = client
.get(&url)
.header(USER_AGENT, "xbp-cli")
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("GitHub repo check failed: {}", e))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(format!(
"Repository {}/{} not found or token lacks access. Ensure the token has repo scope and the repo exists.",
owner, repo
));
}
resp.error_for_status()
.map_err(|e| format!("GitHub repo access check failed: {}", e))?;
Ok(())
}
async fn find_github_token(override_token: Option<&str>) -> Result<String, String> {
if let Some(tok) = pick_token(override_token) {
return Ok(tok);
}
if let Some(tok) = resolve_github_oauth2_key() {
return Ok(tok);
}
let output = match Command::new("gh").arg("auth").arg("token").output().await {
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(token) = prompt_for_github_token_interactively()? {
return Ok(token);
}
try_install_gh_or_generate_script().await?;
let out = Command::new("gh")
.arg("auth")
.arg("token")
.output()
.await
.map_err(|e2| format!("Failed to run 'gh auth token' after install: {}", e2))?;
if !out.status.success() {
if let Some(token) = prompt_for_github_token_interactively()? {
return Ok(token);
}
return Err(github_auth_error_message(
"GitHub CLI installed but not authenticated.",
));
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if token.is_empty() {
return Err("GitHub auth token command returned no value.".to_string());
}
return Ok(token);
}
Err(e) => return Err(format!("Failed to run 'gh auth token': {}", e)),
};
if !output.status.success() {
if let Some(token) = prompt_for_github_token_interactively()? {
return Ok(token);
}
return Err(github_auth_error_message(
"GitHub CLI is not authenticated.",
));
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
return Err("GitHub auth token command returned no value.".to_string());
}
Ok(token)
}
fn pick_token(override_token: Option<&str>) -> Option<String> {
override_token
.map(|t| t.to_string())
.or_else(|| env::var("GITHUB_TOKEN").ok())
.or_else(|| env::var("GH_TOKEN").ok())
}
fn plan_variable_writes(
secrets: &HashMap<String, String>,
existing_names: &HashSet<String>,
) -> Vec<(String, String, VariableWriteMode)> {
let mut names: Vec<&String> = secrets.keys().collect();
names.sort();
names
.into_iter()
.map(|name| {
let mode = if existing_names.contains(name) {
VariableWriteMode::Update
} else {
VariableWriteMode::Create
};
(name.clone(), normalize_env_value(&secrets[name]), mode)
})
.collect()
}
fn prompt_for_github_token_interactively() -> Result<Option<String>, String> {
if !stdin().is_terminal() || !stdout().is_terminal() {
return Ok(None);
}
println!(
"GitHub authentication is not configured for the secrets workflow.\n{}",
GITHUB_AUTH_HELP
);
let token = prompt_for_password("GitHub token (repo scope, leave empty to cancel): ")?;
let token = token.trim().to_string();
if token.is_empty() {
return Ok(None);
}
let should_save = Confirm::new()
.with_prompt("Save this token to global XBP config for future runs?")
.default(true)
.interact()
.map_err(|e| format!("Failed to confirm GitHub token save: {}", e))?;
if should_save {
let mut config = SshConfig::load()?;
config.set_secret(SecretProvider::Github, Some(token.clone()));
config.save()?;
println!("Saved GitHub token to global XBP config.");
}
Ok(Some(token))
}
fn github_auth_error_message(prefix: &str) -> String {
format!("{} {}", prefix, GITHUB_AUTH_HELP)
}
async fn try_install_gh_or_generate_script() -> Result<(), String> {
#[cfg(target_os = "linux")]
{
if command_exists("sudo") {
if command_exists("apt") || command_exists("apt-get") {
let apt = if command_exists("apt") {
"apt"
} else {
"apt-get"
};
let status = Command::new("sudo")
.args([apt, "install", "-y", "gh"])
.status()
.await
.map_err(|e| format!("Failed to run sudo {} install: {}", apt, e))?;
if status.success() {
return Ok(());
}
}
if command_exists("snap") {
let status = Command::new("sudo")
.args(["snap", "install", "gh", "--classic"])
.status()
.await
.map_err(|e| format!("Failed to run sudo snap install: {}", e))?;
if status.success() {
return Ok(());
}
}
}
}
let artifact = write_gh_install_artifact()?;
Err(format!(
"GitHub CLI (gh) is not installed. Run: {} (or set GITHUB_TOKEN)",
artifact.invocation
))
}
fn build_gh_install_artifact(root_dir: &Path) -> GhInstallArtifact {
#[cfg(target_os = "windows")]
{
let path = root_dir.join("install-gh.ps1");
let invocation = format!(
"powershell -ExecutionPolicy Bypass -File \"{}\"",
path.display()
);
let script = r#"$ErrorActionPreference = "Stop"
# Install GitHub CLI (gh) - generated by XBP
if (Get-Command winget -ErrorAction SilentlyContinue) {
winget install --id GitHub.cli --exact --source winget --accept-package-agreements --accept-source-agreements
} elseif (Get-Command choco -ErrorAction SilentlyContinue) {
choco install gh -y
} elseif (Get-Command scoop -ErrorAction SilentlyContinue) {
scoop install gh
} else {
Write-Host "Unsupported: install gh manually from https://cli.github.com/"
exit 1
}
Write-Host "Installed. Run: gh auth login"
"#
.to_string();
GhInstallArtifact {
path,
invocation,
script,
}
}
#[cfg(not(target_os = "windows"))]
{
let path = root_dir.join("install-gh.sh");
let invocation = format!("sh \"{}\"", path.display());
let script = r#"#!/bin/sh
# Install GitHub CLI (gh) - generated by XBP
set -e
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y gh
elif command -v apt >/dev/null 2>&1; then
sudo apt update && sudo apt install -y gh
elif command -v snap >/dev/null 2>&1; then
sudo snap install gh --classic
else
echo "Unsupported: install gh manually from https://cli.github.com/"
exit 1
fi
echo "Installed. Run: gh auth login"
"#
.to_string();
GhInstallArtifact {
path,
invocation,
script,
}
}
}
fn write_gh_install_artifact() -> Result<GhInstallArtifact, String> {
let paths = global_xbp_paths()?;
let artifact = build_gh_install_artifact(&paths.root_dir);
fs::write(&artifact.path, &artifact.script)
.map_err(|e| format!("Failed to write {}: {}", artifact.path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&artifact.path)
.map_err(|e| format!("Failed to stat {}: {}", artifact.path.display(), e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&artifact.path, perms)
.map_err(|e| format!("Failed to chmod {}: {}", artifact.path.display(), e))?;
}
Ok(artifact)
}
#[cfg(test)]
mod tests {
use super::{
build_gh_install_artifact, detect_repo_info, github_auth_error_message, needs_repo_setup,
parse_repo_override, pick_token, plan_variable_writes, retry_variable_write_mode,
VariableWriteMode,
};
use crate::config::global_xbp_paths;
use reqwest::StatusCode;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos: u128 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir: PathBuf = std::env::temp_dir().join(format!("xbp-secrets-{}-{}", label, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn detects_repo_setup_errors() {
assert!(needs_repo_setup(
"Failed to detect GitHub repository. Is 'origin' remote configured?"
));
assert!(needs_repo_setup("Git remote URL is empty."));
assert!(needs_repo_setup(
"Origin remote is not a GitHub repository URL."
));
assert!(needs_repo_setup(
"Unexpected GitHub remote format: git@example.com:foo"
));
}
#[test]
fn ignores_non_repo_setup_errors() {
assert!(!needs_repo_setup(
"GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN."
));
}
#[test]
fn parses_owner_repo_override() {
let parsed =
parse_repo_override("octocat/hello-world.git").expect("owner/repo.git should parse");
assert_eq!(parsed.0, "octocat");
assert_eq!(parsed.1, "hello-world");
}
#[test]
fn rejects_invalid_repo_override() {
let error = parse_repo_override("not-a-valid-repo").expect_err("invalid repo should fail");
assert!(error.contains("expected owner/repo"));
}
#[test]
fn pick_token_prefers_override_then_envs() {
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("GH_TOKEN");
assert_eq!(pick_token(Some("override")), Some("override".to_string()));
std::env::set_var("GITHUB_TOKEN", "env-token");
assert_eq!(pick_token(None), Some("env-token".to_string()));
std::env::remove_var("GITHUB_TOKEN");
std::env::set_var("GH_TOKEN", "gh-token");
assert_eq!(pick_token(None), Some("gh-token".to_string()));
std::env::remove_var("GH_TOKEN");
}
#[test]
fn auth_error_message_mentions_xbp_native_setup_paths() {
let message = github_auth_error_message("GitHub CLI is not authenticated.");
assert!(message.contains("xbp config github set-key"));
assert!(message.contains("GITHUB_TOKEN"));
assert!(message.contains("gh auth login"));
}
#[test]
fn plans_create_for_missing_variables_and_update_for_existing_ones() {
let mut secrets = HashMap::new();
secrets.insert("BETA".to_string(), "beta-value".to_string());
secrets.insert("ALPHA".to_string(), "alpha-value".to_string());
let existing_names = HashSet::from(["BETA".to_string()]);
let plan = plan_variable_writes(&secrets, &existing_names);
assert_eq!(
plan,
vec![
(
"ALPHA".to_string(),
"alpha-value".to_string(),
VariableWriteMode::Create
),
(
"BETA".to_string(),
"beta-value".to_string(),
VariableWriteMode::Update
),
]
);
}
#[test]
fn plans_normalized_values_before_writing_to_github() {
let mut secrets = HashMap::new();
secrets.insert(
"API_BASE".to_string(),
r#""https://mirror5.athena-cluster.com""#.to_string(),
);
let plan = plan_variable_writes(&secrets, &HashSet::new());
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].0, "API_BASE");
assert_eq!(plan[0].1, "https://mirror5.athena-cluster.com");
assert_eq!(plan[0].2, VariableWriteMode::Create);
}
#[test]
fn plans_only_creates_when_remote_has_no_variables() {
let mut secrets = HashMap::new();
secrets.insert("ALPHA".to_string(), "alpha-value".to_string());
let plan = plan_variable_writes(&secrets, &HashSet::new());
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].0, "ALPHA");
assert_eq!(plan[0].2, VariableWriteMode::Create);
}
#[test]
fn retry_mode_switches_create_conflicts_to_update() {
assert_eq!(
retry_variable_write_mode(VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY),
Some(VariableWriteMode::Update)
);
}
#[test]
fn retry_mode_switches_missing_updates_to_create() {
assert_eq!(
retry_variable_write_mode(VariableWriteMode::Update, StatusCode::NOT_FOUND),
Some(VariableWriteMode::Create)
);
}
#[test]
fn retry_mode_ignores_other_statuses() {
assert_eq!(
retry_variable_write_mode(VariableWriteMode::Create, StatusCode::BAD_REQUEST),
None
);
assert_eq!(
retry_variable_write_mode(VariableWriteMode::Update, StatusCode::UNPROCESSABLE_ENTITY),
None
);
}
#[test]
fn gh_install_artifact_is_created_under_global_xbp_root() {
let paths = global_xbp_paths().expect("global xbp paths should resolve");
let artifact = build_gh_install_artifact(&paths.root_dir);
assert_eq!(artifact.path.parent(), Some(paths.root_dir.as_path()));
assert!(
artifact.path.starts_with(&paths.root_dir),
"installer path should live under the global xbp root"
);
}
#[test]
fn gh_install_artifact_uses_platform_specific_path_and_invocation() {
let root = temp_dir("gh-install-artifact");
let artifact = build_gh_install_artifact(&root);
#[cfg(target_os = "windows")]
{
assert_eq!(
artifact.path.file_name().and_then(|value| value.to_str()),
Some("install-gh.ps1")
);
assert!(artifact
.invocation
.contains("powershell -ExecutionPolicy Bypass -File"));
assert!(artifact.script.contains("winget"));
assert!(artifact.script.contains("choco"));
assert!(artifact.script.contains("scoop"));
}
#[cfg(not(target_os = "windows"))]
{
assert_eq!(
artifact.path.file_name().and_then(|value| value.to_str()),
Some("install-gh.sh")
);
assert!(artifact.invocation.starts_with("sh \""));
assert!(artifact.script.contains("apt-get"));
}
fs::remove_dir_all(root).expect("temp project should be removed");
}
#[tokio::test]
async fn detects_repo_info_from_git_metadata_without_git_cli() {
let project_root = temp_dir("git-metadata");
let git_dir = project_root.join(".git");
fs::create_dir_all(&git_dir).expect("git dir should be created");
fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
)
.expect("git config should be written");
let detected = detect_repo_info(&project_root)
.await
.expect("repo info should be detected");
assert_eq!(detected, ("xylex-group".to_string(), "athena".to_string()));
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
}