use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const REPO_CONFIG_FILENAME: &str = ".limb.toml";
pub const GLOBAL_CONFIG_DIRNAME: &str = "limb";
pub const GLOBAL_CONFIG_FILENAME: &str = "config.toml";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GlobalConfigFile {
#[serde(default)]
pub projects: ProjectsFile,
#[serde(default)]
pub ui: UiFile,
#[serde(default)]
pub shell: ShellFile,
#[serde(default)]
pub git: GitFile,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectsFile {
#[serde(default)]
pub roots: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiFile {
pub theme: Option<String>,
pub show_upstream: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellFile {
pub prefix: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitFile {
pub default_base: Option<String>,
pub default_remote: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoConfigFile {
#[serde(default)]
pub worktrees: WorktreesFile,
#[serde(default)]
pub templates: BTreeMap<String, TemplateFile>,
#[serde(default)]
pub hooks: HooksFile,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorktreesFile {
#[serde(default)]
pub shared: Vec<PathBuf>,
pub shared_source: Option<PathBuf>,
pub base_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateFile {
pub base_branch: Option<String>,
pub name_pattern: Option<String>,
#[serde(default)]
pub hooks: HooksFile,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HooksFile {
pub pre_add: Option<PathBuf>,
pub post_add: Option<PathBuf>,
pub pre_remove: Option<PathBuf>,
pub post_remove: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Global {
pub projects_roots: Vec<PathBuf>,
pub ui_theme: String,
pub ui_show_upstream: bool,
pub shell_prefix: String,
pub git_default_base: Option<String>,
pub git_default_remote: String,
pub source: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Repo {
pub worktrees_shared: Vec<PathBuf>,
pub worktrees_shared_source: Option<PathBuf>,
pub worktrees_base_dir: PathBuf,
pub templates: BTreeMap<String, Template>,
pub hooks: Hooks,
pub source: PathBuf,
pub root: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
pub struct Template {
pub base_branch: Option<String>,
pub name_pattern: Option<String>,
pub hooks: Hooks,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Hooks {
pub pre_add: Option<PathBuf>,
pub post_add: Option<PathBuf>,
pub pre_remove: Option<PathBuf>,
pub post_remove: Option<PathBuf>,
}
impl Global {
pub fn load(explicit: Option<&Path>) -> Result<Self> {
let (file, source) = load_global_file(explicit)?;
Ok(Self::resolve(file, source))
}
#[must_use]
pub fn defaults() -> Self {
Self::resolve(GlobalConfigFile::default(), None)
}
fn resolve(file: GlobalConfigFile, source: Option<PathBuf>) -> Self {
let roots = file.projects.roots.into_iter().map(expand_tilde).collect();
Self {
projects_roots: roots,
ui_theme: file.ui.theme.unwrap_or_else(|| "vesper".into()),
ui_show_upstream: file.ui.show_upstream.unwrap_or(true),
shell_prefix: file.shell.prefix.unwrap_or_else(|| "gw".into()),
git_default_base: file.git.default_base,
git_default_remote: file.git.default_remote.unwrap_or_else(|| "origin".into()),
source,
}
}
}
impl Repo {
pub fn discover(start: &Path) -> Result<Option<Self>> {
let Some((file, source, root)) = discover_repo_file(start)? else {
return Ok(None);
};
Ok(Some(Self::resolve(file, source, root)))
}
fn resolve(file: RepoConfigFile, source: PathBuf, root: PathBuf) -> Self {
let templates = file
.templates
.into_iter()
.map(|(k, t)| (k, resolve_template(t)))
.collect();
Self {
worktrees_shared: file.worktrees.shared,
worktrees_shared_source: file.worktrees.shared_source,
worktrees_base_dir: file
.worktrees
.base_dir
.unwrap_or_else(|| PathBuf::from("..")),
templates,
hooks: resolve_hooks(file.hooks),
source,
root,
}
}
#[must_use]
pub fn resolved_shared_source(&self) -> PathBuf {
self.worktrees_shared_source
.clone()
.unwrap_or_else(|| self.root.join(".shared"))
}
}
fn resolve_template(t: TemplateFile) -> Template {
Template {
base_branch: t.base_branch,
name_pattern: t.name_pattern,
hooks: resolve_hooks(t.hooks),
}
}
fn resolve_hooks(h: HooksFile) -> Hooks {
Hooks {
pre_add: h.pre_add,
post_add: h.post_add,
pre_remove: h.pre_remove,
post_remove: h.post_remove,
}
}
fn load_global_file(explicit: Option<&Path>) -> Result<(GlobalConfigFile, Option<PathBuf>)> {
let path = match explicit {
Some(p) => p.to_path_buf(),
None => default_global_path()?,
};
if !path.exists() {
return Ok((GlobalConfigFile::default(), None));
}
let contents =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let file: GlobalConfigFile =
toml::from_str(&contents).with_context(|| format!("parse {}", path.display()))?;
Ok((file, Some(path)))
}
fn discover_repo_file(start: &Path) -> Result<Option<(RepoConfigFile, PathBuf, PathBuf)>> {
for dir in start.ancestors() {
let path = dir.join(REPO_CONFIG_FILENAME);
if path.is_file() {
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let file: RepoConfigFile =
toml::from_str(&contents).with_context(|| format!("parse {}", path.display()))?;
return Ok(Some((file, path, dir.to_path_buf())));
}
}
Ok(None)
}
fn default_global_path() -> Result<PathBuf> {
Ok(xdg_config_home()?
.join(GLOBAL_CONFIG_DIRNAME)
.join(GLOBAL_CONFIG_FILENAME))
}
fn xdg_config_home() -> Result<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(xdg));
}
dirs::home_dir()
.map(|h| h.join(".config"))
.context("cannot resolve home directory; set $HOME or $XDG_CONFIG_HOME")
}
fn expand_tilde(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy();
if let Some(stripped) = s.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(stripped);
}
if s == "~"
&& let Some(home) = dirs::home_dir()
{
return home;
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn global_defaults_when_file_absent() {
let (file, source) = load_global_file(Some(Path::new("/nonexistent/limb.toml"))).unwrap();
assert!(source.is_none());
let g = Global::resolve(file, source);
assert!(g.projects_roots.is_empty());
assert_eq!(g.ui_theme, "vesper");
assert!(g.ui_show_upstream);
assert_eq!(g.shell_prefix, "gw");
assert_eq!(g.git_default_remote, "origin");
}
#[test]
fn global_parses_minimal() {
let dir = tmp();
let p = dir.path().join("config.toml");
std::fs::write(
&p,
"[projects]\nroots = [\"~/dev/work\", \"~/dev/personal\"]\n",
)
.unwrap();
let g = Global::load(Some(&p)).unwrap();
assert_eq!(g.projects_roots.len(), 2);
let home = dirs::home_dir().unwrap();
assert_eq!(g.projects_roots[0], home.join("dev/work"));
}
#[test]
fn global_rejects_unknown_field() {
let dir = tmp();
let p = dir.path().join("config.toml");
std::fs::write(&p, "[ui]\nunknown_field = true\n").unwrap();
assert!(Global::load(Some(&p)).is_err());
}
#[test]
fn repo_discover_returns_none_when_absent() {
let dir = tmp();
assert!(Repo::discover(dir.path()).unwrap().is_none());
}
#[test]
fn repo_discovers_limb_toml() {
let dir = tmp();
std::fs::write(
dir.path().join(REPO_CONFIG_FILENAME),
"[worktrees]\nshared = [\".env\", \".mise.toml\"]\n",
)
.unwrap();
let r = Repo::discover(dir.path()).unwrap().unwrap();
assert_eq!(r.worktrees_shared.len(), 2);
assert_eq!(r.root, dir.path());
}
#[test]
fn repo_walks_ancestors() {
let dir = tmp();
let nested = dir.path().join("sub/deep");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
dir.path().join(REPO_CONFIG_FILENAME),
"[worktrees]\nshared = [\".env\"]\n",
)
.unwrap();
let r = Repo::discover(&nested).unwrap().unwrap();
assert_eq!(r.root, dir.path());
}
#[test]
fn repo_templates() {
let dir = tmp();
std::fs::write(
dir.path().join(REPO_CONFIG_FILENAME),
"[templates.feature]\nbase_branch = \"main\"\nname_pattern = \"feat-{slug}\"\n",
)
.unwrap();
let r = Repo::discover(dir.path()).unwrap().unwrap();
let feat = r.templates.get("feature").unwrap();
assert_eq!(feat.base_branch.as_deref(), Some("main"));
assert_eq!(feat.name_pattern.as_deref(), Some("feat-{slug}"));
}
#[test]
fn repo_hooks() {
let dir = tmp();
std::fs::write(
dir.path().join(REPO_CONFIG_FILENAME),
"[hooks]\npost_add = \"scripts/setup.sh\"\npre_remove = \"scripts/teardown.sh\"\n",
)
.unwrap();
let r = Repo::discover(dir.path()).unwrap().unwrap();
assert_eq!(
r.hooks.post_add.as_deref(),
Some(Path::new("scripts/setup.sh"))
);
assert_eq!(
r.hooks.pre_remove.as_deref(),
Some(Path::new("scripts/teardown.sh"))
);
}
#[test]
fn repo_default_base_dir_is_parent() {
let dir = tmp();
std::fs::write(dir.path().join(REPO_CONFIG_FILENAME), "").unwrap();
let r = Repo::discover(dir.path()).unwrap().unwrap();
assert_eq!(r.worktrees_base_dir, PathBuf::from(".."));
}
#[test]
fn repo_shared_source_defaults_to_shared_subdir() {
let dir = tmp();
std::fs::write(dir.path().join(REPO_CONFIG_FILENAME), "").unwrap();
let r = Repo::discover(dir.path()).unwrap().unwrap();
assert_eq!(r.resolved_shared_source(), dir.path().join(".shared"));
}
#[test]
fn tilde_expansion() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_tilde(PathBuf::from("~/foo")), home.join("foo"));
assert_eq!(expand_tilde(PathBuf::from("~")), home);
assert_eq!(expand_tilde(PathBuf::from("/abs")), PathBuf::from("/abs"));
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
prop_compose! {
fn arb_path()(s in "[a-zA-Z0-9._/-]{1,16}") -> PathBuf {
PathBuf::from(s)
}
}
prop_compose! {
fn arb_hooks_file()(
pre_add in prop::option::of(arb_path()),
post_add in prop::option::of(arb_path()),
pre_remove in prop::option::of(arb_path()),
post_remove in prop::option::of(arb_path()),
) -> HooksFile {
HooksFile { pre_add, post_add, pre_remove, post_remove }
}
}
prop_compose! {
fn arb_global_file()(
roots in prop::collection::vec(arb_path(), 0..3),
theme in prop::option::of("[a-z]{2,8}"),
show_upstream in prop::option::of(any::<bool>()),
prefix in prop::option::of("[a-z]{1,4}"),
default_base in prop::option::of("[a-z]{2,8}"),
default_remote in prop::option::of("[a-z]{2,8}"),
) -> GlobalConfigFile {
GlobalConfigFile {
projects: ProjectsFile { roots },
ui: UiFile { theme, show_upstream },
shell: ShellFile { prefix },
git: GitFile { default_base, default_remote },
}
}
}
prop_compose! {
fn arb_template_file()(
base_branch in prop::option::of("[a-z][a-z0-9-]{0,8}"),
name_pattern in prop::option::of("[a-z][a-z0-9-]{0,8}"),
hooks in arb_hooks_file(),
) -> TemplateFile {
TemplateFile { base_branch, name_pattern, hooks }
}
}
prop_compose! {
fn arb_repo_file()(
shared in prop::collection::vec(arb_path(), 0..3),
shared_source in prop::option::of(arb_path()),
base_dir in prop::option::of(arb_path()),
templates in prop::collection::btree_map(
"[a-z][a-z0-9_]{0,8}",
arb_template_file(),
0..3,
),
hooks in arb_hooks_file(),
) -> RepoConfigFile {
RepoConfigFile {
worktrees: WorktreesFile { shared, shared_source, base_dir },
templates,
hooks,
}
}
}
proptest! {
#[test]
fn global_file_toml_round_trips(g in arb_global_file()) {
let s = toml::to_string(&g).expect("serialize");
let parsed: GlobalConfigFile = toml::from_str(&s).expect("parse");
prop_assert_eq!(g, parsed);
}
#[test]
fn repo_file_toml_round_trips(r in arb_repo_file()) {
let s = toml::to_string(&r).expect("serialize");
let parsed: RepoConfigFile = toml::from_str(&s).expect("parse");
prop_assert_eq!(r, parsed);
}
}
}