use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub const DEFAULT_TEMPLATE: &str = "{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}";
#[derive(Debug, Clone)]
pub struct TemplateVars {
pub repo_parent: PathBuf,
pub repo: String,
pub repo_root: PathBuf,
pub branch: String,
pub branch_slug: String,
pub home: PathBuf,
}
pub fn render(template: &str, vars: &TemplateVars) -> Result<PathBuf> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let close = after
.find('}')
.ok_or_else(|| template_error(template, "unterminated '{' in template"))?;
let name = &after[..close];
out.push_str(&substitute(name, vars).ok_or_else(|| {
template_error(template, &format!("unknown template variable {{{name}}}"))
})?);
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(PathBuf::from(out))
}
fn substitute(name: &str, vars: &TemplateVars) -> Option<String> {
Some(match name {
"repo_parent" => vars.repo_parent.to_string_lossy().into_owned(),
"repo" => vars.repo.clone(),
"repo_root" => vars.repo_root.to_string_lossy().into_owned(),
"branch" => vars.branch.clone(),
"branch_slug" => vars.branch_slug.clone(),
"home" => vars.home.to_string_lossy().into_owned(),
_ => return None,
})
}
fn template_error(template: &str, reason: &str) -> Error {
Error::Config {
file: "path_template".into(),
key: template.into(),
reason: reason.into(),
}
}
pub fn ensure_outside_git(rendered: &Path, git_dir: &Path) -> Result<()> {
if rendered.starts_with(git_dir) {
return Err(Error::Config {
file: "path_template".into(),
key: "path_template".into(),
reason: format!(
"template renders a worktree inside the git directory: {}",
rendered.display()
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn vars() -> TemplateVars {
TemplateVars {
repo_parent: PathBuf::from("/home/u/code"),
repo: "proj".into(),
repo_root: PathBuf::from("/home/u/code/proj"),
branch: "feature/login".into(),
branch_slug: "feature-login".into(),
home: PathBuf::from("/home/u"),
}
}
#[test]
fn renders_default_sibling_template() {
let p = render(DEFAULT_TEMPLATE, &vars()).unwrap();
assert_eq!(
p,
PathBuf::from("/home/u/code/proj.worktrees/proj-feature-login")
);
}
#[test]
fn renders_subdir_and_central_presets() {
let sub = render("{repo_root}/.worktrees/{branch_slug}", &vars()).unwrap();
assert_eq!(
sub,
PathBuf::from("/home/u/code/proj/.worktrees/feature-login")
);
let central = render("{home}/worktrees/{repo}/{branch_slug}", &vars()).unwrap();
assert_eq!(
central,
PathBuf::from("/home/u/worktrees/proj/feature-login")
);
}
#[test]
fn repo_token_does_not_clobber_repo_parent_or_root() {
let p = render("{repo_parent}/{repo}/{repo_root}/{branch}", &vars()).unwrap();
assert_eq!(
p,
PathBuf::from("/home/u/code/proj//home/u/code/proj/feature/login")
);
}
#[test]
fn unknown_variable_is_config_error() {
let err = render("{repo}/{bogus}", &vars()).unwrap_err();
assert!(matches!(err, Error::Config { .. }));
assert!(err.to_string().contains("bogus"));
}
#[test]
fn unterminated_brace_is_config_error() {
let err = render("{repo}/{branch", &vars()).unwrap_err();
assert!(matches!(err, Error::Config { .. }));
assert!(err.to_string().contains("unterminated"));
}
#[test]
fn literal_text_without_variables() {
assert_eq!(
render("/tmp/fixed", &vars()).unwrap(),
PathBuf::from("/tmp/fixed")
);
}
#[test]
fn ensure_outside_git_rejects_inside_and_allows_outside() {
let git_dir = Path::new("/home/u/code/proj/.git");
let inside = Path::new("/home/u/code/proj/.git/worktrees/x");
let outside = Path::new("/home/u/code/proj.worktrees/x");
assert!(ensure_outside_git(inside, git_dir).is_err());
assert!(ensure_outside_git(outside, git_dir).is_ok());
let sibling = Path::new("/home/u/code/proj/.gitignore-dir/x");
assert!(ensure_outside_git(sibling, git_dir).is_ok());
}
}