use std::env;
use std::path::{Component, Path, PathBuf};
use crate::download::{DEFAULT_GH_PROXY, DEFAULT_GITHUB_API_BASE};
use crate::error::AppError;
use crate::i18n::Language;
use super::types::{Cli, PrefixProxyMode, ResolvedOptions};
const DEBUG_ENV_VAR: &str = "GH_DOWNLOAD_DEBUG";
const PREFIX_MODE_ENV_VAR: &str = "GH_DOWNLOAD_PREFIX_MODE";
pub fn resolve_cli(cli: Cli) -> Result<ResolvedOptions, AppError> {
if cli.repo.trim().is_empty() {
return Err(AppError::EmptyRepository);
}
if cli.remote_path.trim().is_empty() {
return Err(AppError::EmptyRemotePath);
}
let local_target = resolve_local_target(&cli.local_target)?;
let prefix_mode = resolve_prefix_mode(
cli.prefix_mode,
env::var(PREFIX_MODE_ENV_VAR).ok().as_deref(),
);
let proxy_base = resolve_proxy_base(
cli.proxy_base.as_deref(),
env::var("GH_PROXY_BASE").ok(),
prefix_mode,
);
let api_base = resolve_api_base(cli.api_base.as_deref());
let token = pick_token(
cli.token.as_deref(),
env::var("GITHUB_TOKEN").ok().as_deref(),
env::var("GH_TOKEN").ok().as_deref(),
);
let debug = resolve_debug(cli.debug, env::var(DEBUG_ENV_VAR).ok().as_deref());
let language = Language::detect(
cli.language,
env::var("LC_ALL").ok().as_deref(),
env::var("LC_MESSAGES").ok().as_deref(),
env::var("LANG").ok().as_deref(),
);
Ok(ResolvedOptions {
repo: cli.repo.trim().to_string(),
remote_path: cli.remote_path.trim().to_string(),
local_target,
git_ref: cli.git_ref.map(|value| value.trim().to_string()),
token,
api_base,
proxy_base,
prefix_mode,
concurrency: cli.concurrency,
language,
overwrite: cli.overwrite,
json: cli.json,
debug,
no_color: cli.no_color,
})
}
pub fn pick_token(
explicit: Option<&str>,
github_token: Option<&str>,
gh_token: Option<&str>,
) -> Option<String> {
selected_token(explicit, github_token, gh_token).map(|(value, _)| value.to_string())
}
pub(crate) fn debug_token_source_label(
explicit: Option<&str>,
github_token: Option<&str>,
gh_token: Option<&str>,
) -> &'static str {
selected_token(explicit, github_token, gh_token)
.map(|(_, source)| source)
.unwrap_or("none")
}
pub fn resolve_proxy_base(
explicit: Option<&str>,
env_value: Option<String>,
prefix_mode: PrefixProxyMode,
) -> String {
match explicit {
Some(value) => value.trim().to_string(),
None => env_value
.map(|value| value.trim().to_string())
.unwrap_or_else(|| match prefix_mode {
PrefixProxyMode::Direct => String::new(),
PrefixProxyMode::Fallback | PrefixProxyMode::Prefer => DEFAULT_GH_PROXY.to_string(),
}),
}
}
pub fn resolve_api_base(explicit: Option<&str>) -> String {
explicit
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_GITHUB_API_BASE)
.to_string()
}
pub fn resolve_prefix_mode(
explicit: Option<PrefixProxyMode>,
env_value: Option<&str>,
) -> PrefixProxyMode {
explicit
.or_else(|| parse_prefix_mode_env(env_value))
.unwrap_or_default()
}
pub fn resolve_debug(explicit: bool, env_value: Option<&str>) -> bool {
explicit || parse_bool_env(env_value)
}
pub fn resolve_local_target(path: &Path) -> Result<PathBuf, AppError> {
let expanded = expand_home(path)?;
if expanded.is_absolute() {
Ok(normalize_path_lexically(&expanded))
} else {
env::current_dir()
.map(|current| current.join(expanded))
.map(|joined| normalize_path_lexically(&joined))
.map_err(|err| AppError::InvalidPath(err.to_string()))
}
}
fn normalize_path_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
normalized.push(component.as_os_str());
}
}
Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn parse_prefix_mode_env(value: Option<&str>) -> Option<PrefixProxyMode> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) if value.eq_ignore_ascii_case("direct") => Some(PrefixProxyMode::Direct),
Some(value) if value.eq_ignore_ascii_case("fallback") => Some(PrefixProxyMode::Fallback),
Some(value) if value.eq_ignore_ascii_case("prefer") => Some(PrefixProxyMode::Prefer),
_ => None,
}
}
fn parse_bool_env(value: Option<&str>) -> bool {
matches!(
value.map(str::trim),
Some("1")
| Some("true")
| Some("TRUE")
| Some("True")
| Some("yes")
| Some("YES")
| Some("Yes")
| Some("on")
| Some("ON")
| Some("On")
)
}
fn selected_token<'a>(
explicit: Option<&'a str>,
github_token: Option<&'a str>,
gh_token: Option<&'a str>,
) -> Option<(&'a str, &'static str)> {
explicit
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| (value, "--token"))
.or_else(|| {
github_token
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| (value, "GITHUB_TOKEN"))
})
.or_else(|| {
gh_token
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| (value, "GH_TOKEN"))
})
}
fn expand_home(path: &Path) -> Result<PathBuf, AppError> {
let raw = path.to_string_lossy();
if raw == "~" {
return home_dir()
.ok_or_else(|| AppError::InvalidPath("failed to resolve home directory".to_string()));
}
if let Some(suffix) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) {
let home = home_dir()
.ok_or_else(|| AppError::InvalidPath("failed to resolve home directory".to_string()))?;
return Ok(home.join(suffix));
}
Ok(path.to_path_buf())
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn token_priority_prefers_explicit_then_github_then_gh() {
assert_eq!(
pick_token(Some("explicit"), Some("github"), Some("gh")),
Some("explicit".to_string())
);
assert_eq!(
pick_token(None, Some("github"), Some("gh")),
Some("github".to_string())
);
assert_eq!(pick_token(None, None, Some("gh")), Some("gh".to_string()));
}
#[test]
fn debug_token_source_reports_explicit_then_env_precedence() {
assert_eq!(
debug_token_source_label(Some("explicit"), Some("github"), Some("gh")),
"--token"
);
assert_eq!(
debug_token_source_label(None, Some("github"), Some("gh")),
"GITHUB_TOKEN"
);
assert_eq!(debug_token_source_label(None, None, Some("gh")), "GH_TOKEN");
assert_eq!(debug_token_source_label(None, None, None), "none");
}
#[test]
fn proxy_base_is_disabled_by_default_in_direct_mode() {
assert_eq!(resolve_proxy_base(None, None, PrefixProxyMode::Direct), "");
}
#[test]
fn proxy_base_uses_env_when_present() {
assert_eq!(
resolve_proxy_base(
None,
Some(" https://proxy.example/ ".to_string()),
PrefixProxyMode::Prefer
),
"https://proxy.example/"
);
}
#[test]
fn proxy_base_explicit_empty_disables_env() {
assert_eq!(
resolve_proxy_base(
Some(" "),
Some("https://proxy.example/".to_string()),
PrefixProxyMode::Prefer
),
""
);
}
#[test]
fn proxy_base_defaults_to_builtin_proxy_in_fallback_mode() {
assert_eq!(
resolve_proxy_base(None, None, PrefixProxyMode::Fallback),
DEFAULT_GH_PROXY
);
}
#[test]
fn proxy_base_defaults_to_builtin_proxy_in_prefer_mode() {
assert_eq!(
resolve_proxy_base(None, None, PrefixProxyMode::Prefer),
DEFAULT_GH_PROXY
);
}
#[test]
fn prefix_mode_defaults_to_direct() {
assert_eq!(resolve_prefix_mode(None, None), PrefixProxyMode::Direct);
}
#[test]
fn api_base_defaults_to_public_github() {
assert_eq!(resolve_api_base(None), DEFAULT_GITHUB_API_BASE);
}
#[test]
fn api_base_uses_trimmed_explicit_value() {
assert_eq!(
resolve_api_base(Some(" https://ghe.example.com/api/v3/ ")),
"https://ghe.example.com/api/v3/"
);
}
#[test]
fn prefix_mode_uses_env_when_present() {
assert_eq!(
resolve_prefix_mode(None, Some(" prefer ")),
PrefixProxyMode::Prefer
);
}
#[test]
fn prefix_mode_prefers_explicit_value() {
assert_eq!(
resolve_prefix_mode(Some(PrefixProxyMode::Fallback), Some("prefer")),
PrefixProxyMode::Fallback
);
}
#[test]
fn debug_uses_env_when_flag_is_absent() {
assert!(resolve_debug(false, Some("true")));
assert!(!resolve_debug(false, Some("0")));
}
#[test]
fn debug_flag_overrides_env() {
assert!(resolve_debug(true, Some("0")));
}
#[test]
fn resolve_cli_uses_builtin_proxy_for_prefer_mode_when_unset() {
let cli = Cli {
repo: "owner/repo".to_string(),
remote_path: "README.md".to_string(),
local_target: PathBuf::from("./README.md"),
git_ref: None,
token: None,
api_base: Some(" https://ghe.example.com/api/v3 ".to_string()),
proxy_base: None,
prefix_mode: Some(PrefixProxyMode::Prefer),
concurrency: 8,
language: Some(Language::En),
overwrite: true,
json: true,
debug: false,
no_color: true,
};
let options = resolve_cli(cli).expect("cli should resolve");
assert_eq!(options.prefix_mode, PrefixProxyMode::Prefer);
assert_eq!(options.proxy_base, DEFAULT_GH_PROXY);
assert_eq!(options.api_base, "https://ghe.example.com/api/v3");
assert!(options.overwrite);
assert!(options.json);
}
#[test]
fn resolve_cli_preserves_explicit_concurrency() {
let cli = Cli {
repo: "owner/repo".to_string(),
remote_path: "src".to_string(),
local_target: PathBuf::from("./downloads"),
git_ref: None,
token: None,
api_base: None,
proxy_base: None,
prefix_mode: None,
concurrency: 12,
language: Some(Language::En),
overwrite: false,
json: false,
debug: false,
no_color: true,
};
let options = resolve_cli(cli).expect("cli should resolve");
assert_eq!(options.concurrency, 12);
}
#[test]
fn local_target_is_lexically_normalized_for_relative_paths() {
let current_dir = env::current_dir().expect("current dir");
let resolved = resolve_local_target(Path::new("./var/./nested/../file.txt"))
.expect("local target should resolve");
assert_eq!(resolved, current_dir.join("var/file.txt"));
}
#[test]
fn local_target_is_lexically_normalized_for_absolute_paths() {
assert_eq!(
resolve_local_target(Path::new("/tmp/./gh-download/../target")).expect("absolute"),
PathBuf::from("/tmp/target")
);
}
}