use indexmap::IndexMap;
use indexmap::IndexSet;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::path::{Path, PathBuf};
use crate::{Result, cache::CacheManagerBuilder, env, hash, hook::Hook, version};
use eyre::{WrapErr, bail};
impl Config {
#[tracing::instrument(level = "info", name = "config.load")]
pub fn get() -> Result<Self> {
let mut config = Self::load_project_config()?;
config.apply_hkrc()?;
config.validate()?;
Ok(config)
}
#[tracing::instrument(level = "info", name = "config.read", skip_all, fields(path = %path.display()))]
fn read(path: &Path) -> Result<Self> {
let ext = path.extension().unwrap_or_default().to_str().unwrap();
let mut config: Config = match ext {
"toml" => {
let raw = xx::file::read_to_string(path)?;
toml::from_str(&raw)?
}
"yaml" | "yml" => {
let raw = xx::file::read_to_string(path)?;
serde_yaml::from_str(&raw)?
}
"json" => {
let raw = xx::file::read_to_string(path)?;
serde_json::from_str(&raw)?
}
"pkl" => {
if env::HK_PKL_BACKEND.as_deref() == Some("pklr") {
run_pklr(path)?
} else {
run_pkl(&["eval"], path)?
}
}
_ => {
bail!("Unsupported file extension: {}", ext);
}
};
config.init(path)?;
Ok(config)
}
fn analyze_imports(path: &Path) -> Result<IndexSet<PathBuf>> {
if env::HK_PKL_BACKEND.as_deref() == Some("pklr") {
return pklr::analyze_imports(path)
.map(|v| v.into_iter().collect())
.map_err(|e| eyre::eyre!("{e}"));
}
let imports: PklImports =
run_pkl(&["analyze", "imports"], path).wrap_err("failed to analyze pkl")?;
let mut paths = IndexSet::new();
for uri in imports.resolvedImports.keys() {
if let Some(file_path) = uri.strip_prefix("file://") {
paths.insert(PathBuf::from(file_path));
}
}
Ok(paths)
}
fn init(&mut self, path: &Path) -> Result<()> {
self.path = path.to_path_buf();
if let Some(min_hk_version) = &self.min_hk_version {
version::version_cmp_or_bail(min_hk_version)?;
}
for (name, hook) in self.hooks.iter_mut() {
hook.init(name)?;
}
for (key, value) in self.env.iter() {
unsafe { std::env::set_var(key, value) };
}
Ok(())
}
#[tracing::instrument(level = "info", name = "config.load_project")]
fn load_project_config() -> Result<Self> {
let paths = Self::project_config_search_paths();
if let Some(path) = Self::find_project_config(&paths) {
return Self::load_config_cached(path);
}
debug!("No config file found, using default");
let mut config = Config::default();
config.init(Path::new(&paths[0]))?;
Ok(config)
}
fn project_config_search_paths() -> Vec<String> {
if let Some(hk_file) = env::HK_FILE.as_ref() {
vec![hk_file.clone()]
} else {
[
"hk.local.pkl",
".config/hk.local.pkl",
"hk.pkl",
".config/hk.pkl",
"hk.toml",
"hk.yaml",
"hk.yml",
"hk.json",
]
.iter()
.map(|s| s.to_string())
.collect()
}
}
fn find_project_config(paths: &[String]) -> Option<PathBuf> {
let mut cwd = std::env::current_dir().ok()?;
while cwd != Path::new("/") {
for name in paths {
let p = cwd.join(name);
if p.exists() {
return Some(p);
}
}
cwd = cwd.parent().map(PathBuf::from).unwrap_or_default();
}
None
}
pub fn project_config_exists() -> bool {
Self::find_project_config(&Self::project_config_search_paths()).is_some()
}
fn load_config_cached(path: PathBuf) -> Result<Config> {
let hash_key = format!("{}.json", hash::hash_to_str(&path));
let cache_dir = env::HK_CACHE_DIR.join("configs");
let is_pkl = path.extension().is_some_and(|ext| ext == "pkl");
let fresh_files: Vec<PathBuf> = if is_pkl {
let imports_cache_path =
cache_dir.join(format!("{}-imports.json", hash::hash_to_str(&path)));
let imports_cache_mgr = CacheManagerBuilder::new(imports_cache_path)
.with_fresh_files(vec![path.clone()])
.build::<IndexSet<PathBuf>>();
let imports = imports_cache_mgr
.get_or_try_init(|| Self::analyze_imports(&path))?
.clone();
let mut files: IndexSet<PathBuf> = imports;
files.insert(path.clone());
files.into_iter().collect()
} else {
vec![path.clone()]
};
let config_cache_path = cache_dir.join(hash_key);
let config_cache_mgr = CacheManagerBuilder::new(config_cache_path)
.with_fresh_files(fresh_files)
.build::<Config>();
let mut config = config_cache_mgr
.get_or_try_init(|| {
Self::read(&path)
.wrap_err_with(|| format!("Failed to read config file: {}", path.display()))
})?
.clone();
config.init(&path)?;
Ok(config)
}
fn apply_user_config(&mut self, user_config: &Option<UserConfig>) -> Result<()> {
if let Some(user_config) = user_config {
if user_config.display_skip_reasons.is_some() {
self.display_skip_reasons = user_config.display_skip_reasons.clone();
}
if user_config.hide_warnings.is_some() {
self.hide_warnings = user_config.hide_warnings.clone();
}
if user_config.warnings.is_some() {
self.warnings = user_config.warnings.clone();
}
if user_config.stage.is_some() {
self.stage = user_config.stage
}
for (key, value) in &user_config.environment {
self.env.insert(key.clone(), value.clone());
unsafe { std::env::set_var(key, value) };
}
for (hook_name, user_hook_config) in &user_config.hooks {
if let Some(hook) = self.hooks.get_mut(hook_name) {
for (step_or_group_name, step_or_group) in hook.steps.iter_mut() {
match step_or_group {
crate::hook::StepOrGroup::Step(step) => {
let step_config = user_hook_config.steps.get(step_or_group_name);
Self::apply_user_config_to_step(
step,
user_hook_config,
step_config,
)?;
}
crate::hook::StepOrGroup::Group(group) => {
for (step_name, step) in group.steps.iter_mut() {
let step_config = user_hook_config.steps.get(step_name);
Self::apply_user_config_to_step(
step,
user_hook_config,
step_config,
)?;
}
}
}
}
}
}
}
Ok(())
}
fn apply_user_config_to_step(
step: &mut crate::step::Step,
hook_config: &UserHookConfig,
step_config: Option<&UserStepConfig>,
) -> Result<()> {
for (key, value) in &hook_config.environment {
step.env.entry(key.clone()).or_insert_with(|| value.clone());
}
if let Some(step_config) = step_config {
for (key, value) in &step_config.environment {
step.env.entry(key.clone()).or_insert_with(|| value.clone());
}
if let Some(glob) = &step_config.glob {
step.glob = Some(glob.clone());
}
if let Some(exclude) = &step_config.exclude {
step.exclude = Some(exclude.clone());
}
if let Some(profiles) = &step_config.profiles {
step.profiles = Some(profiles.clone());
}
}
Ok(())
}
fn apply_hkrc(&mut self) -> Result<()> {
let explicit_path = crate::settings::Settings::cli_user_config_path();
let hkrc_path: Option<PathBuf> = if let Some(path) = explicit_path {
if !path.exists() {
bail!("Config file not found: {}", path.display());
}
deprecated_at!(
"1.37.0",
"2.0.0",
"hkrc-flag",
"--hkrc is deprecated. Use {}/config.pkl for global config \
or hk.local.pkl for per-project overrides.",
env::HK_CONFIG_DIR.display()
);
Some(path)
} else {
let cwd_path = PathBuf::from(".hkrc.pkl");
let home_path = env::HOME_DIR.join(".hkrc.pkl");
let xdg_path = env::HK_CONFIG_DIR.join("config.pkl");
if cwd_path.exists() {
deprecated_at!(
"1.37.0",
"2.0.0",
"hkrc-cwd",
".hkrc.pkl is deprecated. Use hk.local.pkl in the project root instead."
);
Some(cwd_path)
} else if home_path.exists() {
deprecated_at!(
"1.37.0",
"2.0.0",
"hkrc-home",
"~/.hkrc.pkl is deprecated. Use {}/config.pkl instead.",
env::HK_CONFIG_DIR.display()
);
Some(home_path)
} else if xdg_path.exists() {
Some(xdg_path) } else {
None
}
};
if let Some(path) = hkrc_path {
let json_value: serde_json::Value = if env::HK_PKL_BACKEND.as_deref() == Some("pklr") {
run_pklr(&path)?
} else {
run_pkl(&["eval"], &path)?
};
if json_value.get("environment").is_some() {
let user_config: UserConfig = serde_json::from_value(json_value)
.wrap_err("failed to parse hkrc as UserConfig")?;
self.apply_user_config(&Some(user_config))?;
} else {
let mut hkrc_config: Config = serde_json::from_value(json_value)
.wrap_err("failed to parse hkrc as Config")?;
hkrc_config.init(&path)?;
self.merge_from_hkrc(hkrc_config);
}
}
Ok(())
}
fn merge_from_hkrc(&mut self, hkrc: Config) {
for (key, value) in hkrc.env {
if let indexmap::map::Entry::Vacant(e) = self.env.entry(key.clone()) {
unsafe { std::env::set_var(&key, &value) };
e.insert(value);
}
}
self.fail_fast = self.fail_fast.or(hkrc.fail_fast);
self.stage = self.stage.or(hkrc.stage);
self.display_skip_reasons = self
.display_skip_reasons
.take()
.or(hkrc.display_skip_reasons);
self.hide_warnings = self.hide_warnings.take().or(hkrc.hide_warnings);
self.warnings = self.warnings.take().or(hkrc.warnings);
self.exclude = self.exclude.take().or(hkrc.exclude);
self.profiles = self.profiles.take().or(hkrc.profiles);
self.skip_hooks = self.skip_hooks.take().or(hkrc.skip_hooks);
self.skip_steps = self.skip_steps.take().or(hkrc.skip_steps);
self.default_branch = self.default_branch.take().or(hkrc.default_branch);
self.min_hk_version = self.min_hk_version.take().or(hkrc.min_hk_version);
for (hook_name, hkrc_hook) in hkrc.hooks {
if let Some(project_hook) = self.hooks.get_mut(&hook_name) {
for (step_name, hkrc_step) in hkrc_hook.steps {
project_hook.steps.entry(step_name).or_insert(hkrc_step);
}
} else {
self.hooks.insert(hook_name, hkrc_hook);
}
}
}
}
fn get_http_proxy() -> Option<String> {
std::env::var("http_proxy")
.or_else(|_| std::env::var("HTTP_PROXY"))
.or_else(|_| std::env::var("https_proxy"))
.or_else(|_| std::env::var("HTTPS_PROXY"))
.ok()
.filter(|s| !s.is_empty())
}
fn run_pklr<T: DeserializeOwned>(path: &Path) -> Result<T> {
let client = build_pklr_http_client()?;
let http_rewrites = env::HK_PKL_HTTP_REWRITE
.as_deref()
.map(|s| s.split(',').map(String::from).collect::<Vec<_>>())
.unwrap_or_default();
let options = pklr::EvalOptions {
client: Some(client),
http_rewrites,
};
let rt = tokio::runtime::Handle::try_current();
let json = match rt {
Ok(handle) => tokio::task::block_in_place(|| {
handle.block_on(pklr::eval_to_json_with_options(path, options))
}),
Err(_) => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(pklr::eval_to_json_with_options(path, options))
}
}
.map_err(|e| eyre::eyre!("{e}"))?;
serde_json::from_value(json).wrap_err("failed to deserialize pklr output")
}
fn build_pklr_http_client() -> Result<pklr::reqwest::Client> {
let mut builder = pklr::reqwest::Client::builder();
if let Some(proxy_url) = get_http_proxy() {
let mut proxy = pklr::reqwest::Proxy::all(&proxy_url)
.map_err(|e| eyre::eyre!("invalid proxy URL: {e}"))?;
if let Some(no_proxy) = get_no_proxy() {
proxy = proxy.no_proxy(pklr::reqwest::NoProxy::from_string(&no_proxy));
}
builder = builder.proxy(proxy);
}
if let Some(ca_path) = env::HK_PKL_CA_CERTIFICATES.as_ref() {
let cert_pem = std::fs::read(ca_path)
.map_err(|e| eyre::eyre!("failed to read CA certificate {}: {e}", ca_path.display()))?;
let certs = pklr::reqwest::Certificate::from_pem_bundle(&cert_pem)
.map_err(|e| eyre::eyre!("invalid CA certificate: {e}"))?;
for cert in certs {
builder = builder.add_root_certificate(cert);
}
}
builder
.build()
.map_err(|e| eyre::eyre!("failed to build HTTP client: {e}"))
}
fn get_no_proxy() -> Option<String> {
std::env::var("no_proxy")
.or_else(|_| std::env::var("NO_PROXY"))
.ok()
.filter(|s| !s.is_empty())
}
fn run_pkl<T: DeserializeOwned>(subcommand: &[&str], path: &Path) -> Result<T> {
use std::process::{Command, Stdio};
let try_run = |bin: &str| -> Result<T> {
let bin_parts = shell_words::split(bin).wrap_err("failed to parse pkl command")?;
let (cmd, bin_args) = bin_parts
.split_first()
.ok_or_else(|| eyre::eyre!("empty pkl command"))?;
let mut args: Vec<String> = bin_args.to_vec();
args.extend(subcommand.iter().map(|s| s.to_string()));
args.extend(["-f".to_string(), "json".to_string()]);
if let Some(proxy) = get_http_proxy() {
if !proxy.starts_with("http://") {
debug!("Ignoring proxy {proxy}: pkl only supports http:// proxies");
} else if proxy.contains('@') {
debug!("Ignoring proxy {proxy}: pkl does not support proxy authentication");
} else {
args.push("--http-proxy".to_string());
args.push(proxy);
}
}
if let Some(no_proxy) = get_no_proxy() {
args.push("--http-no-proxy".to_string());
args.push(no_proxy);
}
if let Some(http_rewrite) = env::HK_PKL_HTTP_REWRITE.as_ref() {
args.push("--http-rewrite".to_string());
args.push(http_rewrite.to_string());
}
if let Some(ca_certificates) = env::HK_PKL_CA_CERTIFICATES.as_ref() {
args.push("--ca-certificates".to_string());
args.push(ca_certificates.display().to_string());
}
args.push(path.display().to_string());
let output = Command::new(cmd)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.wrap_err("failed to execute pkl command")?;
if !output.status.success() {
handle_pkl_error(&output, path)?;
}
let json = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&json).wrap_err("failed to parse pkl output")
};
match try_run("pkl") {
Ok(result) => Ok(result),
Err(err) => {
if xx::file::which("pkl").is_none() {
if let Ok(result) = try_run("mise x -- pkl") {
return Ok(result);
}
bail!("install pkl cli to use pkl config files https://pkl-lang.org/");
}
Err(err).wrap_err("failed to run pkl")
}
}
}
fn handle_pkl_error(output: &std::process::Output, path: &Path) -> Result<()> {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Cannot find type `Hook`") || stderr.contains("Cannot find type `Step`") {
let version = env!("CARGO_PKG_VERSION");
bail!(
"Missing 'amends' declaration in {}. \n\n\
Your hk.pkl file should start with one of:\n\
• amends \"pkl/Config.pkl\" (if vendored)\n\
• amends \"package://github.com/jdx/hk/releases/download/v{version}/hk@{version}#/Config.pkl\" (for released versions)\n\n\
See https://github.com/jdx/hk for more information.",
path.display()
);
} else if stderr.contains("Module URI") && stderr.contains("has invalid syntax") {
let version = env!("CARGO_PKG_VERSION");
bail!(
"Invalid module URI in {}. \n\n\
Make sure your 'amends' declaration uses a valid path or package URL.\n\
Examples:\n\
• amends \"pkl/Config.pkl\" (if vendored)\n\
• amends \"package://github.com/jdx/hk/releases/download/v{version}/hk@{version}#/Config.pkl\"",
path.display()
);
}
let code = output
.status
.code()
.map_or("unknown".to_string(), |c| c.to_string());
bail!(
"Failed to evaluate Pkl config at {}\n\nExit code: {}\n\nError output:\n{}",
path.display(),
code,
stderr
);
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(debug_assertions, serde(deny_unknown_fields))]
pub struct Config {
pub min_hk_version: Option<String>,
#[serde(default)]
pub hooks: IndexMap<String, Hook>,
pub default_branch: Option<String>,
#[serde(skip)]
#[serde(default)]
pub path: PathBuf,
#[serde(default)]
pub env: IndexMap<String, String>,
pub fail_fast: Option<bool>,
pub display_skip_reasons: Option<Vec<String>>,
pub hide_warnings: Option<Vec<String>>,
pub warnings: Option<Vec<String>>,
pub exclude: Option<StringOrList>,
pub stage: Option<bool>,
pub profiles: Option<Vec<String>>,
pub skip_hooks: Option<Vec<String>>,
pub skip_steps: Option<Vec<String>>,
}
impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", toml::to_string(self).unwrap())
}
}
impl Config {
pub fn validate(&self) -> Result<()> {
for (hook_name, hook) in &self.hooks {
for (step_name, step_or_group) in &hook.steps {
match step_or_group {
crate::hook::StepOrGroup::Step(step) => {
if step.stage.is_some() && step.fix.is_none() {
bail!(
"Step '{}' in hook '{}' has 'stage' attribute but no 'fix' command. \
Steps that stage files must have a fix command.",
step_name,
hook_name
);
}
}
crate::hook::StepOrGroup::Group(group) => {
for (group_step_name, group_step) in &group.steps {
if group_step.stage.is_some() && group_step.fix.is_none() {
bail!(
"Step '{}' in group '{}' of hook '{}' has 'stage' attribute but no 'fix' command. \
Steps that stage files must have a fix command.",
group_step_name,
step_name,
hook_name
);
}
}
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct UserConfig {
#[serde(default)]
pub environment: IndexMap<String, String>,
#[serde(default)]
pub defaults: UserDefaults,
#[serde(default)]
pub hooks: IndexMap<String, UserHookConfig>,
#[serde(rename = "display_skip_reasons")]
pub display_skip_reasons: Option<Vec<String>>,
#[serde(rename = "hide_warnings")]
pub hide_warnings: Option<Vec<String>>,
#[serde(rename = "warnings")]
pub warnings: Option<Vec<String>>,
pub stage: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct UserDefaults {
pub jobs: Option<u16>,
pub fail_fast: Option<bool>,
pub profiles: Option<Vec<String>>,
pub all: Option<bool>,
pub fix: Option<bool>,
pub check: Option<bool>,
pub exclude: Option<StringOrList>,
pub skip_steps: Option<StringOrList>,
pub skip_hooks: Option<StringOrList>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct UserHookConfig {
#[serde(default)]
pub environment: IndexMap<String, String>,
pub jobs: Option<u16>,
pub fail_fast: Option<bool>,
pub profiles: Option<Vec<String>>,
pub all: Option<bool>,
pub fix: Option<bool>,
pub check: Option<bool>,
#[serde(default)]
pub steps: IndexMap<String, UserStepConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct UserStepConfig {
#[serde(default)]
pub environment: IndexMap<String, String>,
pub fail_fast: Option<bool>,
pub profiles: Option<Vec<String>>,
pub all: Option<bool>,
pub fix: Option<bool>,
pub check: Option<bool>,
pub glob: Option<crate::step::Pattern>,
pub exclude: Option<crate::step::Pattern>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum StringOrList {
String(String),
List(Vec<String>),
}
impl IntoIterator for StringOrList {
type Item = String;
type IntoIter = std::vec::IntoIter<String>;
fn into_iter(self) -> Self::IntoIter {
match self {
StringOrList::String(s) => vec![s].into_iter(),
StringOrList::List(list) => list.into_iter(),
}
}
}
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
struct PklImports {
resolvedImports: std::collections::HashMap<String, String>,
}