mod accessors;
mod merge;
pub(crate) mod mutation;
mod path;
mod persistence;
mod resolved;
mod schema;
mod sections;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use super::ConfigError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use merge::Merge;
pub use path::{
config_path, default_config_path, default_system_config_path, set_config_path,
system_config_path,
};
pub use resolved::ResolvedConfig;
pub use schema::valid_user_config_keys;
pub use sections::{
CommitConfig, CommitGenerationConfig, CopyIgnoredConfig, ListConfig, MergeConfig, StageMode,
StepConfig, SwitchConfig, SwitchPickerConfig, UserProjectOverrides,
};
#[derive(Debug)]
pub enum LoadError {
File {
path: PathBuf,
label: &'static str,
err: Box<toml::de::Error>,
},
Env {
err: String,
vars: Vec<(String, String)>,
},
Validation(String),
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::File { path, label, err } => {
write!(
f,
"{label} at {} failed to parse:\n{err}",
crate::path::format_path_for_display(path)
)
}
LoadError::Env { err, .. } => write!(f, "{err}"),
LoadError::Validation(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for LoadError {}
struct EnvVar {
name: String,
segments: Vec<String>,
typed_value: toml::Value,
raw_value: String,
}
fn parse_worktrunk_env_vars() -> Vec<EnvVar> {
const INFRA_VARS: &[&str] = &[
"WORKTRUNK_CONFIG_PATH",
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"WORKTRUNK_APPROVALS_PATH",
];
let mut env_vars: Vec<_> = std::env::vars()
.filter(|(k, _)| k.starts_with("WORKTRUNK_"))
.filter(|(k, _)| !INFRA_VARS.contains(&k.as_str()))
.filter(|(k, _)| !k.starts_with("WORKTRUNK_TEST_"))
.collect();
env_vars.sort_by(|a, b| a.0.cmp(&b.0));
env_vars
.into_iter()
.filter_map(|(key, value)| {
let stripped = &key["WORKTRUNK_".len()..];
let segments: Vec<String> = stripped
.split("__")
.map(|s| {
s.to_lowercase()
.replace('_', "-")
.trim_start_matches('-')
.to_string()
})
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
return None;
}
Some(EnvVar {
name: key,
segments,
typed_value: try_parse_value(&value),
raw_value: value,
})
})
.collect()
}
fn resolve_env_overlay(file_table: &toml::Table, vars: &[EnvVar]) -> toml::Table {
let mut overlay = toml::Table::new();
for var in vars {
let mut probe = file_table.clone();
set_nested_value(&mut probe, &var.segments, var.typed_value.clone());
if toml::Value::Table(probe).try_into::<UserConfig>().is_ok() {
set_nested_value(&mut overlay, &var.segments, var.typed_value.clone());
} else {
set_nested_value(
&mut overlay,
&var.segments,
toml::Value::String(var.raw_value.clone()),
);
}
}
overlay
}
fn try_parse_value(s: &str) -> toml::Value {
if s.eq_ignore_ascii_case("true") {
return toml::Value::Boolean(true);
}
if s.eq_ignore_ascii_case("false") {
return toml::Value::Boolean(false);
}
if let Ok(n) = s.parse::<i64>() {
return toml::Value::Integer(n);
}
if let Ok(n) = s.parse::<f64>() {
return toml::Value::Float(n);
}
toml::Value::String(s.to_string())
}
fn set_nested_value(table: &mut toml::Table, path: &[String], value: toml::Value) {
if path.len() == 1 {
table.insert(path[0].clone(), value);
return;
}
let entry = table
.entry(&path[0])
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(inner) = entry {
set_nested_value(inner, &path[1..], value);
}
}
fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
for (key, value) in overlay {
match (base.get_mut(&key), &value) {
(Some(toml::Value::Table(base_t)), toml::Value::Table(overlay_t)) => {
deep_merge_table(base_t, overlay_t.clone());
}
_ => {
base.insert(key, value);
}
}
}
}
fn load_config_file(
path: &Path,
migrated: &str,
label: &'static str,
) -> Result<toml::Table, LoadError> {
if let Err(err) = toml::from_str::<UserConfig>(migrated) {
return Err(LoadError::File {
path: path.to_path_buf(),
label,
err: Box::new(err),
});
}
Ok(migrated
.parse::<toml::Table>()
.expect("valid TOML after UserConfig parse"))
}
#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
pub struct UserConfig {
#[serde(default)]
pub projects: std::collections::BTreeMap<String, UserProjectOverrides>,
#[serde(flatten, default)]
pub hooks: crate::config::HooksConfig,
#[serde(
rename = "worktree-path",
default,
skip_serializing_if = "Option::is_none"
)]
pub worktree_path: Option<String>,
#[serde(default, skip_serializing_if = "super::is_default")]
pub list: sections::ListConfig,
#[serde(default, skip_serializing_if = "super::is_default")]
pub commit: sections::CommitConfig,
#[serde(default, skip_serializing_if = "super::is_default")]
pub merge: sections::MergeConfig,
#[serde(default, skip_serializing_if = "super::is_default")]
pub switch: sections::SwitchConfig,
#[serde(default, skip_serializing_if = "super::is_default")]
pub step: sections::StepConfig,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub aliases: std::collections::BTreeMap<String, crate::config::commands::CommandConfig>,
#[serde(
default,
rename = "skip-shell-integration-prompt",
skip_serializing_if = "std::ops::Not::not"
)]
pub skip_shell_integration_prompt: bool,
#[serde(
default,
rename = "skip-commit-generation-prompt",
skip_serializing_if = "std::ops::Not::not"
)]
pub skip_commit_generation_prompt: bool,
}
impl UserConfig {
pub fn load() -> Result<Self, ConfigError> {
Self::load_with_cause().map_err(|e| ConfigError(e.to_string()))
}
pub(crate) fn load_with_cause() -> Result<Self, LoadError> {
let (config, warnings) = Self::load_with_warnings();
if let Some(err) = warnings.into_iter().next() {
return Err(err);
}
Ok(config)
}
pub(crate) fn load_with_warnings() -> (Self, Vec<LoadError>) {
let mut warnings = Vec::new();
let mut merged_table = toml::Table::new();
if let Some(system_path) = path::system_config_path()
&& let Ok(content) = std::fs::read_to_string(&system_path)
{
super::deprecation::warn_unknown_fields::<UserConfig>(
&content,
&system_path,
"System config",
);
let migrated = super::deprecation::migrate_content(&content);
match load_config_file(&system_path, &migrated, "System config") {
Ok(table) => deep_merge_table(&mut merged_table, table),
Err(e) => warnings.push(e),
}
}
let config_path = config_path();
if let Some(config_path) = config_path.as_ref()
&& config_path.exists()
{
if let Ok(content) = std::fs::read_to_string(config_path) {
let migrated = super::deprecation::check_and_migrate(
config_path,
&content,
true,
"User config",
None,
true,
)
.map(|result| result.migrated_content)
.unwrap_or_else(|_| super::deprecation::migrate_content(&content));
super::deprecation::warn_unknown_fields::<UserConfig>(
&content,
config_path,
"User config",
);
match load_config_file(config_path, &migrated, "User config") {
Ok(table) => deep_merge_table(&mut merged_table, table),
Err(e) => warnings.push(e),
}
}
} else if let Some(config_path) = config_path.as_ref()
&& path::is_config_path_explicit()
{
crate::styling::eprintln!(
"{}",
crate::styling::warning_message(format!(
"Config file not found: {}",
crate::path::format_path_for_display(config_path)
))
);
}
let env_vars = parse_worktrunk_env_vars();
if env_vars.is_empty() {
return Self::finalize(merged_table, warnings);
}
let file_table = merged_table.clone();
let env_overlay = resolve_env_overlay(&file_table, &env_vars);
deep_merge_table(&mut merged_table, env_overlay);
match toml::Value::Table(merged_table).try_into::<Self>() {
Ok(config) => match config.validate() {
Ok(()) => (config, warnings),
Err(e) => {
warnings.push(LoadError::Validation(e.0));
(Self::default(), warnings)
}
},
Err(err) => {
warnings.push(LoadError::Env {
err: err.to_string(),
vars: env_vars
.iter()
.map(|v| (v.name.clone(), v.raw_value.clone()))
.collect(),
});
Self::finalize(file_table, warnings)
}
}
}
fn finalize(table: toml::Table, mut warnings: Vec<LoadError>) -> (Self, Vec<LoadError>) {
match toml::Value::Table(table).try_into::<Self>() {
Ok(config) => match config.validate() {
Ok(()) => (config, warnings),
Err(e) => {
warnings.push(LoadError::Validation(e.0));
(Self::default(), warnings)
}
},
Err(err) => {
warnings.push(LoadError::Validation(err.to_string()));
(Self::default(), warnings)
}
}
}
#[cfg(test)]
pub(crate) fn load_from_str(content: &str) -> Result<Self, ConfigError> {
let migrated = crate::config::deprecation::migrate_content(content);
let config: Self = toml::from_str(&migrated).map_err(|e| ConfigError(e.to_string()))?;
config.validate()?;
Ok(config)
}
}