Skip to main content

wt/
template.rs

1//! Worktree-store path-template rendering (spec §6).
2//!
3//! New worktrees are placed according to a configurable template with the
4//! variables `{repo_parent}`, `{repo}`, `{repo_root}`, `{branch}`,
5//! `{branch_slug}`, and `{home}`. [`render`] substitutes them; [`ensure_outside_git`]
6//! rejects a rendered path that would land inside the `.git` directory.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12/// The default worktree-store template (spec §6 "Sibling").
13pub const DEFAULT_TEMPLATE: &str = "{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}";
14
15/// The values substituted into a path template. For a bare repository these
16/// resolve against the bare repo's own directory (spec §6).
17#[derive(Debug, Clone)]
18pub struct TemplateVars {
19    /// Directory containing the repo root.
20    pub repo_parent: PathBuf,
21    /// Repo directory name.
22    pub repo: String,
23    /// Repo root (or bare repo path).
24    pub repo_root: PathBuf,
25    /// Raw branch name.
26    pub branch: String,
27    /// Filesystem-safe branch slug.
28    pub branch_slug: String,
29    /// The user's home directory.
30    pub home: PathBuf,
31}
32
33/// Renders `template`, substituting the [`TemplateVars`]. An unknown `{var}` or
34/// an unterminated `{` is a configuration error.
35pub fn render(template: &str, vars: &TemplateVars) -> Result<PathBuf> {
36    let mut out = String::with_capacity(template.len());
37    let mut rest = template;
38    while let Some(open) = rest.find('{') {
39        out.push_str(&rest[..open]);
40        let after = &rest[open + 1..];
41        let close = after
42            .find('}')
43            .ok_or_else(|| template_error(template, "unterminated '{' in template"))?;
44        let name = &after[..close];
45        out.push_str(&substitute(name, vars).ok_or_else(|| {
46            template_error(template, &format!("unknown template variable {{{name}}}"))
47        })?);
48        rest = &after[close + 1..];
49    }
50    out.push_str(rest);
51    Ok(PathBuf::from(out))
52}
53
54/// Returns the substitution for a variable name, or `None` if unknown.
55fn substitute(name: &str, vars: &TemplateVars) -> Option<String> {
56    Some(match name {
57        "repo_parent" => vars.repo_parent.to_string_lossy().into_owned(),
58        "repo" => vars.repo.clone(),
59        "repo_root" => vars.repo_root.to_string_lossy().into_owned(),
60        "branch" => vars.branch.clone(),
61        "branch_slug" => vars.branch_slug.clone(),
62        "home" => vars.home.to_string_lossy().into_owned(),
63        _ => return None,
64    })
65}
66
67/// Builds a config error for a bad `path_template`.
68fn template_error(template: &str, reason: &str) -> Error {
69    Error::Config {
70        file: "path_template".into(),
71        key: template.into(),
72        reason: reason.into(),
73    }
74}
75
76/// Rejects a rendered worktree path that lies inside the repository's `.git`
77/// directory (spec §6).
78pub fn ensure_outside_git(rendered: &Path, git_dir: &Path) -> Result<()> {
79    if rendered.starts_with(git_dir) {
80        return Err(Error::Config {
81            file: "path_template".into(),
82            key: "path_template".into(),
83            reason: format!(
84                "template renders a worktree inside the git directory: {}",
85                rendered.display()
86            ),
87        });
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn vars() -> TemplateVars {
97        TemplateVars {
98            repo_parent: PathBuf::from("/home/u/code"),
99            repo: "proj".into(),
100            repo_root: PathBuf::from("/home/u/code/proj"),
101            branch: "feature/login".into(),
102            branch_slug: "feature-login".into(),
103            home: PathBuf::from("/home/u"),
104        }
105    }
106
107    #[test]
108    fn renders_default_sibling_template() {
109        let p = render(DEFAULT_TEMPLATE, &vars()).unwrap();
110        assert_eq!(
111            p,
112            PathBuf::from("/home/u/code/proj.worktrees/proj-feature-login")
113        );
114    }
115
116    #[test]
117    fn renders_subdir_and_central_presets() {
118        let sub = render("{repo_root}/.worktrees/{branch_slug}", &vars()).unwrap();
119        assert_eq!(
120            sub,
121            PathBuf::from("/home/u/code/proj/.worktrees/feature-login")
122        );
123        let central = render("{home}/worktrees/{repo}/{branch_slug}", &vars()).unwrap();
124        assert_eq!(
125            central,
126            PathBuf::from("/home/u/worktrees/proj/feature-login")
127        );
128    }
129
130    #[test]
131    fn repo_token_does_not_clobber_repo_parent_or_root() {
132        let p = render("{repo_parent}/{repo}/{repo_root}/{branch}", &vars()).unwrap();
133        assert_eq!(
134            p,
135            PathBuf::from("/home/u/code/proj//home/u/code/proj/feature/login")
136        );
137    }
138
139    #[test]
140    fn unknown_variable_is_config_error() {
141        let err = render("{repo}/{bogus}", &vars()).unwrap_err();
142        assert!(matches!(err, Error::Config { .. }));
143        assert!(err.to_string().contains("bogus"));
144    }
145
146    #[test]
147    fn unterminated_brace_is_config_error() {
148        let err = render("{repo}/{branch", &vars()).unwrap_err();
149        assert!(matches!(err, Error::Config { .. }));
150        assert!(err.to_string().contains("unterminated"));
151    }
152
153    #[test]
154    fn literal_text_without_variables() {
155        assert_eq!(
156            render("/tmp/fixed", &vars()).unwrap(),
157            PathBuf::from("/tmp/fixed")
158        );
159    }
160
161    #[test]
162    fn ensure_outside_git_rejects_inside_and_allows_outside() {
163        let git_dir = Path::new("/home/u/code/proj/.git");
164        let inside = Path::new("/home/u/code/proj/.git/worktrees/x");
165        let outside = Path::new("/home/u/code/proj.worktrees/x");
166        assert!(ensure_outside_git(inside, git_dir).is_err());
167        assert!(ensure_outside_git(outside, git_dir).is_ok());
168        // A sibling whose name merely starts with the git dir name is allowed.
169        let sibling = Path::new("/home/u/code/proj/.gitignore-dir/x");
170        assert!(ensure_outside_git(sibling, git_dir).is_ok());
171    }
172}