use crate::error::{GwmError, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
pub const CONFIG_FILE: &str = ".gwm.toml";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub forge: Option<crate::forge::ForgeKind>,
#[serde(default)]
pub forge_hosts: BTreeMap<String, crate::forge::ForgeKind>,
#[serde(default)]
pub worktree: WorktreeConfig,
#[serde(default)]
pub bootstrap: BootstrapConfig,
#[serde(default)]
pub hooks: LifecycleHooksConfig,
#[serde(default)]
pub doctor: DoctorConfig,
#[serde(default)]
pub tui: TuiConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub git_tui: GitTuiConfig,
#[serde(default)]
pub review: ReviewConfig,
#[serde(default)]
pub labels: Vec<LabelConfig>,
#[serde(default)]
pub milestones: Vec<MilestoneConfig>,
#[serde(rename = "branch_types", default)]
pub branch_types: Vec<BranchType>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
#[serde(default)]
pub gitmoji: BTreeMap<String, String>,
#[serde(default)]
pub issue_template: IssueTemplateConfig,
#[serde(default)]
pub pr_template: PrTemplateConfig,
#[serde(default)]
pub exec: ExecConfig,
#[serde(default)]
pub clean: CleanConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecConfig {
#[serde(default)]
pub jobs: Option<u32>,
#[serde(default)]
pub profiles: BTreeMap<String, ExecProfile>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecProfile {
pub command: Vec<String>,
#[serde(default)]
pub jobs: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CleanConfig {
#[serde(default)]
pub profiles: BTreeMap<String, CleanProfile>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CleanProfile {
pub dirs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelConfig {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MilestoneConfig {
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub due_on: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BranchType {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IssueTemplateConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub by_type: BTreeMap<String, IssueTemplateTypeConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IssueTemplateTypeConfig {
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub surface: Option<String>,
#[serde(default)]
pub title_prefix: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrTemplateConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub by_type: BTreeMap<String, PrTemplateTypeConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrTemplateTypeConfig {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchTypesSource {
Default,
Config,
}
impl BranchTypesSource {
pub fn label(self) -> &'static str {
match self {
Self::Default => "built-in defaults",
Self::Config => ".gwm.toml",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedBranchTypes {
pub types: Vec<BranchType>,
pub source: BranchTypesSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorktreeConfig {
#[serde(default = "default_worktree_base")]
pub base: String,
#[serde(default = "default_path_pattern")]
pub path_pattern: String,
#[serde(default = "default_branch_pattern")]
pub branch_pattern: String,
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
base: default_worktree_base(),
path_pattern: default_path_pattern(),
branch_pattern: default_branch_pattern(),
}
}
}
fn default_worktree_base() -> String {
"{home}/cc-worktree/{repo}".into()
}
fn default_path_pattern() -> String {
"{type}-{issue}-{desc}".into()
}
pub(crate) fn default_branch_pattern() -> String {
"{type}/#{issue}-{desc}".into()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapConfig {
#[serde(default)]
pub copy: Vec<CopyStep>,
#[serde(default)]
pub guard: Vec<Guard>,
#[serde(default)]
pub no_symlink: Vec<NoSymlink>,
#[serde(default)]
pub command: Vec<CommandStep>,
#[serde(default)]
pub fallback: HashMap<String, FallbackContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CopyStep {
pub from: String,
pub to: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub guards: Vec<String>,
#[serde(default)]
pub fallback: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Guard {
pub name: String,
#[serde(default)]
pub deny_patterns: Vec<String>,
#[serde(default = "default_on_match")]
pub on_match: String,
#[serde(default)]
pub example_file: Option<String>,
}
fn default_on_match() -> String {
"abort".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NoSymlink {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandStep {
pub name: String,
pub run: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LifecycleHooksConfig {
#[serde(default)]
pub pre_create: Vec<HookStep>,
#[serde(default)]
pub post_create: Vec<HookStep>,
#[serde(default)]
pub pre_bootstrap: Vec<HookStep>,
#[serde(default)]
pub post_bootstrap: Vec<HookStep>,
#[serde(default)]
pub pre_remove: Vec<HookStep>,
#[serde(default)]
pub post_remove: Vec<HookStep>,
}
impl LifecycleHooksConfig {
pub fn has_any(&self) -> bool {
!self.pre_create.is_empty()
|| !self.post_create.is_empty()
|| !self.pre_bootstrap.is_empty()
|| !self.post_bootstrap.is_empty()
|| !self.pre_remove.is_empty()
|| !self.post_remove.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookStep {
pub name: String,
pub run: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub on_fail: HookOnFail,
}
impl From<CommandStep> for HookStep {
fn from(step: CommandStep) -> Self {
Self {
name: step.name,
run: step.run,
when: step.when,
env: step.env,
on_fail: HookOnFail::Abort,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HookOnFail {
#[default]
Abort,
Warn,
Ignore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FallbackContent {
pub target: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DoctorConfig {
#[serde(default = "default_trunks")]
pub trunks: Vec<String>,
}
impl Default for DoctorConfig {
fn default() -> Self {
Self {
trunks: default_trunks(),
}
}
}
fn default_trunks() -> Vec<String> {
vec!["dev".into(), "main".into()]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SidebarPosition {
Left,
#[default]
Right,
}
impl SidebarPosition {
pub const ALL: [SidebarPosition; 2] = [SidebarPosition::Right, SidebarPosition::Left];
pub const fn label(self) -> &'static str {
match self {
SidebarPosition::Left => "left",
SidebarPosition::Right => "right",
}
}
pub fn is_left(self) -> bool {
matches!(self, SidebarPosition::Left)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ClipboardMode {
#[default]
Auto,
Osc52,
Tools,
}
impl ClipboardMode {
pub const ALL: [ClipboardMode; 3] = [ClipboardMode::Auto, ClipboardMode::Osc52, ClipboardMode::Tools];
pub const fn label(self) -> &'static str {
match self {
ClipboardMode::Auto => "auto",
ClipboardMode::Osc52 => "osc52",
ClipboardMode::Tools => "tools",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SidebarOrientation {
Auto,
SideBySide,
#[default]
Stacked,
}
impl SidebarOrientation {
pub const ALL: [SidebarOrientation; 3] = [
SidebarOrientation::Stacked,
SidebarOrientation::SideBySide,
SidebarOrientation::Auto,
];
pub const fn label(self) -> &'static str {
match self {
SidebarOrientation::Auto => "auto",
SidebarOrientation::SideBySide => "side-by-side",
SidebarOrientation::Stacked => "stacked",
}
}
pub fn next(self) -> Self {
match self {
SidebarOrientation::Auto => SidebarOrientation::SideBySide,
SidebarOrientation::SideBySide => SidebarOrientation::Stacked,
SidebarOrientation::Stacked => SidebarOrientation::Auto,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MacroOpenMode {
#[default]
Pty,
MuxPane,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TuiMacroConfig {
pub command: String,
#[serde(default)]
pub open_in: MacroOpenMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TuiConfig {
#[serde(default = "default_confirm_countdown_secs")]
pub confirm_countdown_secs: u32,
#[serde(default = "default_auto_refresh_secs")]
pub auto_refresh_secs: u64,
#[serde(default)]
pub open: TuiOpenConfig,
#[serde(default)]
pub sidebar_position: SidebarPosition,
#[serde(default)]
pub sidebar_orientation: SidebarOrientation,
#[serde(default)]
pub clipboard: ClipboardMode,
#[serde(default)]
pub keys: TuiKeysConfig,
#[serde(default)]
pub macro1: Option<TuiMacroConfig>,
#[serde(default)]
pub macro2: Option<TuiMacroConfig>,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
confirm_countdown_secs: default_confirm_countdown_secs(),
auto_refresh_secs: default_auto_refresh_secs(),
open: TuiOpenConfig::default(),
sidebar_position: SidebarPosition::default(),
sidebar_orientation: SidebarOrientation::default(),
clipboard: ClipboardMode::default(),
keys: TuiKeysConfig::default(),
macro1: None,
macro2: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TuiKeysConfig {
pub raw: toml::Table,
}
impl TuiKeysConfig {
pub fn resolved_keymap(&self) -> Result<crate::tui::keymap::Keymap> {
use crate::tui::keymap::{Action, KeyStroke, Keymap};
let mut km = Keymap::defaults();
for (action_slug, value) in &self.raw {
if action_slug == TUI_KEYS_MODAL_NAMESPACE {
continue;
}
let chord_strings = match value {
toml::Value::Array(_) => as_chord_list(action_slug, value)?,
other => {
return Err(GwmError::Config(format!(
"tui.keys.{}: expected an array of chords; modal contexts go under [tui.keys.modal.<context>], got {}",
action_slug,
other.type_str()
)))
}
};
let action = Action::from_slug_compat(action_slug).ok_or_else(|| {
GwmError::Config(format!(
"tui.keys: unknown action {:?} (run `gwm tui keys` for the full list)",
action_slug
))
})?;
let mut parsed = Vec::with_capacity(chord_strings.len());
for chord_str in &chord_strings {
let chord = KeyStroke::parse_chord(chord_str).map_err(|e| rewrap(&format!("tui.keys.{}", action_slug), e))?;
parsed.push(chord);
}
km.apply_override(action, parsed)
.map_err(|e| rewrap(&format!("tui.keys.{}", action_slug), e))?;
}
Ok(km)
}
pub fn resolved_modal_keymap(&self) -> Result<crate::tui::modal_keymap::ModalKeymap> {
use crate::tui::modal_keymap::ModalKeymap;
let mut mk = ModalKeymap::defaults();
let Some(modal_val) = self.raw.get(TUI_KEYS_MODAL_NAMESPACE) else {
return Ok(mk);
};
let table = modal_val.as_table().ok_or_else(|| {
GwmError::Config(format!(
"tui.keys.modal: expected a table of modal contexts, got {}",
modal_val.type_str()
))
})?;
for (ctx_key, value) in table {
match value {
toml::Value::Table(sub) => walk_modal_context(ctx_key, sub, &mut mk)?,
other => {
return Err(GwmError::Config(format!(
"tui.keys.modal.{}: expected a context table, got {}",
ctx_key,
other.type_str()
)))
}
}
}
Ok(mk)
}
}
const TUI_KEYS_MODAL_NAMESPACE: &str = "modal";
fn as_chord_list(coord: &str, value: &toml::Value) -> Result<Vec<String>> {
let arr = value
.as_array()
.expect("as_chord_list called on a non-array — caller must match Value::Array first");
let mut out = Vec::with_capacity(arr.len());
for v in arr {
let s = v.as_str().ok_or_else(|| {
GwmError::Config(format!(
"tui.keys.{}: chord list must contain strings, got {}",
coord,
v.type_str()
))
})?;
out.push(s.to_string());
}
Ok(out)
}
fn is_modal_context_group(path: &str) -> bool {
let prefix = format!("{}.", path);
crate::tui::modal_keymap::KeyContext::all()
.iter()
.any(|c| c.config_path().starts_with(&prefix))
}
fn walk_modal_context(
ctx_path: &str,
table: &toml::Table,
mk: &mut crate::tui::modal_keymap::ModalKeymap,
) -> Result<()> {
use crate::tui::modal_keymap::{parse_single, KeyContext, ModalAction};
let ctx = KeyContext::from_config_path(ctx_path);
if ctx.is_none() && !is_modal_context_group(ctx_path) {
return Err(GwmError::Config(format!(
"tui.keys.modal.{}: unknown modal context (run `gwm tui keys` for the list)",
ctx_path
)));
}
for (key, value) in table {
match value {
toml::Value::Array(_) => {
let ctx = ctx.ok_or_else(|| {
GwmError::Config(format!(
"tui.keys.modal.{path}: {path:?} is a context group, not a leaf — bind under a stage (e.g. {path}.<stage>)",
path = ctx_path
))
})?;
let coord = format!("modal.{}.{}", ctx_path, key);
let chords = as_chord_list(&coord, value)?;
let action = ModalAction::from_context_verb(ctx, key).ok_or_else(|| {
GwmError::Config(format!(
"tui.keys.modal.{}: unknown verb {:?} (run `gwm tui keys` for the list)",
ctx_path, key
))
})?;
let mut parsed = Vec::with_capacity(chords.len());
for s in &chords {
parsed.push(parse_single(s).map_err(|e| rewrap(&coord, e))?);
}
mk.apply_override(action, parsed)
.map_err(|e| rewrap(&format!("tui.keys.modal.{}", ctx_path), e))?;
}
toml::Value::Table(sub) => {
let child = format!("{}.{}", ctx_path, key);
walk_modal_context(&child, sub, mk)?;
}
other => {
return Err(GwmError::Config(format!(
"tui.keys.modal.{}.{}: expected an array of keys or a sub-table, got {}",
ctx_path,
key,
other.type_str()
)))
}
}
}
Ok(())
}
fn rewrap(coord: &str, e: GwmError) -> GwmError {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!("{}: {}", coord, inner))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ThemeConfig {
pub preset: Option<String>,
#[serde(flatten)]
pub overrides: std::collections::BTreeMap<String, String>,
}
impl ThemeConfig {
pub fn resolve(&self) -> Result<crate::tui::theme::Theme> {
use crate::tui::theme::Theme;
let mut theme = match &self.preset {
Some(name) => Theme::preset(name).ok_or_else(|| {
let known = crate::tui::theme::preset_names().join(", ");
GwmError::Config(format!("theme.preset: unknown preset {:?} (known: {})", name, known))
})?,
None => Theme::default(),
};
for (role, value) in &self.overrides {
if role == "preset" {
continue;
}
theme.apply_override(role, value)?;
}
Ok(theme)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TuiOpenConfig {
#[serde(default)]
pub mode: TuiOpenMode,
#[serde(default, deserialize_with = "deserialize_optional_non_empty")]
pub shell_cmd: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_non_empty")]
pub editor_cmd: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TuiOpenMode {
#[default]
Shell,
Editor,
Finder,
}
fn deserialize_optional_non_empty<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::deserialize(d)?;
Ok(opt.filter(|s| !s.is_empty()))
}
impl TuiConfig {
pub const MAX_CONFIRM_COUNTDOWN_SECS: u32 = 5;
pub fn effective_confirm_countdown_secs(&self) -> u32 {
self.confirm_countdown_secs.min(Self::MAX_CONFIRM_COUNTDOWN_SECS)
}
}
fn default_confirm_countdown_secs() -> u32 {
3
}
fn default_auto_refresh_secs() -> u64 {
60
}
fn read_config_value(path: &Path) -> Result<toml::Value> {
let raw = std::fs::read_to_string(path)?;
let val: toml::Value = toml::from_str(&raw)?;
Ok(val)
}
fn merge_toml(base: toml::Value, over: toml::Value) -> toml::Value {
match (base, over) {
(toml::Value::Table(mut b), toml::Value::Table(o)) => {
for (k, ov) in o {
let merged = match b.remove(&k) {
Some(bv) => merge_toml(bv, ov),
None => ov,
};
b.insert(k, merged);
}
toml::Value::Table(b)
}
(_, over) => over,
}
}
fn load_config_section<T>(repo_root: Option<&Path>, key: &str) -> Result<T>
where
T: serde::de::DeserializeOwned + Default,
{
load_config_section_layered(global_config_path().as_deref(), repo_root, key)
}
fn load_config_section_layered<T>(global: Option<&Path>, repo_root: Option<&Path>, key: &str) -> Result<T>
where
T: serde::de::DeserializeOwned + Default,
{
let global_val = match global {
Some(p) if p.exists() => Some(read_config_value(p)?),
_ => None,
};
let repo_val = match repo_root {
Some(root) => {
let repo_path = root.join(CONFIG_FILE);
if repo_path.exists() {
Some(read_config_value(&repo_path)?)
} else {
None
}
}
None => None,
};
let merged = match (global_val, repo_val) {
(None, None) => return Ok(T::default()),
(Some(g), None) => g,
(None, Some(r)) => r,
(Some(g), Some(r)) => merge_toml(g, r),
};
match merged.get(key) {
Some(section) => section
.clone()
.try_into()
.map_err(|e| GwmError::Config(format!("invalid `[{key}]` config: {e}"))),
None => Ok(T::default()),
}
}
pub(crate) fn flatten_value(prefix: &str, value: &toml::Value, rows: &mut Vec<(String, String)>) {
match value {
toml::Value::Table(table) => {
for (key, value) in table {
let next = if prefix.is_empty() {
key.to_string()
} else {
format!("{}.{}", prefix, key)
};
flatten_value(&next, value, rows);
}
}
toml::Value::Array(values) if values.iter().all(toml::Value::is_table) => {
for (i, value) in values.iter().enumerate() {
flatten_value(&format!("{}[{}]", prefix, i), value, rows);
}
}
_ => rows.push((prefix.to_string(), format_list_value(value))),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
Default,
User,
Repo,
}
impl ConfigSource {
pub fn label(self) -> &'static str {
match self {
ConfigSource::Default => "default",
ConfigSource::User => "user",
ConfigSource::Repo => "repo",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigRow {
pub key: String,
pub value: String,
pub source: ConfigSource,
}
fn declared_keys(path: &Path) -> Result<std::collections::HashSet<String>> {
if !path.exists() {
return Ok(std::collections::HashSet::new());
}
let value = read_config_value(path)?;
let mut rows = Vec::new();
flatten_value("", &value, &mut rows);
Ok(rows.into_iter().map(|(key, _)| key).collect())
}
pub fn resolved_rows(repo_root: &Path, global_path: Option<&Path>) -> Result<Vec<ConfigRow>> {
let cfg = Config::load_layered(repo_root, global_path)?;
let value = toml::Value::try_from(cfg).map_err(|e| GwmError::Config(e.to_string()))?;
let mut flat = Vec::new();
flatten_value("", &value, &mut flat);
let repo_keys = declared_keys(&repo_root.join(CONFIG_FILE))?;
let user_keys = match global_path {
Some(p) => declared_keys(p)?,
None => std::collections::HashSet::new(),
};
Ok(
flat
.into_iter()
.map(|(key, value)| {
let source = if repo_keys.contains(&key) {
ConfigSource::Repo
} else if user_keys.contains(&key) {
ConfigSource::User
} else {
ConfigSource::Default
};
ConfigRow { key, value, source }
})
.collect(),
)
}
pub(crate) fn format_list_value(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => format!("{:?}", s),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Datetime(d) => d.to_string(),
toml::Value::Array(_) | toml::Value::Table(_) => value.to_string(),
}
}
pub fn global_config_path_in(config_home: &Path) -> PathBuf {
config_home.join("gwm").join("config.toml")
}
pub fn resolve_gwm_config_file(
filename: &str,
xdg_config_home: Option<&Path>,
home_dir: Option<&Path>,
platform_config_dir: Option<&Path>,
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
let join = |home: &Path| home.join("gwm").join(filename);
if let Some(xdg) = xdg_config_home {
return Some(join(xdg));
}
let dotconfig = home_dir.map(|h| join(&h.join(".config")));
let platform = platform_config_dir.map(join);
if let Some(p) = dotconfig.as_ref().filter(|p| exists(p)) {
return Some(p.clone());
}
if let Some(p) = platform.as_ref().filter(|p| exists(p)) {
return Some(p.clone());
}
dotconfig.or(platform)
}
pub fn resolve_global_config_path(
xdg_config_home: Option<&Path>,
home_dir: Option<&Path>,
platform_config_dir: Option<&Path>,
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
resolve_gwm_config_file("config.toml", xdg_config_home, home_dir, platform_config_dir, exists)
}
pub fn global_config_path() -> Option<PathBuf> {
if crate::trust::env_truthy("GWM_NO_GLOBAL_CONFIG") {
return None;
}
let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty());
let home = dirs::home_dir();
let platform = dirs::config_dir();
resolve_global_config_path(
xdg.as_deref().map(Path::new),
home.as_deref(),
platform.as_deref(),
|p| p.exists(),
)
}
impl Config {
pub fn global_forge_host(host: &str) -> Option<crate::forge::ForgeKind> {
Self::forge_host_in(&global_config_path()?, host)
}
pub fn forge_host_in(global_path: &Path, host: &str) -> Option<crate::forge::ForgeKind> {
let value = read_config_value(global_path).ok()?;
let hosts = value.get("forge_hosts")?.as_table()?;
hosts
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(host))
.and_then(|(_, v)| v.clone().try_into().ok())
}
pub fn load_for_repo(repo_root: &Path) -> Result<Self> {
Self::load_layered(repo_root, global_config_path().as_deref())
}
pub fn load_exec_config(repo_root: &Path) -> Result<ExecConfig> {
let cfg: ExecConfig = load_config_section(Some(repo_root), "exec")?;
for (name, p) in &cfg.profiles {
crate::exec::validate_exec_profile_command(name, &p.command)?;
}
Ok(cfg)
}
pub fn load_exec_jobs_default(repo_root: Option<&Path>) -> Result<Option<u32>> {
let cfg: ExecConfig = load_config_section(repo_root, "exec")?;
Ok(cfg.jobs)
}
pub fn load_exec_jobs_default_layered(global: Option<&Path>, repo_root: Option<&Path>) -> Result<Option<u32>> {
let cfg: ExecConfig = load_config_section_layered(global, repo_root, "exec")?;
Ok(cfg.jobs)
}
pub fn load_clean_config(repo_root: Option<&Path>) -> Result<CleanConfig> {
let cfg: CleanConfig = load_config_section(repo_root, "clean")?;
for (name, p) in &cfg.profiles {
crate::clean::validate_clean_profile_dirs(name, &p.dirs)?;
}
Ok(cfg)
}
pub fn load_layered(repo_root: &Path, global_path: Option<&Path>) -> Result<Self> {
let cfg = Self::merge_layered(repo_root, global_path)?;
cfg.validate_branch_types()?;
cfg.validate_bootstrap_paths()?;
cfg.validate_bootstrap_guards()?;
cfg.validate_labels()?;
cfg.validate_aliases()?;
cfg.validate_tui_keys()?;
cfg.validate_theme()?;
cfg.validate_profiles()?;
Ok(cfg)
}
pub(crate) fn validate_profiles(&self) -> Result<()> {
for (name, p) in &self.exec.profiles {
crate::exec::validate_exec_profile_command(name, &p.command)?;
}
for (name, p) in &self.clean.profiles {
crate::clean::validate_clean_profile_dirs(name, &p.dirs)?;
}
Ok(())
}
pub(crate) fn merge_layered(repo_root: &Path, global_path: Option<&Path>) -> Result<Self> {
let repo_path = repo_root.join(CONFIG_FILE);
let global_val = match global_path {
Some(p) if p.exists() => Some(read_config_value(p)?),
_ => None,
};
let repo_val = if repo_path.exists() {
Some(read_config_value(&repo_path)?)
} else {
None
};
Ok(match (global_val, repo_val) {
(None, None) => Self::default(),
(Some(g), None) => g.try_into()?,
(None, Some(r)) => r.try_into()?,
(Some(g), Some(r)) => merge_toml(g, r).try_into()?,
})
}
pub(crate) fn validate_tui_keys(&self) -> Result<()> {
self.tui.keys.resolved_keymap().map(|_| ())?;
self.tui.keys.resolved_modal_keymap().map(|_| ())
}
pub(crate) fn validate_theme(&self) -> Result<()> {
self.theme.resolve().map(|_| ())
}
pub(crate) fn validate_aliases(&self) -> Result<()> {
crate::aliases::validate_aliases(&self.aliases, ".gwm.toml `[aliases]`")
}
pub(crate) fn validate_labels(&self) -> Result<()> {
for (i, l) in self.labels.iter().enumerate() {
crate::labels::validate_label_name(&l.name).map_err(|e| {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!("labels[{}]: {}", i, inner))
})?;
}
Ok(())
}
pub fn validate_bootstrap_guards(&self) -> Result<()> {
for (gi, g) in self.bootstrap.guard.iter().enumerate() {
for (pi, pat) in g.deny_patterns.iter().enumerate() {
regex::Regex::new(pat).map_err(|e| {
GwmError::Config(format!(
"bootstrap.guard[{}].deny_patterns[{}] '{}': invalid pattern {:?} — regex: {}",
gi, pi, g.name, pat, e
))
})?;
}
}
Ok(())
}
pub(crate) fn validate_bootstrap_paths(&self) -> Result<()> {
for (i, c) in self.bootstrap.copy.iter().enumerate() {
check_relative_no_traversal(&c.to, &format!("bootstrap.copy[{}].to", i))?;
}
for (i, g) in self.bootstrap.guard.iter().enumerate() {
if let Some(ex) = &g.example_file {
check_relative_no_traversal(ex, &format!("bootstrap.guard[{}].example_file", i))?;
}
}
for (key, fb) in &self.bootstrap.fallback {
check_relative_no_traversal(&fb.target, &format!("bootstrap.fallback.{}.target", key))?;
}
Ok(())
}
pub(crate) fn validate_branch_types(&self) -> Result<()> {
let name_re = regex::Regex::new(r"^[a-z]+$").expect("static regex compiles");
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for entry in &self.branch_types {
if entry.name.is_empty() {
return Err(GwmError::Config(
"branch_types: entry has empty `name`; use a lowercase ASCII alpha token (e.g. \"feat\")".into(),
));
}
if !name_re.is_match(&entry.name) {
return Err(GwmError::Config(format!(
"branch_types: invalid `name = \"{}\"`; must match ^[a-z]+$ to be a valid branch-prefix \
(lowercase letters only, no digits, no dashes — git refs and the branch parser rely on this)",
entry.name
)));
}
if !seen.insert(entry.name.as_str()) {
return Err(GwmError::Config(format!(
"branch_types: duplicate entry for `name = \"{}\"` — each branch type must be declared at most once",
entry.name
)));
}
}
Ok(())
}
pub fn write_default(repo_root: &Path) -> Result<PathBuf> {
Self::write_preset(repo_root, crate::presets::GENERIC_BODY)
}
pub fn write_preset(repo_root: &Path, body: &str) -> Result<PathBuf> {
let target = repo_root.join(CONFIG_FILE);
if target.exists() {
return Err(GwmError::Config(format!("{} already exists", target.display())));
}
std::fs::write(&target, body)?;
Ok(target)
}
pub fn guard_by_name(&self, name: &str) -> Option<&Guard> {
self.bootstrap.guard.iter().find(|g| g.name == name)
}
pub fn resolved_branch_types(&self) -> ResolvedBranchTypes {
if self.branch_types.is_empty() {
ResolvedBranchTypes {
types: crate::naming::default_branch_types(),
source: BranchTypesSource::Default,
}
} else {
ResolvedBranchTypes {
types: self.branch_types.clone(),
source: BranchTypesSource::Config,
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitTuiConfig {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub fullscreen: Option<bool>,
}
impl GitTuiConfig {
pub fn resolved(&self) -> ResolvedLauncher {
ResolvedLauncher {
command: self.command.clone().unwrap_or_else(|| "lazygit -p {path}".into()),
fullscreen: self.fullscreen.unwrap_or(true),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewConfig {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub fullscreen: Option<bool>,
#[serde(default)]
pub tool: Option<String>,
#[serde(default = "default_skip_when_no_changes")]
pub skip_when_no_changes: bool,
#[serde(default)]
pub default_base: Option<String>,
}
impl Default for ReviewConfig {
fn default() -> Self {
Self {
command: None,
fullscreen: None,
tool: None,
skip_when_no_changes: default_skip_when_no_changes(),
default_base: None,
}
}
}
fn default_skip_when_no_changes() -> bool {
true
}
fn check_relative_no_traversal(value: &str, field: &str) -> Result<()> {
if value.is_empty() {
return Err(GwmError::Config(format!(
"{}: empty path is not a valid bootstrap target",
field
)));
}
let p = Path::new(value);
if p.is_absolute() {
return Err(GwmError::Config(format!(
"{}: {:?} is an absolute path — only relative paths under the base directory are allowed",
field, value
)));
}
for comp in p.components() {
match comp {
std::path::Component::ParentDir => {
return Err(GwmError::Config(format!(
"{}: {:?} contains '..' traversal — only relative paths under the base directory are allowed",
field, value
)));
}
std::path::Component::Prefix(_) => {
return Err(GwmError::Config(format!(
"{}: {:?} contains a Windows drive prefix — only relative paths under the base directory are allowed",
field, value
)));
}
_ => {}
}
}
Ok(())
}
impl ReviewConfig {
pub fn resolved(&self) -> Option<ResolvedLauncher> {
if let Some(cmd) = self.command.as_ref().filter(|s| !s.trim().is_empty()) {
return Some(ResolvedLauncher {
command: cmd.clone(),
fullscreen: self.fullscreen.unwrap_or(false),
});
}
let tool = self.tool.as_deref()?.trim();
if tool.is_empty() {
return None;
}
let (cmd, fullscreen_default) = review_tool_preset(tool)?;
Some(ResolvedLauncher {
command: cmd.into(),
fullscreen: self.fullscreen.unwrap_or(fullscreen_default),
})
}
pub fn has_shadowed_tool(&self) -> bool {
self.command.as_ref().is_some_and(|s| !s.trim().is_empty())
&& self.tool.as_ref().is_some_and(|s| !s.trim().is_empty())
}
}
pub fn review_tool_preset(tool: &str) -> Option<(&'static str, bool)> {
Some(match tool {
"lumen" => ("lumen diff {base}..{head}", true),
"claude" => ("claude --print 'review the diff {base}..{head}'", false),
"codex" => ("codex review {base}..{head}", false),
"aider" => ("aider --message 'review {base}..{head}'", true),
"gh" => ("gh pr view --web", false),
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLauncher {
pub command: String,
pub fullscreen: bool,
}
pub fn expand_placeholders(
template: &str,
repo: &str,
type_: Option<&str>,
issue: Option<&str>,
desc: Option<&str>,
repo_path: Option<&Path>,
) -> Result<String> {
let home = dirs::home_dir()
.ok_or_else(|| GwmError::Config("cannot resolve $HOME".into()))?
.to_string_lossy()
.to_string();
let value_for = |token: &str| -> Option<String> {
match token {
"{home}" => Some(home.clone()),
"{repo}" => Some(repo.to_string()),
"{type}" => type_.map(str::to_string),
"{issue}" => issue.map(str::to_string),
"{desc}" => desc.map(str::to_string),
"{repo_path}" => repo_path.map(|p| p.to_string_lossy().into_owned()),
"{repo_parent}" => repo_path
.and_then(|p| p.parent())
.map(|p| p.to_string_lossy().into_owned()),
_ => None,
}
};
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
let Some(close) = rest[open..].find('}').map(|at| open + at) else {
break;
};
let start = rest[open..close].rfind('{').map(|at| open + at).unwrap_or(open);
out.push_str(&rest[..start]);
let token = &rest[start..=close];
match value_for(token) {
Some(value) => out.push_str(&value),
None => out.push_str(token),
}
rest = &rest[close + 1..];
}
out.push_str(rest);
let expanded = shellexpand::tilde(&out).to_string();
Ok(expanded)
}