use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub worktree_destination: Option<PathBuf>,
pub branch_separator: Option<String>,
#[serde(default)]
pub project_prefix: bool,
#[serde(default)]
pub scripts: HashMap<String, Vec<ScriptEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScriptEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
let config: Self = toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
config.validate()?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
self.validate()?;
let path = config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create config directory {}", parent.display())
})?;
}
let serialized = toml::to_string_pretty(self).context("failed to serialize config")?;
let parent = path
.parent()
.ok_or_else(|| anyhow!("invalid config path {}", path.display()))?;
let mut temp = NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
use std::io::Write;
temp.write_all(serialized.as_bytes())
.context("failed to write temporary config file")?;
temp.persist(&path)
.map_err(|err| err.error)
.with_context(|| format!("failed to persist config to {}", path.display()))?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if let Some(separator) = &self.branch_separator
&& separator.is_empty()
{
bail!("branch_separator cannot be empty")
}
if let Some(destination) = &self.worktree_destination {
let expanded = expand_path(destination);
if !expanded.is_absolute() {
bail!("worktree_destination must resolve to an absolute path")
}
}
for (project, scripts) in &self.scripts {
let mut seen = std::collections::HashSet::new();
for script in scripts {
if script.name.trim().is_empty() {
bail!("script name for project '{project}' cannot be empty")
}
if !seen.insert(script.name.clone()) {
bail!("duplicate script '{}' for project '{project}'", script.name)
}
match (&script.path, &script.text) {
(Some(_), Some(_)) => bail!(
"script '{}' for project '{project}' cannot have both path and text",
script.name
),
(None, None) => bail!(
"script '{}' for project '{project}' must have either a path or text",
script.name
),
(None, Some(text)) if text.trim().is_empty() => bail!(
"script '{}' for project '{project}' text cannot be empty",
script.name
),
_ => {}
}
}
}
Ok(())
}
pub fn worktree_destination(&self) -> Result<PathBuf> {
let path = self
.worktree_destination
.as_ref()
.ok_or_else(|| anyhow!("worktree_destination is not configured"))?;
Ok(expand_path(path))
}
pub fn scripts_for_project(&self, project: &str) -> &[ScriptEntry] {
self.scripts.get(project).map(Vec::as_slice).unwrap_or(&[])
}
pub fn add_script(&mut self, project: &str, script: ScriptEntry) -> Result<()> {
let scripts = self.scripts.entry(project.to_owned()).or_default();
if scripts.iter().any(|existing| existing.name == script.name) {
bail!(
"script '{}' already exists for project '{project}'",
script.name
);
}
scripts.push(script);
scripts.sort_by(|a, b| a.name.cmp(&b.name));
Ok(())
}
pub fn remove_script(&mut self, project: &str, name: &str) -> Result<()> {
let scripts = self
.scripts
.get_mut(project)
.ok_or_else(|| anyhow!("project '{project}' has no configured scripts"))?;
let before = scripts.len();
scripts.retain(|script| script.name != name);
if scripts.len() == before {
bail!("script '{name}' does not exist for project '{project}'")
}
if scripts.is_empty() {
self.scripts.remove(project);
}
Ok(())
}
}
pub fn default_config_template() -> &'static str {
r#"# bra configuration
# Base directory for project worktrees.
# worktree_destination = "/home/you/worktrees"
# Nest worktrees under a per-project alias directory.
# project_prefix = true
# Optional separator used to flatten branch names.
# branch_separator = "_"
# Project scripts keyed by project alias.
#
# [[scripts.my-project]]
# name = "bootstrap"
# path = "~/bin/bootstrap-my-project.sh"
#
# [[scripts.my-project]]
# name = "install"
# text = "bun install"
"#
}
pub fn config_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("BRA_CONFIG") {
return Ok(PathBuf::from(path));
}
let dirs =
BaseDirs::new().ok_or_else(|| anyhow!("failed to determine XDG config directory"))?;
Ok(dirs.config_dir().join("bra").join("config.toml"))
}
pub fn expand_path(path: &Path) -> PathBuf {
let raw = path.to_string_lossy();
if raw == "~"
&& let Ok(home) = std::env::var("HOME")
{
return PathBuf::from(home);
}
if let Some(stripped) = raw.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return PathBuf::from(home).join(stripped);
}
path.to_path_buf()
}
pub fn flatten_branch(branch: &str, separator: Option<&str>) -> String {
match separator {
Some(separator) => branch.replace('/', separator),
None => branch.to_owned(),
}
}
pub fn worktree_path(config: &Config, alias: &str, branch: &str) -> Result<PathBuf> {
let destination = config.worktree_destination()?;
let flattened = flatten_branch(branch, config.branch_separator.as_deref());
let base = if config.project_prefix {
destination.join(alias)
} else {
destination
};
Ok(base.join(flattened))
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn flattens_branch_name_when_separator_is_set() {
assert_eq!(flatten_branch("feature/test", Some("-")), "feature-test");
}
#[test]
fn keeps_branch_name_nested_without_separator() {
assert_eq!(flatten_branch("feature/test", None), "feature/test");
}
#[test]
fn expands_home_prefix() {
let home = std::env::var("HOME").expect("HOME should be set in tests");
let probe = PathBuf::from(home).join("bra-test-home-child");
assert_eq!(expand_path(Path::new("~/bra-test-home-child")), probe);
}
#[test]
fn worktree_path_flattens_branch_component() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let config = Config {
worktree_destination: Some(root.clone()),
branch_separator: Some("-".to_owned()),
project_prefix: false,
scripts: HashMap::new(),
};
assert_eq!(
worktree_path(&config, "my-project", "feature/test").unwrap(),
root.join("feature-test")
);
}
#[test]
fn worktree_path_can_prefix_project_alias() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let config = Config {
worktree_destination: Some(root.clone()),
branch_separator: Some("-".to_owned()),
project_prefix: true,
scripts: HashMap::new(),
};
assert_eq!(
worktree_path(&config, "my-project", "feature/test").unwrap(),
root.join("my-project").join("feature-test")
);
}
#[test]
fn rejects_duplicate_script_names() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let script_a = temp.path().join("script-a");
let script_b = temp.path().join("script-b");
let config = Config {
worktree_destination: Some(root),
branch_separator: None,
project_prefix: false,
scripts: HashMap::from([(
"my-project".to_owned(),
vec![
ScriptEntry {
name: "setup".to_owned(),
path: Some(script_a),
text: None,
},
ScriptEntry {
name: "setup".to_owned(),
path: Some(script_b),
text: None,
},
],
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn rejects_script_with_path_and_text() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let script = temp.path().join("script");
let config = Config {
worktree_destination: Some(root),
branch_separator: None,
project_prefix: false,
scripts: HashMap::from([(
"my-project".to_owned(),
vec![ScriptEntry {
name: "setup".to_owned(),
path: Some(script),
text: Some("echo setup".to_owned()),
}],
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn rejects_script_without_path_or_text() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let config = Config {
worktree_destination: Some(root),
branch_separator: None,
project_prefix: false,
scripts: HashMap::from([(
"my-project".to_owned(),
vec![ScriptEntry {
name: "setup".to_owned(),
path: None,
text: None,
}],
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn rejects_blank_inline_script_text() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("worktrees-root");
let config = Config {
worktree_destination: Some(root),
branch_separator: None,
project_prefix: false,
scripts: HashMap::from([(
"my-project".to_owned(),
vec![ScriptEntry {
name: "setup".to_owned(),
path: None,
text: Some(" \n\t".to_owned()),
}],
)]),
};
assert!(config.validate().is_err());
}
#[test]
fn default_config_path_uses_xdg_location() {
let path = config_path().unwrap();
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("config.toml")
);
assert_eq!(
path.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str()),
Some("bra")
);
}
}