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::PathBuf;
use config::{Case, Config, ConfigError, File};
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::{find_unknown_keys, valid_user_config_keys};
pub use sections::{
CommitConfig, CommitGenerationConfig, CopyIgnoredConfig, ListConfig, MergeConfig,
OverridableConfig, StageMode, StepConfig, SwitchConfig, SwitchPickerConfig,
UserProjectOverrides,
};
#[derive(Debug)]
pub enum LoadError {
File {
path: PathBuf,
label: &'static str,
err: Box<toml::de::Error>,
},
Env {
err: ConfigError,
override_vars: Vec<String>,
},
Other(ConfigError),
}
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::Other(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for LoadError {}
fn collect_worktrunk_override_vars() -> Vec<String> {
const INFRA_VARS: &[&str] = &[
"WORKTRUNK_CONFIG_PATH",
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"WORKTRUNK_APPROVALS_PATH",
];
let mut vars: Vec<String> = std::env::vars()
.filter_map(|(k, _)| {
if !k.starts_with("WORKTRUNK_") {
return None;
}
if INFRA_VARS.contains(&k.as_str()) || k.starts_with("WORKTRUNK_TEST_") {
return None;
}
Some(k)
})
.collect();
vars.sort();
vars
}
#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
pub struct UserConfig {
#[serde(default)]
pub projects: std::collections::BTreeMap<String, UserProjectOverrides>,
#[serde(flatten, default)]
pub configs: OverridableConfig,
#[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::Message(e.to_string()))
}
pub(crate) fn load_with_cause() -> Result<Self, LoadError> {
let mut builder = Config::builder();
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>(
&system_path,
&find_unknown_keys(&content),
"System config",
);
let migrated = super::deprecation::migrate_content(&content);
if let Err(err) = toml::from_str::<OverridableConfig>(&migrated) {
return Err(LoadError::File {
path: system_path,
label: "System config",
err: Box::new(err),
});
}
if let Err(err) = toml::from_str::<Self>(&migrated) {
return Err(LoadError::File {
path: system_path,
label: "System config",
err: Box::new(err),
});
}
builder = builder.add_source(File::from_str(&migrated, config::FileFormat::Toml));
}
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>(
config_path,
&find_unknown_keys(&content),
"User config",
);
if let Err(err) = toml::from_str::<OverridableConfig>(&migrated) {
return Err(LoadError::File {
path: config_path.clone(),
label: "User config",
err: Box::new(err),
});
}
if let Err(err) = toml::from_str::<Self>(&migrated) {
return Err(LoadError::File {
path: config_path.clone(),
label: "User config",
err: Box::new(err),
});
}
builder = builder.add_source(File::from_str(&migrated, config::FileFormat::Toml));
}
} 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)
))
);
}
builder = builder.add_source(
config::Environment::with_prefix("WORKTRUNK")
.prefix_separator("_")
.separator("__")
.convert_case(Case::Kebab)
.try_parsing(true),
);
let config: Self = builder
.build()
.map_err(LoadError::Other)?
.try_deserialize()
.map_err(|err| {
LoadError::Env {
err,
override_vars: collect_worktrunk_override_vars(),
}
})?;
config.validate().map_err(LoadError::Other)?;
Ok(config)
}
#[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::Message(e.to_string()))?;
config.validate()?;
Ok(config)
}
}